haru-cli 0.1.2__tar.gz → 0.1.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.
- {haru_cli-0.1.2 → haru_cli-0.1.3}/CHANGELOG.md +13 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/PKG-INFO +8 -3
- {haru_cli-0.1.2 → haru_cli-0.1.3}/README.md +7 -2
- {haru_cli-0.1.2 → haru_cli-0.1.3}/config/haru.yaml +4 -2
- {haru_cli-0.1.2 → haru_cli-0.1.3}/pyproject.toml +1 -1
- haru_cli-0.1.3/src/haru/auth/identity.py +169 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/auth/session.py +24 -8
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/commands/config.py +2 -6
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/commands/login.py +24 -2
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/schema.py +9 -3
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/haru.yaml +4 -2
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_cli.py +14 -1
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_config_command.py +0 -2
- haru_cli-0.1.3/tests/unit/test_identity.py +172 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_session.py +60 -4
- {haru_cli-0.1.2 → haru_cli-0.1.3}/uv.lock +1 -1
- {haru_cli-0.1.2 → haru_cli-0.1.3}/.github/workflows/ci.yml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/.github/workflows/release.yml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/.gitignore +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/.kiro/steering/coding-standards.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/.kiro/steering/git-workflow.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/.kiro/steering/product.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/.kiro/steering/security.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/.kiro/steering/structure.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/.kiro/steering/tech.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/.kiro/steering/testing.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/.pre-commit-config.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/.python-version +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/CLAUDE.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/LICENSE +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/config/agents.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/config/guardrails.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/config/logging.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/config/mcp.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/config/models.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/config/prompts/researcher.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/config/prompts/supervisor.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/config/prompts/writer.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/docs/configuration.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/renovate.json +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/__main__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/agents/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/agents/factory.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/agents/orchestration.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/auth/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/auth/cache.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/auth/pkce.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/auth/sso.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/cli.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/commands/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/commands/agents.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/commands/chat.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/commands/run.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/commands/session.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/commands/streaming.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/loader.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/agents.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/logging.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/mcp.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/models.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/prompts/researcher.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/prompts/supervisor.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/prompts/writer.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/errors.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/models/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/models/bedrock.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/observability/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/observability/guardrails.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/observability/telemetry.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/sessions/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/sessions/manager.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/steering/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/steering/prompts.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/tools/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/tools/mcp.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/tools/registry.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/conftest.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/integration/test_packaging.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_agent_factory.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_bedrock.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_cache.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_config_loader.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_guardrails.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_main.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_mcp.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_orchestration.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_pkce.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_prompts.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_sessions.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_sso.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_telemetry.py +0 -0
|
@@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
11
|
|
|
12
|
+
- Browser-only login: `haru login` now discovers your accessible AWS
|
|
13
|
+
accounts and permission-set roles from Identity Center after sign-in
|
|
14
|
+
(auto-selected when unambiguous, prompted otherwise) and remembers the
|
|
15
|
+
choice — no IAM role name or account id env var needed upfront.
|
|
16
|
+
`auth.sso.account_id`/`role_name` remain available as optional pins.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- `haru config init` no longer asks for an IAM role name; the
|
|
21
|
+
`HARU_AWS_ACCOUNT_ID` step is gone from the quickstart.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
12
25
|
- Sampling controls (`temperature`, `top_p`, `top_k`, `seed`) across three
|
|
13
26
|
surfaces: model entries, per-agent `sampling:` blocks, and CLI/REPL
|
|
14
27
|
overrides (`--temperature/--top-p/--top-k/--seed`, `/sampling`).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: haru-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Secure, scriptable CLI for Amazon Bedrock Claude models via the AWS Strands Agents SDK.
|
|
5
5
|
Author: haru-cli maintainers
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -84,12 +84,17 @@ Inside `haru chat`, slash commands switch targets mid-session:
|
|
|
84
84
|
|
|
85
85
|
```bash
|
|
86
86
|
haru config init # writes ~/.config/haru (asks for your SSO start URL)
|
|
87
|
-
|
|
88
|
-
haru login
|
|
87
|
+
haru login # browser sign-in; account and role discovered for you
|
|
89
88
|
haru run "Summarise the Converse API in two sentences."
|
|
90
89
|
haru chat --agent supervisor --session-id demo
|
|
91
90
|
```
|
|
92
91
|
|
|
92
|
+
`haru login` is browser-only: after Identity Center sign-in, your accessible
|
|
93
|
+
AWS accounts and permission-set roles are discovered automatically (like
|
|
94
|
+
`aws configure sso`), auto-selected when there is exactly one, prompted
|
|
95
|
+
otherwise, and remembered for later sessions. Pin `auth.sso.account_id` /
|
|
96
|
+
`role_name` in configuration only if you want to skip the discovery.
|
|
97
|
+
|
|
93
98
|
Authentication uses the same PKCE authorization-code flow as `aws sso login`,
|
|
94
99
|
caching tokens in the botocore-compatible schema under `~/.aws/sso/cache`
|
|
95
100
|
(0600) so standard AWS tooling can consume and refresh the same cache. Access
|
|
@@ -56,12 +56,17 @@ Inside `haru chat`, slash commands switch targets mid-session:
|
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
58
|
haru config init # writes ~/.config/haru (asks for your SSO start URL)
|
|
59
|
-
|
|
60
|
-
haru login
|
|
59
|
+
haru login # browser sign-in; account and role discovered for you
|
|
61
60
|
haru run "Summarise the Converse API in two sentences."
|
|
62
61
|
haru chat --agent supervisor --session-id demo
|
|
63
62
|
```
|
|
64
63
|
|
|
64
|
+
`haru login` is browser-only: after Identity Center sign-in, your accessible
|
|
65
|
+
AWS accounts and permission-set roles are discovered automatically (like
|
|
66
|
+
`aws configure sso`), auto-selected when there is exactly one, prompted
|
|
67
|
+
otherwise, and remembered for later sessions. Pin `auth.sso.account_id` /
|
|
68
|
+
`role_name` in configuration only if you want to skip the discovery.
|
|
69
|
+
|
|
65
70
|
Authentication uses the same PKCE authorization-code flow as `aws sso login`,
|
|
66
71
|
caching tokens in the botocore-compatible schema under `~/.aws/sso/cache`
|
|
67
72
|
(0600) so standard AWS tooling can consume and refresh the same cache. Access
|
|
@@ -8,8 +8,10 @@ auth:
|
|
|
8
8
|
registration_scopes:
|
|
9
9
|
- sso:account:access
|
|
10
10
|
client_name: haru-cli
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
# Optional pins. When unset, your AWS account and role are discovered
|
|
12
|
+
# during 'haru login' (browser sign-in) and remembered.
|
|
13
|
+
# account_id: "123456789012"
|
|
14
|
+
# role_name: HaruBedrockInvoke
|
|
13
15
|
callback_port: 0 # 0 selects an ephemeral loopback port
|
|
14
16
|
browser: true
|
|
15
17
|
bedrock_region: us-east-1
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Discover and persist the AWS account and role behind an SSO sign-in.
|
|
2
|
+
|
|
3
|
+
Kiro-style browser sign-in needs no upfront AWS knowledge: after the token
|
|
4
|
+
exchange, the accessible accounts and permission-set roles are listed from
|
|
5
|
+
IAM Identity Center (as ``aws configure sso`` does), auto-selected when there
|
|
6
|
+
is exactly one, and persisted next to the token cache for later sessions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import boto3
|
|
17
|
+
from botocore.exceptions import ClientError
|
|
18
|
+
|
|
19
|
+
from haru.auth.cache import cache_path
|
|
20
|
+
from haru.auth.sso import SsoToken
|
|
21
|
+
from haru.config.schema import SsoConfig
|
|
22
|
+
from haru.errors import AuthError
|
|
23
|
+
|
|
24
|
+
Chooser = Callable[[str, list[str]], int]
|
|
25
|
+
"""Callback picking one option: (question, labels) -> chosen index."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class SelectedIdentity:
|
|
30
|
+
"""The AWS account and role selected for Bedrock access."""
|
|
31
|
+
|
|
32
|
+
account_id: str
|
|
33
|
+
role_name: str
|
|
34
|
+
account_name: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def identity_path(start_url: str, cache_dir: Path | None = None) -> Path:
|
|
38
|
+
"""Return the persisted-identity path for ``start_url``."""
|
|
39
|
+
token_path = cache_path(start_url, cache_dir)
|
|
40
|
+
return token_path.with_name(f"{token_path.stem}-haru-identity.json")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def write_identity(
|
|
44
|
+
identity: SelectedIdentity, start_url: str, cache_dir: Path | None = None
|
|
45
|
+
) -> Path:
|
|
46
|
+
"""Persist the selected identity next to the token cache."""
|
|
47
|
+
path = identity_path(start_url, cache_dir)
|
|
48
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
49
|
+
payload = {
|
|
50
|
+
"accountId": identity.account_id,
|
|
51
|
+
"roleName": identity.role_name,
|
|
52
|
+
"accountName": identity.account_name,
|
|
53
|
+
}
|
|
54
|
+
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
55
|
+
path.chmod(0o600)
|
|
56
|
+
return path
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def read_identity(start_url: str, cache_dir: Path | None = None) -> SelectedIdentity | None:
|
|
60
|
+
"""Read the persisted identity; None when absent or unreadable."""
|
|
61
|
+
path = identity_path(start_url, cache_dir)
|
|
62
|
+
if not path.is_file():
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
66
|
+
return SelectedIdentity(
|
|
67
|
+
account_id=payload["accountId"],
|
|
68
|
+
role_name=payload["roleName"],
|
|
69
|
+
account_name=payload.get("accountName"),
|
|
70
|
+
)
|
|
71
|
+
except (json.JSONDecodeError, KeyError):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def select_identity(
|
|
76
|
+
sso_cfg: SsoConfig,
|
|
77
|
+
token: SsoToken,
|
|
78
|
+
*,
|
|
79
|
+
sso_client: Any | None = None,
|
|
80
|
+
cache_dir: Path | None = None,
|
|
81
|
+
chooser: Chooser | None = None,
|
|
82
|
+
) -> SelectedIdentity:
|
|
83
|
+
"""Resolve the account and role for this sign-in and persist the choice.
|
|
84
|
+
|
|
85
|
+
Configuration pins win (``account_id``/``role_name``, or the account id
|
|
86
|
+
from ``account_id_env``); anything unpinned is discovered from Identity
|
|
87
|
+
Center. With multiple choices and no ``chooser``, raises AuthError.
|
|
88
|
+
"""
|
|
89
|
+
if sso_client is None:
|
|
90
|
+
sso_client = boto3.Session().client("sso", region_name=sso_cfg.sso_region)
|
|
91
|
+
|
|
92
|
+
account_id = _pinned_account(sso_cfg)
|
|
93
|
+
account_name: str | None = None
|
|
94
|
+
if account_id is None:
|
|
95
|
+
accounts = _list_accounts(sso_client, token.access_token)
|
|
96
|
+
chosen = _choose(
|
|
97
|
+
"Choose the AWS account to use",
|
|
98
|
+
accounts,
|
|
99
|
+
lambda account: f"{account['accountId']} {account.get('accountName', '')}".strip(),
|
|
100
|
+
chooser,
|
|
101
|
+
)
|
|
102
|
+
account_id = chosen["accountId"]
|
|
103
|
+
account_name = chosen.get("accountName")
|
|
104
|
+
|
|
105
|
+
role_name = sso_cfg.role_name
|
|
106
|
+
if role_name is None:
|
|
107
|
+
roles = _list_roles(sso_client, token.access_token, account_id)
|
|
108
|
+
role_name = _choose(f"Choose the role for account {account_id}", roles, str, chooser)
|
|
109
|
+
|
|
110
|
+
identity = SelectedIdentity(
|
|
111
|
+
account_id=account_id, role_name=role_name, account_name=account_name
|
|
112
|
+
)
|
|
113
|
+
write_identity(identity, sso_cfg.start_url, cache_dir)
|
|
114
|
+
return identity
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _pinned_account(sso_cfg: SsoConfig) -> str | None:
|
|
118
|
+
if sso_cfg.account_id is not None:
|
|
119
|
+
return sso_cfg.account_id
|
|
120
|
+
if sso_cfg.account_id_env is not None:
|
|
121
|
+
return os.environ.get(sso_cfg.account_id_env)
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _list_accounts(sso_client: Any, access_token: str) -> list[dict[str, Any]]:
|
|
126
|
+
try:
|
|
127
|
+
paginator = sso_client.get_paginator("list_accounts")
|
|
128
|
+
accounts = [
|
|
129
|
+
account
|
|
130
|
+
for page in paginator.paginate(accessToken=access_token)
|
|
131
|
+
for account in page.get("accountList", [])
|
|
132
|
+
]
|
|
133
|
+
except ClientError as exc:
|
|
134
|
+
raise AuthError(f"Listing accessible AWS accounts failed: {exc}") from exc
|
|
135
|
+
if not accounts:
|
|
136
|
+
raise AuthError("This sign-in grants access to no AWS accounts; contact your admin")
|
|
137
|
+
return sorted(accounts, key=lambda account: str(account["accountId"]))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _list_roles(sso_client: Any, access_token: str, account_id: str) -> list[str]:
|
|
141
|
+
try:
|
|
142
|
+
paginator = sso_client.get_paginator("list_account_roles")
|
|
143
|
+
roles = [
|
|
144
|
+
role["roleName"]
|
|
145
|
+
for page in paginator.paginate(accessToken=access_token, accountId=account_id)
|
|
146
|
+
for role in page.get("roleList", [])
|
|
147
|
+
]
|
|
148
|
+
except ClientError as exc:
|
|
149
|
+
raise AuthError(f"Listing roles for account {account_id} failed: {exc}") from exc
|
|
150
|
+
if not roles:
|
|
151
|
+
raise AuthError(f"No roles are available in account {account_id}; contact your admin")
|
|
152
|
+
return sorted(roles)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _choose[Item](
|
|
156
|
+
question: str,
|
|
157
|
+
options: list[Item],
|
|
158
|
+
label: Callable[[Item], str],
|
|
159
|
+
chooser: Chooser | None,
|
|
160
|
+
) -> Item:
|
|
161
|
+
if len(options) == 1:
|
|
162
|
+
return options[0]
|
|
163
|
+
if chooser is None:
|
|
164
|
+
raise AuthError(
|
|
165
|
+
f"{question}: multiple options available; re-run 'haru login' interactively"
|
|
166
|
+
" or pin the choice in configuration"
|
|
167
|
+
)
|
|
168
|
+
index = chooser(question, [label(option) for option in options])
|
|
169
|
+
return options[index]
|
|
@@ -14,9 +14,10 @@ from botocore.exceptions import (
|
|
|
14
14
|
)
|
|
15
15
|
|
|
16
16
|
from haru.auth.cache import read_token_cache, write_token_cache
|
|
17
|
+
from haru.auth.identity import read_identity
|
|
17
18
|
from haru.auth.sso import SsoToken
|
|
18
|
-
from haru.config.schema import AuthConfig
|
|
19
|
-
from haru.errors import AuthExpiredError
|
|
19
|
+
from haru.config.schema import AuthConfig, SsoConfig
|
|
20
|
+
from haru.errors import AuthExpiredError
|
|
20
21
|
|
|
21
22
|
_REFRESH_WINDOW = timedelta(minutes=15)
|
|
22
23
|
_RELOGIN_HINT = "run 'haru login'"
|
|
@@ -42,16 +43,12 @@ def build_boto3_session(
|
|
|
42
43
|
if token.expires_at <= datetime.now(UTC) + _REFRESH_WINDOW:
|
|
43
44
|
token = _refresh_token(config, token, oidc=oidc, cache_dir=cache_dir)
|
|
44
45
|
|
|
45
|
-
account_id =
|
|
46
|
-
if account_id is None:
|
|
47
|
-
raise ConfigError(
|
|
48
|
-
f"Environment variable {sso.account_id_env!r} must hold the target AWS account id"
|
|
49
|
-
)
|
|
46
|
+
account_id, role_name = _resolve_identity(sso, cache_dir)
|
|
50
47
|
if sso_client is None:
|
|
51
48
|
sso_client = boto3.Session().client("sso", region_name=sso.sso_region)
|
|
52
49
|
try:
|
|
53
50
|
response = sso_client.get_role_credentials(
|
|
54
|
-
roleName=
|
|
51
|
+
roleName=role_name, accountId=account_id, accessToken=token.access_token
|
|
55
52
|
)
|
|
56
53
|
except (ClientError, SSOTokenLoadError, UnauthorizedSSOTokenError, TokenRetrievalError) as exc:
|
|
57
54
|
raise AuthExpiredError(f"SSO credentials rejected; {_RELOGIN_HINT}") from exc
|
|
@@ -65,6 +62,25 @@ def build_boto3_session(
|
|
|
65
62
|
)
|
|
66
63
|
|
|
67
64
|
|
|
65
|
+
def _resolve_identity(sso: SsoConfig, cache_dir: Path | None) -> tuple[str, str]:
|
|
66
|
+
"""Resolve the account id and role: config pins, env var, then the login choice."""
|
|
67
|
+
stored = read_identity(sso.start_url, cache_dir)
|
|
68
|
+
|
|
69
|
+
account_id = sso.account_id
|
|
70
|
+
if account_id is None and sso.account_id_env is not None:
|
|
71
|
+
account_id = os.environ.get(sso.account_id_env)
|
|
72
|
+
if account_id is None and stored is not None:
|
|
73
|
+
account_id = stored.account_id
|
|
74
|
+
|
|
75
|
+
role_name = sso.role_name
|
|
76
|
+
if role_name is None and stored is not None:
|
|
77
|
+
role_name = stored.role_name
|
|
78
|
+
|
|
79
|
+
if account_id is None or role_name is None:
|
|
80
|
+
raise AuthExpiredError(f"No AWS account/role selected yet; {_RELOGIN_HINT}")
|
|
81
|
+
return account_id, role_name
|
|
82
|
+
|
|
83
|
+
|
|
68
84
|
def _refresh_token(
|
|
69
85
|
config: AuthConfig, token: SsoToken, *, oidc: Any | None, cache_dir: Path | None
|
|
70
86
|
) -> SsoToken:
|
|
@@ -65,7 +65,6 @@ def config() -> None:
|
|
|
65
65
|
"--sso-region", default="us-east-1", prompt="Identity Center region", show_default=True
|
|
66
66
|
)
|
|
67
67
|
@click.option("--bedrock-region", default="us-east-1", prompt="Bedrock region", show_default=True)
|
|
68
|
-
@click.option("--role-name", default="HaruBedrockInvoke", prompt="IAM role name", show_default=True)
|
|
69
68
|
@click.option(
|
|
70
69
|
"--guardrail-id",
|
|
71
70
|
default="",
|
|
@@ -78,7 +77,6 @@ def init( # noqa: PLR0913, PLR0917 - one option per prompted setting; Click pas
|
|
|
78
77
|
start_url: str,
|
|
79
78
|
sso_region: str,
|
|
80
79
|
bedrock_region: str,
|
|
81
|
-
role_name: str,
|
|
82
80
|
guardrail_id: str,
|
|
83
81
|
force: bool,
|
|
84
82
|
) -> None:
|
|
@@ -92,7 +90,6 @@ def init( # noqa: PLR0913, PLR0917 - one option per prompted setting; Click pas
|
|
|
92
90
|
"__START_URL__": start_url,
|
|
93
91
|
"__SSO_REGION__": sso_region,
|
|
94
92
|
"__BEDROCK_REGION__": bedrock_region,
|
|
95
|
-
"__ROLE_NAME__": role_name,
|
|
96
93
|
}
|
|
97
94
|
templates = resources.files("haru.config.templates")
|
|
98
95
|
(target / "prompts").mkdir(parents=True, exist_ok=True)
|
|
@@ -113,9 +110,8 @@ def init( # noqa: PLR0913, PLR0917 - one option per prompted setting; Click pas
|
|
|
113
110
|
|
|
114
111
|
click.echo(f"Configuration written to {target}.")
|
|
115
112
|
click.echo("Next steps:")
|
|
116
|
-
click.echo(" 1.
|
|
117
|
-
click.echo(" 2. haru
|
|
118
|
-
click.echo(" 3. haru chat")
|
|
113
|
+
click.echo(" 1. haru login (browser sign-in; your account and role are discovered)")
|
|
114
|
+
click.echo(" 2. haru chat")
|
|
119
115
|
|
|
120
116
|
|
|
121
117
|
@config.command()
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
"""The ``haru login`` command: interactive IAM Identity Center sign-in.
|
|
1
|
+
"""The ``haru login`` command: interactive IAM Identity Center sign-in.
|
|
2
|
+
|
|
3
|
+
Browser-only sign-in: no IAM role or account id needs to be known upfront.
|
|
4
|
+
After the token exchange, the accessible accounts and roles are discovered
|
|
5
|
+
from Identity Center, auto-selected when unambiguous, and remembered.
|
|
6
|
+
"""
|
|
2
7
|
|
|
3
8
|
import webbrowser
|
|
4
9
|
from pathlib import Path
|
|
@@ -6,18 +11,28 @@ from pathlib import Path
|
|
|
6
11
|
import click
|
|
7
12
|
|
|
8
13
|
from haru.auth.cache import write_token_cache
|
|
14
|
+
from haru.auth.identity import select_identity
|
|
9
15
|
from haru.auth.sso import run_login
|
|
10
16
|
from haru.config import load_config
|
|
11
17
|
from haru.errors import HaruError
|
|
12
18
|
|
|
13
19
|
|
|
20
|
+
def _prompt_choice(question: str, labels: list[str]) -> int:
|
|
21
|
+
"""Interactive numbered chooser for accounts and roles."""
|
|
22
|
+
click.echo(f"{question}:")
|
|
23
|
+
for number, label in enumerate(labels, start=1):
|
|
24
|
+
click.echo(f" {number}. {label}")
|
|
25
|
+
choice: int = click.prompt("Enter a number", type=click.IntRange(1, len(labels)))
|
|
26
|
+
return choice - 1
|
|
27
|
+
|
|
28
|
+
|
|
14
29
|
@click.command()
|
|
15
30
|
@click.option(
|
|
16
31
|
"--config",
|
|
17
32
|
"config_path",
|
|
18
33
|
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
19
34
|
default=None,
|
|
20
|
-
help="Path to haru.yaml (default: config/haru.yaml).",
|
|
35
|
+
help="Path to haru.yaml (default: ./config/haru.yaml, then ~/.config/haru/haru.yaml).",
|
|
21
36
|
)
|
|
22
37
|
def login(config_path: Path | None) -> None:
|
|
23
38
|
"""Sign in to AWS IAM Identity Center via the browser (PKCE)."""
|
|
@@ -33,6 +48,13 @@ def login(config_path: Path | None) -> None:
|
|
|
33
48
|
|
|
34
49
|
token = run_login(auth, opener=_open)
|
|
35
50
|
path = write_token_cache(token, auth.sso.start_url, auth.sso.sso_region)
|
|
51
|
+
identity = select_identity(auth.sso, token, chooser=_prompt_choice)
|
|
36
52
|
except HaruError as exc:
|
|
37
53
|
raise click.ClickException(str(exc)) from exc
|
|
38
54
|
click.echo(f"Login successful. Token cached at {path}.")
|
|
55
|
+
account_label = (
|
|
56
|
+
f"{identity.account_id} ({identity.account_name})"
|
|
57
|
+
if identity.account_name
|
|
58
|
+
else identity.account_id
|
|
59
|
+
)
|
|
60
|
+
click.echo(f"Using account {account_label}, role {identity.role_name}.")
|
|
@@ -24,14 +24,20 @@ class AppConfig(_FrozenModel):
|
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
class SsoConfig(_FrozenModel):
|
|
27
|
-
"""IAM Identity Center (SSO) login settings.
|
|
27
|
+
"""IAM Identity Center (SSO) login settings.
|
|
28
|
+
|
|
29
|
+
``account_id`` and ``role_name`` are optional: when unset, the account
|
|
30
|
+
and permission-set role are discovered interactively during
|
|
31
|
+
``haru login`` (browser-only sign-in, like the AWS CLI's SSO flow).
|
|
32
|
+
"""
|
|
28
33
|
|
|
29
34
|
start_url: str
|
|
30
35
|
sso_region: str
|
|
31
36
|
registration_scopes: tuple[str, ...] = ("sso:account:access",)
|
|
32
37
|
client_name: str = "haru-cli"
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
account_id: str | None = None
|
|
39
|
+
account_id_env: str | None = None
|
|
40
|
+
role_name: str | None = None
|
|
35
41
|
callback_port: int = Field(default=0, ge=0, le=65535)
|
|
36
42
|
browser: bool = True
|
|
37
43
|
|
|
@@ -8,8 +8,10 @@ auth:
|
|
|
8
8
|
registration_scopes:
|
|
9
9
|
- sso:account:access
|
|
10
10
|
client_name: haru-cli
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
# Optional pins. When unset, your AWS account and role are discovered
|
|
12
|
+
# during 'haru login' (browser sign-in) and remembered.
|
|
13
|
+
# account_id: "123456789012"
|
|
14
|
+
# role_name: BedrockAccess
|
|
13
15
|
callback_port: 0 # 0 selects an ephemeral loopback port
|
|
14
16
|
browser: true
|
|
15
17
|
bedrock_region: __BEDROCK_REGION__
|
|
@@ -10,6 +10,7 @@ from typing import Any
|
|
|
10
10
|
from click.testing import CliRunner
|
|
11
11
|
from rich.console import Console
|
|
12
12
|
|
|
13
|
+
from haru.auth.identity import SelectedIdentity
|
|
13
14
|
from haru.auth.sso import ClientRegistration, SsoToken
|
|
14
15
|
from haru.cli import cli
|
|
15
16
|
from haru.commands.chat import run_chat
|
|
@@ -81,15 +82,23 @@ def test_login_command(runner: CliRunner, tmp_path: Path, mocker: Any) -> None:
|
|
|
81
82
|
write_cache = mocker.patch(
|
|
82
83
|
"haru.commands.login.write_token_cache", return_value=tmp_path / "cache.json"
|
|
83
84
|
)
|
|
85
|
+
select = mocker.patch(
|
|
86
|
+
"haru.commands.login.select_identity",
|
|
87
|
+
return_value=SelectedIdentity(
|
|
88
|
+
account_id="111122223333", role_name="BedrockAccess", account_name="Sandbox"
|
|
89
|
+
),
|
|
90
|
+
)
|
|
84
91
|
|
|
85
92
|
result = runner.invoke(cli, ["login", "--config", str(config_path)])
|
|
86
93
|
|
|
87
|
-
assert result.exit_code == 0
|
|
94
|
+
assert result.exit_code == 0, result.output
|
|
88
95
|
assert "Login successful" in result.output
|
|
89
96
|
assert "access-abc" not in result.output
|
|
97
|
+
assert "Using account 111122223333 (Sandbox), role BedrockAccess." in result.output
|
|
90
98
|
auth = run_login.call_args.args[0]
|
|
91
99
|
assert auth.sso.start_url == "https://example.awsapps.com/start"
|
|
92
100
|
write_cache.assert_called_once_with(token, auth.sso.start_url, "us-east-1")
|
|
101
|
+
assert select.call_args.args[0] == auth.sso
|
|
93
102
|
|
|
94
103
|
|
|
95
104
|
def make_config(tmp_path: Path) -> Path:
|
|
@@ -429,6 +438,10 @@ def test_login_opener_echoes_url_and_opens_browser(
|
|
|
429
438
|
config_path.write_text(BASE_CONFIG, encoding="utf-8")
|
|
430
439
|
browser_open = mocker.patch("haru.commands.login.webbrowser.open")
|
|
431
440
|
mocker.patch("haru.commands.login.write_token_cache", return_value=tmp_path / "cache.json")
|
|
441
|
+
mocker.patch(
|
|
442
|
+
"haru.commands.login.select_identity",
|
|
443
|
+
return_value=SelectedIdentity(account_id="111122223333", role_name="BedrockAccess"),
|
|
444
|
+
)
|
|
432
445
|
|
|
433
446
|
def fake_run_login(auth: Any, *, opener: Any) -> Any:
|
|
434
447
|
opener("https://oidc.us-east-1.amazonaws.com/authorize?x=1")
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Tests for account/role discovery and persistence after SSO sign-in."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime, timedelta
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from botocore.exceptions import ClientError
|
|
9
|
+
|
|
10
|
+
from haru.auth.identity import (
|
|
11
|
+
SelectedIdentity,
|
|
12
|
+
read_identity,
|
|
13
|
+
select_identity,
|
|
14
|
+
write_identity,
|
|
15
|
+
)
|
|
16
|
+
from haru.auth.sso import ClientRegistration, SsoToken
|
|
17
|
+
from haru.config.schema import SsoConfig
|
|
18
|
+
from haru.errors import AuthError
|
|
19
|
+
|
|
20
|
+
START_URL = "https://example.awsapps.com/start"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def make_sso_config(**overrides: Any) -> SsoConfig:
|
|
24
|
+
"""Build an SsoConfig for tests."""
|
|
25
|
+
return SsoConfig.model_validate(
|
|
26
|
+
{"start_url": START_URL, "sso_region": "us-east-1", **overrides}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def make_token() -> SsoToken:
|
|
31
|
+
"""Build a valid SsoToken for tests."""
|
|
32
|
+
now = datetime.now(UTC)
|
|
33
|
+
return SsoToken(
|
|
34
|
+
access_token="access-abc",
|
|
35
|
+
refresh_token=None,
|
|
36
|
+
expires_at=now + timedelta(hours=1),
|
|
37
|
+
registration=ClientRegistration(
|
|
38
|
+
client_id="client-123", client_secret="client-secret", expires_at=now
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FakeSso:
|
|
44
|
+
"""Minimal sso client stand-in with paginators."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, accounts: list[dict[str, Any]], roles: dict[str, list[str]]) -> None:
|
|
47
|
+
self._accounts = accounts
|
|
48
|
+
self._roles = roles
|
|
49
|
+
self.role_calls: list[str] = []
|
|
50
|
+
|
|
51
|
+
def get_paginator(self, operation: str) -> Any:
|
|
52
|
+
fake = self
|
|
53
|
+
|
|
54
|
+
class _Paginator:
|
|
55
|
+
def paginate(self, **kwargs: Any) -> list[dict[str, Any]]:
|
|
56
|
+
if operation == "list_accounts":
|
|
57
|
+
return [{"accountList": fake._accounts}]
|
|
58
|
+
fake.role_calls.append(kwargs["accountId"])
|
|
59
|
+
roles = fake._roles.get(kwargs["accountId"], [])
|
|
60
|
+
return [{"roleList": [{"roleName": name} for name in roles]}]
|
|
61
|
+
|
|
62
|
+
return _Paginator()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_identity_roundtrip(tmp_path: Path) -> None:
|
|
66
|
+
"""A written identity reads back identically, with 0600 permissions."""
|
|
67
|
+
identity = SelectedIdentity(account_id="111122223333", role_name="Dev", account_name="Sandbox")
|
|
68
|
+
path = write_identity(identity, START_URL, tmp_path)
|
|
69
|
+
assert path.stat().st_mode & 0o777 == 0o600
|
|
70
|
+
assert read_identity(START_URL, tmp_path) == identity
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_read_missing_identity(tmp_path: Path) -> None:
|
|
74
|
+
"""A missing identity file reads as None."""
|
|
75
|
+
assert read_identity(START_URL, tmp_path) is None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_single_account_and_role_auto_selected(tmp_path: Path) -> None:
|
|
79
|
+
"""One account with one role needs no interaction and is persisted."""
|
|
80
|
+
sso = FakeSso(
|
|
81
|
+
[{"accountId": "111122223333", "accountName": "Sandbox"}],
|
|
82
|
+
{"111122223333": ["BedrockAccess"]},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
identity = select_identity(make_sso_config(), make_token(), sso_client=sso, cache_dir=tmp_path)
|
|
86
|
+
|
|
87
|
+
assert identity == SelectedIdentity(
|
|
88
|
+
account_id="111122223333", role_name="BedrockAccess", account_name="Sandbox"
|
|
89
|
+
)
|
|
90
|
+
assert read_identity(START_URL, tmp_path) == identity
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_multiple_accounts_use_chooser(tmp_path: Path) -> None:
|
|
94
|
+
"""The chooser picks among multiple accounts and roles."""
|
|
95
|
+
sso = FakeSso(
|
|
96
|
+
[
|
|
97
|
+
{"accountId": "111122223333", "accountName": "Sandbox"},
|
|
98
|
+
{"accountId": "444455556666", "accountName": "Prod"},
|
|
99
|
+
],
|
|
100
|
+
{"444455556666": ["Admin", "ReadOnly"]},
|
|
101
|
+
)
|
|
102
|
+
questions: list[tuple[str, list[str]]] = []
|
|
103
|
+
|
|
104
|
+
def choose_last(question: str, labels: list[str]) -> int:
|
|
105
|
+
questions.append((question, labels))
|
|
106
|
+
return len(labels) - 1
|
|
107
|
+
|
|
108
|
+
identity = select_identity(
|
|
109
|
+
make_sso_config(), make_token(), sso_client=sso, cache_dir=tmp_path, chooser=choose_last
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
assert identity.account_id == "444455556666"
|
|
113
|
+
assert identity.role_name == "ReadOnly"
|
|
114
|
+
assert len(questions) == 2
|
|
115
|
+
assert "444455556666" in questions[0][1][1]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_multiple_accounts_without_chooser_raises(tmp_path: Path) -> None:
|
|
119
|
+
"""Multiple options with no chooser fail with guidance."""
|
|
120
|
+
sso = FakeSso(
|
|
121
|
+
[{"accountId": "111122223333"}, {"accountId": "444455556666"}],
|
|
122
|
+
{},
|
|
123
|
+
)
|
|
124
|
+
with pytest.raises(AuthError, match="multiple options"):
|
|
125
|
+
select_identity(make_sso_config(), make_token(), sso_client=sso, cache_dir=tmp_path)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_pinned_account_skips_account_listing(tmp_path: Path) -> None:
|
|
129
|
+
"""A configured account_id goes straight to role discovery."""
|
|
130
|
+
sso = FakeSso([], {"999900001111": ["BedrockAccess"]})
|
|
131
|
+
|
|
132
|
+
identity = select_identity(
|
|
133
|
+
make_sso_config(account_id="999900001111"),
|
|
134
|
+
make_token(),
|
|
135
|
+
sso_client=sso,
|
|
136
|
+
cache_dir=tmp_path,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
assert identity.account_id == "999900001111"
|
|
140
|
+
assert sso.role_calls == ["999900001111"]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_env_pinned_account(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
144
|
+
"""account_id_env still pins the account when set."""
|
|
145
|
+
monkeypatch.setenv("HARU_AWS_ACCOUNT_ID", "999900001111")
|
|
146
|
+
sso = FakeSso([], {"999900001111": ["BedrockAccess"]})
|
|
147
|
+
|
|
148
|
+
identity = select_identity(
|
|
149
|
+
make_sso_config(account_id_env="HARU_AWS_ACCOUNT_ID"),
|
|
150
|
+
make_token(),
|
|
151
|
+
sso_client=sso,
|
|
152
|
+
cache_dir=tmp_path,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
assert identity.account_id == "999900001111"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_no_accounts_raises(tmp_path: Path) -> None:
|
|
159
|
+
"""A sign-in granting no accounts fails with a clear message."""
|
|
160
|
+
sso = FakeSso([], {})
|
|
161
|
+
with pytest.raises(AuthError, match="no AWS accounts"):
|
|
162
|
+
select_identity(make_sso_config(), make_token(), sso_client=sso, cache_dir=tmp_path)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_listing_failure_wrapped(tmp_path: Path, mocker: Any) -> None:
|
|
166
|
+
"""botocore errors surface as AuthError."""
|
|
167
|
+
sso = mocker.Mock()
|
|
168
|
+
sso.get_paginator.return_value.paginate.side_effect = ClientError(
|
|
169
|
+
{"Error": {"Code": "UnauthorizedException", "Message": "no"}}, "ListAccounts"
|
|
170
|
+
)
|
|
171
|
+
with pytest.raises(AuthError, match="Listing accessible AWS accounts failed"):
|
|
172
|
+
select_identity(make_sso_config(), make_token(), sso_client=sso, cache_dir=tmp_path)
|
|
@@ -8,10 +8,11 @@ import pytest
|
|
|
8
8
|
from botocore.exceptions import ClientError
|
|
9
9
|
|
|
10
10
|
from haru.auth.cache import read_token_cache, write_token_cache
|
|
11
|
+
from haru.auth.identity import SelectedIdentity, write_identity
|
|
11
12
|
from haru.auth.session import build_boto3_session
|
|
12
13
|
from haru.auth.sso import ClientRegistration, SsoToken
|
|
13
14
|
from haru.config.schema import AuthConfig
|
|
14
|
-
from haru.errors import AuthExpiredError
|
|
15
|
+
from haru.errors import AuthExpiredError
|
|
15
16
|
|
|
16
17
|
START_URL = "https://example.awsapps.com/start"
|
|
17
18
|
|
|
@@ -168,9 +169,64 @@ def test_unauthorized_role_credentials_raises(tmp_path: Path, mocker: Any) -> No
|
|
|
168
169
|
build_boto3_session(make_config(), sso_client=sso_client, cache_dir=tmp_path)
|
|
169
170
|
|
|
170
171
|
|
|
171
|
-
def
|
|
172
|
-
|
|
172
|
+
def test_no_identity_anywhere_requires_login(
|
|
173
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
174
|
+
) -> None:
|
|
175
|
+
"""No pin, no env var, no stored identity: re-login is required."""
|
|
173
176
|
monkeypatch.delenv("HARU_AWS_ACCOUNT_ID", raising=False)
|
|
174
177
|
seed_cache(tmp_path)
|
|
175
|
-
with pytest.raises(
|
|
178
|
+
with pytest.raises(AuthExpiredError, match="haru login"):
|
|
176
179
|
build_boto3_session(make_config(), cache_dir=tmp_path)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_stored_identity_used_when_unpinned(tmp_path: Path, mocker: Any) -> None:
|
|
183
|
+
"""The identity chosen at login backs the session when config pins nothing."""
|
|
184
|
+
seed_cache(tmp_path)
|
|
185
|
+
write_identity(
|
|
186
|
+
SelectedIdentity(account_id="777788889999", role_name="StoredRole"),
|
|
187
|
+
START_URL,
|
|
188
|
+
tmp_path,
|
|
189
|
+
)
|
|
190
|
+
sso_client = mocker.Mock()
|
|
191
|
+
sso_client.get_role_credentials.return_value = ROLE_CREDENTIALS
|
|
192
|
+
config = AuthConfig.model_validate(
|
|
193
|
+
{
|
|
194
|
+
"sso": {"start_url": START_URL, "sso_region": "us-east-1"},
|
|
195
|
+
"bedrock_region": "eu-west-1",
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
build_boto3_session(config, sso_client=sso_client, cache_dir=tmp_path)
|
|
200
|
+
|
|
201
|
+
sso_client.get_role_credentials.assert_called_once_with(
|
|
202
|
+
roleName="StoredRole", accountId="777788889999", accessToken="access-abc"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_config_pins_beat_stored_identity(tmp_path: Path, mocker: Any) -> None:
|
|
207
|
+
"""Explicit config account/role win over the stored login choice."""
|
|
208
|
+
seed_cache(tmp_path)
|
|
209
|
+
write_identity(
|
|
210
|
+
SelectedIdentity(account_id="777788889999", role_name="StoredRole"),
|
|
211
|
+
START_URL,
|
|
212
|
+
tmp_path,
|
|
213
|
+
)
|
|
214
|
+
sso_client = mocker.Mock()
|
|
215
|
+
sso_client.get_role_credentials.return_value = ROLE_CREDENTIALS
|
|
216
|
+
config = AuthConfig.model_validate(
|
|
217
|
+
{
|
|
218
|
+
"sso": {
|
|
219
|
+
"start_url": START_URL,
|
|
220
|
+
"sso_region": "us-east-1",
|
|
221
|
+
"account_id": "123456789012",
|
|
222
|
+
"role_name": "PinnedRole",
|
|
223
|
+
},
|
|
224
|
+
"bedrock_region": "eu-west-1",
|
|
225
|
+
}
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
build_boto3_session(config, sso_client=sso_client, cache_dir=tmp_path)
|
|
229
|
+
|
|
230
|
+
sso_client.get_role_credentials.assert_called_once_with(
|
|
231
|
+
roleName="PinnedRole", accountId="123456789012", accessToken="access-abc"
|
|
232
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|