Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 94 additions & 1 deletion apps/backend/tests/test_tools/test_credentials.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
"""Per-org AWS credential resolution + cache isolation."""
"""Per-org credential resolution, cache isolation, and multi-cloud fail-closed behavior."""

import pytest
from moto import mock_aws
from opendevops_core.providers.aws import credentials as cred
from opendevops_core.tools._cache import tool_cached


def _azure_account(client_id: str = "client-1") -> dict:
return {
"id": "az1",
"provider": "azure",
"auth_method": "service_principal",
"region": "eastus",
"config": {"tenant_id": "t", "client_id": client_id, "subscription_id": "sub-1"},
"secret_enc": None,
}


def _account(role_arn: str, acct_id: str = "acct") -> dict:
return {
"id": acct_id,
Expand Down Expand Up @@ -181,3 +193,84 @@ def _boom():
assert res["success"] is False
assert res.get("blocked") is True
assert "credentials" in res["error"].lower()


# ── Multi-cloud (Azure) ──────────────────────────────────────────────────────


def test_resolve_session_fails_closed_for_non_aws_account():
# An Azure org must NOT fall back to the platform's base AWS creds.
token = cred.set_current_cloud_account(_azure_account())
try:
with pytest.raises(RuntimeError):
cred.resolve_session()
finally:
cred.reset_current_cloud_account(token)


def test_bash_az_allowed_and_blocked():
from opendevops_core.tools import bash_tool

assert bash_tool._allowed("az aks list", ["az", "aks", "list"])
assert bash_tool._allowed(
"az aks get-credentials -g r -n n", ["az", "aks", "get-credentials", "-g", "r", "-n", "n"]
)
assert bash_tool._allowed("az monitor metrics list", ["az", "monitor", "metrics", "list"])
assert not bash_tool._allowed("az group create", ["az", "group", "create"])
assert not bash_tool._allowed("az vm delete --name x", ["az", "vm", "delete", "--name", "x"])


def test_bash_injects_azure_env_for_az(monkeypatch):
from opendevops_core.tools import bash_tool

captured: dict = {}
monkeypatch.setattr(
bash_tool,
"azure_cli_env",
lambda account: {"AZURE_CONFIG_DIR": "/tmp/x", "AZURE_SUBSCRIPTION_ID": "sub-1"},
)
monkeypatch.setattr(
bash_tool.subprocess,
"run",
lambda tokens, **kw: (captured.__setitem__("env", kw.get("env")), _CompletedProc())[1],
)
token = cred.set_current_cloud_account(_azure_account())
try:
res = bash_tool.run_bash_command("az aks list")
finally:
cred.reset_current_cloud_account(token)

assert res["success"]
assert captured["env"]["AZURE_SUBSCRIPTION_ID"] == "sub-1"


def test_bash_blocks_aws_for_azure_org(monkeypatch):
from opendevops_core.tools import bash_tool

def _no_run(*a, **k):
raise AssertionError("subprocess must not run for a cross-provider command")

monkeypatch.setattr(bash_tool.subprocess, "run", _no_run)
token = cred.set_current_cloud_account(_azure_account())
try:
res = bash_tool.run_bash_command("aws s3api list-buckets")
finally:
cred.reset_current_cloud_account(token)
assert res["success"] is False and res.get("blocked") is True
assert "aws is not available" in res["error"].lower()


def test_bash_blocks_az_for_aws_org(monkeypatch):
from opendevops_core.tools import bash_tool

def _no_run(*a, **k):
raise AssertionError("subprocess must not run for a cross-provider command")

monkeypatch.setattr(bash_tool.subprocess, "run", _no_run)
token = cred.set_current_cloud_account(_account("arn:aws:iam::111111111111:role/x"))
try:
res = bash_tool.run_bash_command("az aks list")
finally:
cred.reset_current_cloud_account(token)
assert res["success"] is False and res.get("blocked") is True
assert "azure is not available" in res["error"].lower()
57 changes: 57 additions & 0 deletions apps/backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dependencies = [
"langchain-openai>=0.1.0",
"langgraph>=0.2.0",
"boto3>=1.34.0",
"azure-identity>=1.17.0",
"pydantic>=2.7.0",
"pydantic-settings>=2.3.0",
"python-dotenv>=1.0.0",
Expand Down
22 changes: 18 additions & 4 deletions apps/core/src/opendevops_core/agent/prompts.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
from __future__ import annotations

_BASE_PROMPT = """You are an expert AWS SRE investigating an incident. You have read-only access to AWS services via tools.
_BASE_PROMPT = """You are an expert cloud SRE investigating an incident. You have read-only access to the affected cloud (AWS or Azure) via structured tools and read-only CLIs. The active cloud for this run is given in the run context; if none is stated, assume AWS.

## Investigation Methodology

**On AWS** (structured tools available):
1. Start by checking CloudWatch alarms (`get_alarms` with state=ALARM) for the affected service.
2. Check CloudTrail for recent changes (deployments, config changes) in the last 2 hours.
3. Before calling `get_log_events`, always call `describe_log_groups` first with a relevant prefix to discover the real log group name — never guess it.
4. Pull relevant metrics and correlate spikes with log errors.

**On Azure** (use the bash tool with the Azure CLI `az` — there are no structured Azure tools):
1. Check Azure Monitor metrics (`az monitor metrics list`) and the Activity Log for recent changes (`az monitor activity-log list`).
2. Inspect the affected resource (`az webapp show`, `az aks show`, `az vm list`, etc.) and its logs (`az webapp log tail`, Log Analytics queries).
3. For AKS/Kubernetes: run `az aks get-credentials -g <rg> -n <cluster>`, then triage with `kubectl get/describe/logs`.
4. Call `use_skill` for the matching Azure runbook (e.g. `azure-aks-debugging`) early.

In both clouds:
5. Form explicit hypotheses before calling tools to verify them.
6. Rank hypotheses by likelihood and confirm or rule out each one with evidence.

Expand Down Expand Up @@ -43,11 +52,16 @@
`query*`, `batch-get-*`
Examples: `aws s3api list-buckets`, `aws dynamodb describe-table --table-name X`,
`aws sns list-topics`, `aws secretsmanager list-secrets`, `aws ssm describe-parameters`
- `kubectl get / describe / logs` — always use bash for kubectl, no boto3 equivalent
- `docker ps / logs / inspect` — always use bash for docker, no boto3 equivalent
- Any `az <group...> <verb>` where the verb is read-only: `list`, `show`, `get-*`,
`check`, `describe`, `tail`, `query`, `version`
Examples: `az aks list`, `az aks show -g rg -n cluster`, `az monitor metrics list`,
`az monitor activity-log list`, `az webapp log tail`, `az aks get-credentials -g rg -n cluster`
- `kubectl get / describe / logs` — use bash for kubectl (works against AKS once you've run
`az aks get-credentials`)
- `docker ps / logs / inspect`

**Rules:**
- For `docker` and `kubectl`: always go through this tool directly.
- For `docker`, `kubectl`, and `az`: always go through this tool directly (no structured equivalent).
- For AWS: prefer the structured boto3 tools for CloudWatch, ECS, Lambda, EC2, RDS,
CloudTrail, IAM since they return cleaner structured data. Use this tool for any
other AWS service or when you need raw CLI output.
Expand Down
8 changes: 6 additions & 2 deletions apps/core/src/opendevops_core/providers/aws/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,12 @@ def resolve_session() -> boto3.Session:

provider = account.get("provider", "aws")
if provider != "aws":
logger.warning("resolve_session for non-aws provider '{}'; using base creds", provider)
return _base_session()
# Fail closed: an org whose active account is non-AWS must NOT fall back to the
# platform's base AWS credentials — that would leak the platform account across
# tenants. AWS tools should error out instead. (Tools catch this and return an error.)
raise RuntimeError(
f"AWS is not available for this organization (active cloud provider: {provider})."
)

identity = current_credential_identity()
cached = _session_cache.get(identity)
Expand Down
123 changes: 123 additions & 0 deletions apps/core/src/opendevops_core/providers/azure/credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Per-request Azure credential resolution for the bash `az` CLI path.

Azure support is CLI-first: the agent investigates Azure through `az` (and `kubectl` for AKS)
via the bash tool, not structured SDK tools. Unlike AWS (which reads creds from env vars),
the Azure CLI is **stateful** — it needs `az login`, which writes tokens to AZURE_CONFIG_DIR.
So per-org `az` runs in an **isolated, cached config dir** with a one-time service-principal
login, never a shared ~/.azure.

The active account comes from the same contextvar the AWS resolver uses
(`get_current_cloud_account`); the account dict's `provider` field disambiguates. Azure accounts
look like::

{"provider": "azure", "auth_method": "service_principal",
"config": {"tenant_id": "...", "client_id": "...", "subscription_id": "..."},
"secret_enc": <Fernet({"client_secret": "..."})>, "id": "..."}
"""

from __future__ import annotations

import hashlib
import os
import subprocess
import tempfile
from pathlib import Path

from cachetools import TTLCache

from opendevops_core.providers.aws.credentials import _account_secrets, get_current_cloud_account

# Identities that already have a logged-in AZURE_CONFIG_DIR (avoid re-login per tool call).
_login_cache: TTLCache = TTLCache(maxsize=64, ttl=1800) # 30 min
_BASE_DIR = Path(tempfile.gettempdir()) / "odo-azure"


def current_azure_account() -> dict | None:
acct = get_current_cloud_account()
if acct and acct.get("provider") == "azure":
return acct
return None


def azure_identity(account: dict) -> str:
"""Stable identity for caching the login/config dir (and not bleeding across orgs)."""
cfg = account.get("config") or {}
return cfg.get("client_id") or str(account.get("id") or "azure")


def _sp_fields(account: dict) -> dict:
cfg = account.get("config") or {}
secrets = _account_secrets(account)
return {
"tenant_id": cfg.get("tenant_id"),
"client_id": cfg.get("client_id"),
"subscription_id": cfg.get("subscription_id"),
# secret lives encrypted in secret_enc; tolerate a plaintext config fallback (no key set).
"client_secret": secrets.get("client_secret") or cfg.get("client_secret"),
}


def _config_dir_for(identity: str) -> Path:
h = hashlib.sha256(identity.encode()).hexdigest()[:16]
d = _BASE_DIR / h
d.mkdir(parents=True, exist_ok=True)
return d


def _az_login(env: dict, sp: dict) -> None:
"""Log the service principal into the isolated AZURE_CONFIG_DIR (our subprocess, not the
agent's — so it bypasses the bash-tool allowlist intentionally)."""
login = subprocess.run(
[
"az", "login", "--service-principal",
"-u", sp["client_id"],
"-p", sp["client_secret"],
"--tenant", sp["tenant_id"],
"--only-show-errors",
],
capture_output=True, text=True, timeout=60, env=env,
)
if login.returncode != 0:
raise RuntimeError(f"az login failed: {login.stderr[:300]}")
if sp.get("subscription_id"):
subprocess.run(
["az", "account", "set", "--subscription", sp["subscription_id"], "--only-show-errors"],
capture_output=True, text=True, timeout=30, env=env,
)


def azure_cli_env(account: dict) -> dict:
"""Env for running `az`/`kubectl` as the org's service principal in an isolated, logged-in
config dir. Raises on missing creds or login failure (caller fails closed)."""
sp = _sp_fields(account)
if not (sp["tenant_id"] and sp["client_id"] and sp["client_secret"]):
raise RuntimeError("Azure account is missing service principal credentials")

identity = azure_identity(account)
cfg_dir = _config_dir_for(identity)
env = dict(os.environ)
env["AZURE_CONFIG_DIR"] = str(cfg_dir)
env["HOME"] = str(cfg_dir) # az + kubectl write under HOME by default; keep it per-org
env["KUBECONFIG"] = str(cfg_dir / "kubeconfig") # az aks get-credentials writes here
if sp["subscription_id"]:
env["AZURE_SUBSCRIPTION_ID"] = sp["subscription_id"]

if _login_cache.get(identity) is None:
_az_login(env, sp)
_login_cache[identity] = True
return env


def verify_service_principal(account: dict) -> dict:
"""Validate the SP by acquiring an ARM token (parallel to AWS sts:GetCallerIdentity)."""
sp = _sp_fields(account)
if not (sp["tenant_id"] and sp["client_id"] and sp["client_secret"]):
return {"ok": False, "error": "Missing tenant_id / client_id / client_secret"}
try:
from azure.identity import ClientSecretCredential

cred = ClientSecretCredential(sp["tenant_id"], sp["client_id"], sp["client_secret"])
token = cred.get_token("https://management.azure.com/.default")
return {"ok": bool(token and token.token), "subscription_id": sp.get("subscription_id")}
except Exception as e: # noqa: BLE001 - surfaced to the admin as a verify failure
return {"ok": False, "error": str(e)}
Loading
Loading