haru-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- haru/__init__.py +1 -0
- haru/__main__.py +12 -0
- haru/agents/__init__.py +18 -0
- haru/agents/factory.py +76 -0
- haru/agents/orchestration.py +169 -0
- haru/auth/__init__.py +14 -0
- haru/auth/cache.py +82 -0
- haru/auth/pkce.py +21 -0
- haru/auth/session.py +95 -0
- haru/auth/sso.py +206 -0
- haru/cli.py +20 -0
- haru/commands/__init__.py +1 -0
- haru/commands/chat.py +102 -0
- haru/commands/login.py +34 -0
- haru/commands/run.py +60 -0
- haru/commands/session.py +36 -0
- haru/commands/streaming.py +34 -0
- haru/config/__init__.py +6 -0
- haru/config/loader.py +163 -0
- haru/config/schema.py +261 -0
- haru/errors.py +25 -0
- haru/models/__init__.py +5 -0
- haru/models/bedrock.py +77 -0
- haru/observability/__init__.py +6 -0
- haru/observability/guardrails.py +36 -0
- haru/observability/telemetry.py +34 -0
- haru/sessions/__init__.py +5 -0
- haru/sessions/manager.py +70 -0
- haru/steering/__init__.py +5 -0
- haru/steering/prompts.py +42 -0
- haru/tools/__init__.py +12 -0
- haru/tools/mcp.py +137 -0
- haru/tools/registry.py +41 -0
- haru_cli-0.1.0.dist-info/METADATA +120 -0
- haru_cli-0.1.0.dist-info/RECORD +38 -0
- haru_cli-0.1.0.dist-info/WHEEL +4 -0
- haru_cli-0.1.0.dist-info/entry_points.txt +2 -0
- haru_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
haru/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""haru: a governed CLI for Amazon Bedrock agents built on the Strands Agents SDK."""
|
haru/__main__.py
ADDED
haru/agents/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Agent factories and multi-agent orchestration."""
|
|
2
|
+
|
|
3
|
+
from haru.agents.factory import build_agent, resolve_agent_parts
|
|
4
|
+
from haru.agents.orchestration import (
|
|
5
|
+
build_graph,
|
|
6
|
+
build_supervisor,
|
|
7
|
+
build_swarm,
|
|
8
|
+
run_orchestration,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"build_agent",
|
|
13
|
+
"build_graph",
|
|
14
|
+
"build_supervisor",
|
|
15
|
+
"build_swarm",
|
|
16
|
+
"resolve_agent_parts",
|
|
17
|
+
"run_orchestration",
|
|
18
|
+
]
|
haru/agents/factory.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Strands agent factories built from typed configuration.
|
|
2
|
+
|
|
3
|
+
Factories return fully-wired Strands agents: model, steering prompt, built-in
|
|
4
|
+
tools, and MCP tools. Multi-agent orchestration composes these in
|
|
5
|
+
``haru.agents.orchestration``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import boto3
|
|
13
|
+
from strands import Agent
|
|
14
|
+
from strands.models import BedrockModel
|
|
15
|
+
from strands.session.session_manager import SessionManager
|
|
16
|
+
from strands.tools.mcp import MCPClient
|
|
17
|
+
|
|
18
|
+
from haru.config.schema import HaruConfig
|
|
19
|
+
from haru.errors import ConfigError
|
|
20
|
+
from haru.models.bedrock import build_model, get_model_config
|
|
21
|
+
from haru.steering.prompts import DEFAULT_PROMPTS_ROOT, load_prompts, resolve_prompt
|
|
22
|
+
from haru.tools.mcp import collect_tools
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_agent( # noqa: PLR0913 - keyword-only wiring points, all optional
|
|
26
|
+
config: HaruConfig,
|
|
27
|
+
agent_name: str | None,
|
|
28
|
+
session: boto3.Session,
|
|
29
|
+
*,
|
|
30
|
+
prompts_root: Path | None = None,
|
|
31
|
+
mcp_clients: Mapping[str, MCPClient] | None = None,
|
|
32
|
+
session_manager: SessionManager | None = None,
|
|
33
|
+
) -> Agent:
|
|
34
|
+
"""Build the named agent from configuration (default model when unnamed).
|
|
35
|
+
|
|
36
|
+
Raises ConfigError for unknown agents, prompt references, or tools.
|
|
37
|
+
"""
|
|
38
|
+
if agent_name is None:
|
|
39
|
+
model = build_model(get_model_config(config), session, guardrails=config.guardrails)
|
|
40
|
+
return Agent(model=model, session_manager=session_manager)
|
|
41
|
+
model, system_prompt, tools = resolve_agent_parts(
|
|
42
|
+
config, agent_name, session, prompts_root=prompts_root, mcp_clients=mcp_clients
|
|
43
|
+
)
|
|
44
|
+
return Agent(
|
|
45
|
+
model=model,
|
|
46
|
+
system_prompt=system_prompt,
|
|
47
|
+
tools=tools,
|
|
48
|
+
name=agent_name,
|
|
49
|
+
session_manager=session_manager,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_agent_parts(
|
|
54
|
+
config: HaruConfig,
|
|
55
|
+
agent_name: str,
|
|
56
|
+
session: boto3.Session,
|
|
57
|
+
*,
|
|
58
|
+
prompts_root: Path | None = None,
|
|
59
|
+
mcp_clients: Mapping[str, MCPClient] | None = None,
|
|
60
|
+
) -> tuple[BedrockModel, str | None, list[Any]]:
|
|
61
|
+
"""Resolve an agent's model, steering prompt, and tool list from config."""
|
|
62
|
+
if config.agents is None or agent_name not in config.agents.agents:
|
|
63
|
+
available = ", ".join(sorted(config.agents.agents)) if config.agents else "none"
|
|
64
|
+
raise ConfigError(f"Unknown agent {agent_name!r}; configured agents: {available}")
|
|
65
|
+
agent_cfg = config.agents.agents[agent_name]
|
|
66
|
+
model = build_model(
|
|
67
|
+
get_model_config(config, agent_cfg.model), session, guardrails=config.guardrails
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
system_prompt: str | None = None
|
|
71
|
+
if agent_cfg.system_prompt_ref is not None:
|
|
72
|
+
root = prompts_root if prompts_root is not None else DEFAULT_PROMPTS_ROOT
|
|
73
|
+
system_prompt = resolve_prompt(agent_cfg.system_prompt_ref, load_prompts(root))
|
|
74
|
+
|
|
75
|
+
tools = collect_tools(agent_cfg, None, mcp_clients or {}, mcp_cfg=config.mcp)
|
|
76
|
+
return model, system_prompt, tools
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Multi-agent orchestration: supervisor (agents-as-tools), swarm, and graph.
|
|
2
|
+
|
|
3
|
+
Each pattern is wired from the ``orchestration`` section of the agents
|
|
4
|
+
configuration; the agent loop, handoffs, and graph execution are delegated
|
|
5
|
+
entirely to Strands.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import boto3
|
|
13
|
+
from strands import Agent, tool
|
|
14
|
+
from strands.multiagent import GraphBuilder, Swarm
|
|
15
|
+
from strands.multiagent.graph import Graph
|
|
16
|
+
from strands.tools.mcp import MCPClient
|
|
17
|
+
|
|
18
|
+
from haru.agents.factory import build_agent, resolve_agent_parts
|
|
19
|
+
from haru.auth.session import build_boto3_session
|
|
20
|
+
from haru.config.schema import AgentsConfig, HaruConfig, OrchestrationConfig
|
|
21
|
+
from haru.errors import ConfigError
|
|
22
|
+
|
|
23
|
+
SUPERVISOR_AGENT_NAME = "supervisor"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_supervisor(
|
|
27
|
+
config: HaruConfig,
|
|
28
|
+
session: boto3.Session,
|
|
29
|
+
*,
|
|
30
|
+
prompts_root: Path | None = None,
|
|
31
|
+
mcp_clients: Mapping[str, MCPClient] | None = None,
|
|
32
|
+
) -> Agent:
|
|
33
|
+
"""Build the supervisor agent with every other agent exposed as a tool."""
|
|
34
|
+
agents_cfg = _require_agents(config)
|
|
35
|
+
if SUPERVISOR_AGENT_NAME not in agents_cfg.agents:
|
|
36
|
+
raise ConfigError(
|
|
37
|
+
f"Supervisor orchestration requires an agent named {SUPERVISOR_AGENT_NAME!r}"
|
|
38
|
+
)
|
|
39
|
+
model, system_prompt, tools = resolve_agent_parts(
|
|
40
|
+
config, SUPERVISOR_AGENT_NAME, session, prompts_root=prompts_root, mcp_clients=mcp_clients
|
|
41
|
+
)
|
|
42
|
+
worker_tools = [
|
|
43
|
+
_agent_as_tool(
|
|
44
|
+
name,
|
|
45
|
+
build_agent(config, name, session, prompts_root=prompts_root, mcp_clients=mcp_clients),
|
|
46
|
+
)
|
|
47
|
+
for name in sorted(agents_cfg.agents)
|
|
48
|
+
if name != SUPERVISOR_AGENT_NAME
|
|
49
|
+
]
|
|
50
|
+
return Agent(
|
|
51
|
+
model=model,
|
|
52
|
+
system_prompt=system_prompt,
|
|
53
|
+
tools=[*tools, *worker_tools],
|
|
54
|
+
name=SUPERVISOR_AGENT_NAME,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_swarm(
|
|
59
|
+
config: HaruConfig,
|
|
60
|
+
session: boto3.Session,
|
|
61
|
+
*,
|
|
62
|
+
prompts_root: Path | None = None,
|
|
63
|
+
mcp_clients: Mapping[str, MCPClient] | None = None,
|
|
64
|
+
) -> Swarm:
|
|
65
|
+
"""Build a Swarm over the configured member agents."""
|
|
66
|
+
orchestration = _require_orchestration(config)
|
|
67
|
+
if orchestration.swarm is None:
|
|
68
|
+
raise ConfigError("Swarm orchestration requested but no swarm section is configured")
|
|
69
|
+
members = [
|
|
70
|
+
build_agent(config, name, session, prompts_root=prompts_root, mcp_clients=mcp_clients)
|
|
71
|
+
for name in orchestration.swarm.members
|
|
72
|
+
]
|
|
73
|
+
return Swarm(
|
|
74
|
+
members,
|
|
75
|
+
max_handoffs=orchestration.swarm.max_handoffs,
|
|
76
|
+
execution_timeout=float(orchestration.swarm.execution_timeout_seconds),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_graph(
|
|
81
|
+
config: HaruConfig,
|
|
82
|
+
session: boto3.Session,
|
|
83
|
+
*,
|
|
84
|
+
prompts_root: Path | None = None,
|
|
85
|
+
mcp_clients: Mapping[str, MCPClient] | None = None,
|
|
86
|
+
) -> Graph:
|
|
87
|
+
"""Build a Graph from the configured nodes and edges via GraphBuilder."""
|
|
88
|
+
orchestration = _require_orchestration(config)
|
|
89
|
+
if orchestration.graph is None:
|
|
90
|
+
raise ConfigError("Graph orchestration requested but no graph section is configured")
|
|
91
|
+
builder = GraphBuilder()
|
|
92
|
+
for node in orchestration.graph.nodes:
|
|
93
|
+
builder.add_node(
|
|
94
|
+
build_agent(
|
|
95
|
+
config, node.agent, session, prompts_root=prompts_root, mcp_clients=mcp_clients
|
|
96
|
+
),
|
|
97
|
+
node.id,
|
|
98
|
+
)
|
|
99
|
+
for edge in orchestration.graph.edges:
|
|
100
|
+
builder.add_edge(edge.source, edge.target)
|
|
101
|
+
return builder.build()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def run_orchestration( # noqa: PLR0913 - three keyword-only injection points for tests/callers
|
|
105
|
+
config: HaruConfig,
|
|
106
|
+
prompt: str,
|
|
107
|
+
pattern: str | None = None,
|
|
108
|
+
*,
|
|
109
|
+
session: boto3.Session | None = None,
|
|
110
|
+
prompts_root: Path | None = None,
|
|
111
|
+
mcp_clients: Mapping[str, MCPClient] | None = None,
|
|
112
|
+
) -> str:
|
|
113
|
+
"""Run ``prompt`` through the requested (or default) orchestration pattern."""
|
|
114
|
+
orchestration = _require_agents(config).orchestration
|
|
115
|
+
selected = pattern if pattern is not None else _default_pattern(orchestration)
|
|
116
|
+
if session is None:
|
|
117
|
+
session = build_boto3_session(config.auth)
|
|
118
|
+
|
|
119
|
+
if selected == "supervisor":
|
|
120
|
+
supervisor = build_supervisor(
|
|
121
|
+
config, session, prompts_root=prompts_root, mcp_clients=mcp_clients
|
|
122
|
+
)
|
|
123
|
+
return str(supervisor(prompt))
|
|
124
|
+
if selected == "swarm":
|
|
125
|
+
swarm = build_swarm(config, session, prompts_root=prompts_root, mcp_clients=mcp_clients)
|
|
126
|
+
return _final_text(swarm(prompt))
|
|
127
|
+
if selected == "graph":
|
|
128
|
+
graph = build_graph(config, session, prompts_root=prompts_root, mcp_clients=mcp_clients)
|
|
129
|
+
return _final_text(graph(prompt))
|
|
130
|
+
raise ConfigError(f"Unknown orchestration pattern {selected!r}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _default_pattern(orchestration: OrchestrationConfig | None) -> str:
|
|
134
|
+
return orchestration.default_pattern if orchestration is not None else "supervisor"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _agent_as_tool(name: str, agent: Agent) -> Any:
|
|
138
|
+
"""Expose ``agent`` as a tool the supervisor can delegate tasks to."""
|
|
139
|
+
|
|
140
|
+
@tool(name=name, description=f"Delegate a task to the {name} specialist agent.")
|
|
141
|
+
def delegate(task: str) -> str:
|
|
142
|
+
"""Send a task to the specialist agent and return its answer."""
|
|
143
|
+
return str(agent(task))
|
|
144
|
+
|
|
145
|
+
return delegate
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _final_text(result: Any) -> str:
|
|
149
|
+
"""Extract the final node's text from a swarm or graph result."""
|
|
150
|
+
order = getattr(result, "node_history", None) or getattr(result, "execution_order", None)
|
|
151
|
+
if order:
|
|
152
|
+
node_id = getattr(order[-1], "node_id", order[-1])
|
|
153
|
+
node_result = result.results.get(node_id)
|
|
154
|
+
if node_result is not None:
|
|
155
|
+
return str(node_result.result)
|
|
156
|
+
return "\n".join(str(node.result) for node in result.results.values())
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _require_agents(config: HaruConfig) -> AgentsConfig:
|
|
160
|
+
if config.agents is None:
|
|
161
|
+
raise ConfigError("Orchestration requires an agents section in configuration")
|
|
162
|
+
return config.agents
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _require_orchestration(config: HaruConfig) -> OrchestrationConfig:
|
|
166
|
+
orchestration = _require_agents(config).orchestration
|
|
167
|
+
if orchestration is None:
|
|
168
|
+
raise ConfigError("No orchestration section configured")
|
|
169
|
+
return orchestration
|
haru/auth/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""IAM Identity Center authentication: PKCE login, token cache, boto3 sessions."""
|
|
2
|
+
|
|
3
|
+
from haru.auth.cache import read_token_cache, write_token_cache
|
|
4
|
+
from haru.auth.pkce import generate_pkce_pair
|
|
5
|
+
from haru.auth.session import build_boto3_session
|
|
6
|
+
from haru.auth.sso import run_login
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"build_boto3_session",
|
|
10
|
+
"generate_pkce_pair",
|
|
11
|
+
"read_token_cache",
|
|
12
|
+
"run_login",
|
|
13
|
+
"write_token_cache",
|
|
14
|
+
]
|
haru/auth/cache.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Botocore-compatible SSO token cache under ``~/.aws/sso/cache``.
|
|
2
|
+
|
|
3
|
+
The cache file name is the SHA-1 of the start URL, and the JSON schema matches
|
|
4
|
+
what botocore's ``SSOTokenProvider`` reads, so boto3 tooling can consume and
|
|
5
|
+
refresh the same cache. Files are written with 0600 permissions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from haru.auth.sso import ClientRegistration, SsoToken
|
|
15
|
+
from haru.errors import AuthError
|
|
16
|
+
|
|
17
|
+
_DEFAULT_CACHE_DIR = Path("~") / ".aws" / "sso" / "cache"
|
|
18
|
+
_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cache_path(start_url: str, cache_dir: Path | None = None) -> Path:
|
|
22
|
+
"""Return the cache file path for ``start_url`` (SHA-1 hex name, .json)."""
|
|
23
|
+
directory = cache_dir if cache_dir is not None else _DEFAULT_CACHE_DIR.expanduser()
|
|
24
|
+
digest = hashlib.sha1(start_url.encode("utf-8"), usedforsecurity=False).hexdigest()
|
|
25
|
+
return directory / f"{digest}.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_token_cache(
|
|
29
|
+
token: SsoToken, start_url: str, region: str, cache_dir: Path | None = None
|
|
30
|
+
) -> Path:
|
|
31
|
+
"""Write ``token`` to the botocore-compatible cache and return the path."""
|
|
32
|
+
path = cache_path(start_url, cache_dir)
|
|
33
|
+
payload: dict[str, str] = {
|
|
34
|
+
"startUrl": start_url,
|
|
35
|
+
"region": region,
|
|
36
|
+
"accessToken": token.access_token,
|
|
37
|
+
"expiresAt": _format_timestamp(token.expires_at),
|
|
38
|
+
"clientId": token.registration.client_id,
|
|
39
|
+
"clientSecret": token.registration.client_secret,
|
|
40
|
+
"registrationExpiresAt": _format_timestamp(token.registration.expires_at),
|
|
41
|
+
}
|
|
42
|
+
if token.refresh_token is not None:
|
|
43
|
+
payload["refreshToken"] = token.refresh_token
|
|
44
|
+
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
46
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
47
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
48
|
+
json.dump(payload, handle)
|
|
49
|
+
path.chmod(0o600)
|
|
50
|
+
return path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_token_cache(start_url: str, cache_dir: Path | None = None) -> SsoToken | None:
|
|
54
|
+
"""Read the cached token for ``start_url``; return None when absent."""
|
|
55
|
+
path = cache_path(start_url, cache_dir)
|
|
56
|
+
if not path.is_file():
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
60
|
+
registration = ClientRegistration(
|
|
61
|
+
client_id=payload["clientId"],
|
|
62
|
+
client_secret=payload["clientSecret"],
|
|
63
|
+
expires_at=_parse_timestamp(payload["registrationExpiresAt"]),
|
|
64
|
+
)
|
|
65
|
+
return SsoToken(
|
|
66
|
+
access_token=payload["accessToken"],
|
|
67
|
+
refresh_token=payload.get("refreshToken"),
|
|
68
|
+
expires_at=_parse_timestamp(payload["expiresAt"]),
|
|
69
|
+
registration=registration,
|
|
70
|
+
)
|
|
71
|
+
except (json.JSONDecodeError, KeyError, ValueError) as exc:
|
|
72
|
+
raise AuthError(f"Corrupt SSO token cache at {path}; run 'haru login'") from exc
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _format_timestamp(value: datetime) -> str:
|
|
76
|
+
"""Format a datetime as the UTC timestamp string botocore expects."""
|
|
77
|
+
return value.astimezone(UTC).strftime(_TIMESTAMP_FORMAT)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_timestamp(value: str) -> datetime:
|
|
81
|
+
"""Parse a botocore UTC timestamp string."""
|
|
82
|
+
return datetime.strptime(value, _TIMESTAMP_FORMAT).replace(tzinfo=UTC)
|
haru/auth/pkce.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""RFC 7636 PKCE helpers for the IAM Identity Center login flow."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import secrets
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_pkce_pair() -> tuple[str, str]:
|
|
9
|
+
"""Return a ``(code_verifier, code_challenge)`` pair using the S256 method.
|
|
10
|
+
|
|
11
|
+
The verifier is 32 random bytes encoded as unpadded URL-safe base64; the
|
|
12
|
+
challenge is the unpadded URL-safe base64 SHA-256 of the verifier.
|
|
13
|
+
"""
|
|
14
|
+
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode("ascii")
|
|
15
|
+
return verifier, code_challenge_from(verifier)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def code_challenge_from(verifier: str) -> str:
|
|
19
|
+
"""Derive the S256 code challenge for ``verifier`` (RFC 7636, section 4.2)."""
|
|
20
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
21
|
+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
haru/auth/session.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Construct boto3 sessions from the cached SSO token, refreshing when needed."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import boto3
|
|
9
|
+
from botocore.exceptions import (
|
|
10
|
+
ClientError,
|
|
11
|
+
SSOTokenLoadError,
|
|
12
|
+
TokenRetrievalError,
|
|
13
|
+
UnauthorizedSSOTokenError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from haru.auth.cache import read_token_cache, write_token_cache
|
|
17
|
+
from haru.auth.sso import SsoToken
|
|
18
|
+
from haru.config.schema import AuthConfig
|
|
19
|
+
from haru.errors import AuthExpiredError, ConfigError
|
|
20
|
+
|
|
21
|
+
_REFRESH_WINDOW = timedelta(minutes=15)
|
|
22
|
+
_RELOGIN_HINT = "run 'haru login'"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_boto3_session(
|
|
26
|
+
config: AuthConfig,
|
|
27
|
+
*,
|
|
28
|
+
oidc: Any | None = None,
|
|
29
|
+
sso_client: Any | None = None,
|
|
30
|
+
cache_dir: Path | None = None,
|
|
31
|
+
) -> boto3.Session:
|
|
32
|
+
"""Build a boto3 session with role credentials from the cached SSO token.
|
|
33
|
+
|
|
34
|
+
Refreshes the access token via the ``refresh_token`` grant when it is
|
|
35
|
+
within the refresh window. Raises AuthExpiredError whenever a fresh
|
|
36
|
+
interactive login is required.
|
|
37
|
+
"""
|
|
38
|
+
sso = config.sso
|
|
39
|
+
token = read_token_cache(sso.start_url, cache_dir=cache_dir)
|
|
40
|
+
if token is None:
|
|
41
|
+
raise AuthExpiredError(f"No cached SSO token; {_RELOGIN_HINT}")
|
|
42
|
+
if token.expires_at <= datetime.now(UTC) + _REFRESH_WINDOW:
|
|
43
|
+
token = _refresh_token(config, token, oidc=oidc, cache_dir=cache_dir)
|
|
44
|
+
|
|
45
|
+
account_id = os.environ.get(sso.account_id_env)
|
|
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
|
+
)
|
|
50
|
+
if sso_client is None:
|
|
51
|
+
sso_client = boto3.Session().client("sso", region_name=sso.sso_region)
|
|
52
|
+
try:
|
|
53
|
+
response = sso_client.get_role_credentials(
|
|
54
|
+
roleName=sso.role_name, accountId=account_id, accessToken=token.access_token
|
|
55
|
+
)
|
|
56
|
+
except (ClientError, SSOTokenLoadError, UnauthorizedSSOTokenError, TokenRetrievalError) as exc:
|
|
57
|
+
raise AuthExpiredError(f"SSO credentials rejected; {_RELOGIN_HINT}") from exc
|
|
58
|
+
|
|
59
|
+
credentials = response["roleCredentials"]
|
|
60
|
+
return boto3.Session(
|
|
61
|
+
aws_access_key_id=credentials["accessKeyId"],
|
|
62
|
+
aws_secret_access_key=credentials["secretAccessKey"],
|
|
63
|
+
aws_session_token=credentials["sessionToken"],
|
|
64
|
+
region_name=config.bedrock_region,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _refresh_token(
|
|
69
|
+
config: AuthConfig, token: SsoToken, *, oidc: Any | None, cache_dir: Path | None
|
|
70
|
+
) -> SsoToken:
|
|
71
|
+
"""Refresh an expiring token via the ``refresh_token`` grant and re-cache it."""
|
|
72
|
+
sso = config.sso
|
|
73
|
+
now = datetime.now(UTC)
|
|
74
|
+
if token.refresh_token is None or token.registration.expires_at <= now:
|
|
75
|
+
raise AuthExpiredError(f"SSO token expired and cannot be refreshed; {_RELOGIN_HINT}")
|
|
76
|
+
if oidc is None:
|
|
77
|
+
oidc = boto3.Session().client("sso-oidc", region_name=sso.sso_region)
|
|
78
|
+
try:
|
|
79
|
+
response = oidc.create_token(
|
|
80
|
+
clientId=token.registration.client_id,
|
|
81
|
+
clientSecret=token.registration.client_secret,
|
|
82
|
+
grantType="refresh_token",
|
|
83
|
+
refreshToken=token.refresh_token,
|
|
84
|
+
)
|
|
85
|
+
except ClientError as exc:
|
|
86
|
+
raise AuthExpiredError(f"SSO token refresh failed; {_RELOGIN_HINT}") from exc
|
|
87
|
+
|
|
88
|
+
refreshed = SsoToken(
|
|
89
|
+
access_token=response["accessToken"],
|
|
90
|
+
refresh_token=response.get("refreshToken", token.refresh_token),
|
|
91
|
+
expires_at=now + timedelta(seconds=response["expiresIn"]),
|
|
92
|
+
registration=token.registration,
|
|
93
|
+
)
|
|
94
|
+
write_token_cache(refreshed, sso.start_url, sso.sso_region, cache_dir=cache_dir)
|
|
95
|
+
return refreshed
|