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
56 changes: 55 additions & 1 deletion apps/backend/tests/test_tools/test_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,60 @@ def test_bash_uses_both_clouds_when_both_connected(monkeypatch):
cred.reset_current_cloud_account(token)

assert r_aws["success"] and r_az["success"]
env_by_bin = dict(runs)
# runs[N][0] is the absolute exe path (shutil.which resolved it); match on basename
# so the test works regardless of where aws/az live (or .exe / .cmd on Windows).
import os as _os

env_by_bin = {_os.path.basename(t).split(".")[0]: env for t, env in runs}
assert env_by_bin["aws"]["AWS_ACCESS_KEY_ID"] == "AKIAFAKE" # AWS account creds
assert env_by_bin["az"]["AZURE_SUBSCRIPTION_ID"] == "sub-1" # Azure account env


# ── Tri-state contextvar: None (ambient) vs {} (explicit empty / fail closed) ──


def test_current_cloud_accounts_is_none_when_unset():
"""OSS / self-host default: contextvar was never set → callers see None, not {}."""
assert cred.current_cloud_accounts() is None


def test_resolve_session_ambient_when_contextvar_unset(monkeypatch):
"""OSS default behavior is preserved: with no contextvar set, resolve_session falls back
to _base_session() (the host's ambient env/profile creds)."""
sentinel = object()
monkeypatch.setattr(cred, "_base_session", lambda: sentinel)
cred._session_cache.clear()
# contextvar is at its default (None); no setter was called.
assert cred.resolve_session() is sentinel


def test_resolve_session_fails_closed_on_explicit_empty():
"""Product safety net: when the contextvar is explicitly set to {} (e.g. tenant with no
Cloud Account connected), resolve_session must NOT fall back to platform creds — it raises.
"""
cred._session_cache.clear()
token = cred.set_current_cloud_accounts([]) # builds {} -> fail-closed signal
try:
with pytest.raises(RuntimeError, match="no AWS account connected"):
cred.resolve_session()
finally:
cred.reset_current_cloud_account(token)


def test_bash_aws_blocked_on_explicit_empty_accounts(monkeypatch):
"""Mirror of the resolver test at the bash layer: with {} on the contextvar, bash `aws`
is denied without touching subprocess (so the platform's `aws` CLI can't be used either)."""
from opendevops_core.tools import bash_tool

def _no_run(*a, **k): # subprocess must not execute
raise AssertionError("subprocess.run must not run when accounts is explicitly empty")

monkeypatch.setattr(bash_tool.subprocess, "run", _no_run)
token = cred.set_current_cloud_accounts([])
try:
res = bash_tool.run_bash_command("aws sts get-caller-identity")
finally:
cred.reset_current_cloud_account(token)

assert res["success"] is False and res.get("blocked") is True
assert "no cloud account" in res["error"].lower()
40 changes: 29 additions & 11 deletions apps/core/src/opendevops_core/providers/aws/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,15 @@

from opendevops_core.config import settings

# Map of provider -> active cloud account for this request (or None = env/profile fallback).
# Map of provider -> active cloud account for this request.
# An org can have several connected clouds active at once (e.g. AWS + Azure); each cloud's
# tools resolve credentials against that provider's account independently.
#
# Three states matter:
# None -> contextvar never set in this task. OSS / self-host => ambient env/profile creds.
# {} -> explicitly set to empty (e.g. product tenant with no Cloud Account connected) =>
# fail closed; AWS/Azure tools deny instead of using the platform's creds.
# {p:a} -> active accounts; resolve per-provider.
_current_accounts: contextvars.ContextVar[dict[str, dict] | None] = contextvars.ContextVar(
"current_cloud_accounts", default=None
)
Expand All @@ -56,18 +62,21 @@ def reset_current_cloud_account(token: contextvars.Token) -> None:
_current_accounts.reset(token)


def current_cloud_accounts() -> dict[str, dict]:
"""The provider->account map active for this task ({} when none)."""
return _current_accounts.get() or {}
def current_cloud_accounts() -> dict[str, dict] | None:
"""Return the provider->account map active for this task, or None when the contextvar
was never set (OSS / self-host => ambient fallback). Callers must distinguish ``None``
(ambient) from ``{}`` (explicitly empty => fail closed)."""
return _current_accounts.get()


def account_for_provider(provider: str) -> dict | None:
return current_cloud_accounts().get(provider)
accts = _current_accounts.get()
return accts.get(provider) if accts else None


def get_current_cloud_account() -> dict | None:
"""Back-compat single-account accessor: prefer AWS, else any active account, else None."""
accts = current_cloud_accounts()
accts = _current_accounts.get()
if not accts:
return None
return accts.get("aws") or next(iter(accts.values()))
Expand Down Expand Up @@ -176,15 +185,24 @@ def _access_key_session(account: dict) -> boto3.Session:


def resolve_session() -> boto3.Session:
"""The boto3 session for the active AWS account, or the base session when nothing is set."""
accts = current_cloud_accounts()
if not accts:
"""The boto3 session for the active AWS account.

Tri-state on the per-task contextvar:
* Unset (None) — OSS / self-host => fall back to the host's ambient creds.
* Explicitly empty ({}) — product tenant with no Cloud Account connected =>
fail closed; do NOT fall back to platform creds (cross-tenant leak).
* Has an "aws" entry — assume role / use access key.
* Has entries but no "aws" — Azure-only / other; AWS access denied.
"""
accts = _current_accounts.get()
if accts is None:
return _base_session() # OSS / unscoped -> ambient env/profile creds

account = accts.get("aws")
if account is None:
# Fail closed: the org has connected clouds but no AWS account, so AWS access must NOT
# fall back to the platform's base credentials (that would leak the platform account).
# Fail closed: either the contextvar was explicitly set to {} (product tenant with no
# connected accounts) or the org has only non-AWS clouds connected. Either way, AWS
# access must NOT fall back to the platform's base credentials.
raise RuntimeError(
"AWS is not available for this organization (no AWS account connected)."
)
Expand Down
34 changes: 23 additions & 11 deletions apps/core/src/opendevops_core/tools/bash_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,18 +246,26 @@ def run_bash_command(command: str) -> dict[str, Any]:
logger.info("bash_tool RUN | cmd={!r}", command)

# Resolve credentials per command, against the org's connected account for that command's
# cloud. An org can have multiple clouds active at once (AWS + Azure); each binary uses its
# own provider's account. If the org has connected clouds but none for this binary's provider,
# fail closed (don't fall back to the platform's ambient creds). No connected accounts at all
# (OSS / self-host) -> ambient env (the host's `aws`/`az` login).
# cloud. Tri-state on the contextvar (set in product chat.py; never set in OSS):
# None -> ambient mode (OSS / self-host); run_env stays None, subprocess inherits host env.
# {} -> product tenant with no Cloud Account connected; deny all cloud binaries
# (do NOT fall back to platform creds — that would be a cross-tenant leak).
# {p:a} -> per-provider resolution: each binary uses its own provider's account, fail
# closed when an org has connected clouds but not the one this binary needs.
accounts = current_cloud_accounts()
binary = tokens[0]
run_env = None

def _blocked(msg: str) -> dict[str, Any]:
return {"success": False, "output": "", "error": msg, "command": command, "blocked": True}

if binary == "aws":
cloud_binary = binary in ("aws", "az", "kubectl")
if accounts == {} and cloud_binary:
return _blocked(
f"{binary} is not available — no cloud account is connected for this organization."
)

if accounts and binary == "aws":
if accounts.get("aws") is not None:
try:
frozen = resolve_session().get_credentials().get_frozen_credentials()
Expand All @@ -273,18 +281,22 @@ def _blocked(msg: str) -> dict[str, Any]:
except Exception as e: # noqa: BLE001 - fail closed; do not leak platform creds
logger.error("bash_tool: could not resolve org AWS creds: {}", e)
return _blocked(f"Could not resolve organization AWS credentials: {e}")
elif accounts:
return _blocked("AWS is not available for this organization (no AWS account connected).")
elif binary == "az":
else:
return _blocked(
"AWS is not available for this organization (no AWS account connected)."
)
elif accounts and binary == "az":
if accounts.get("azure") is not None:
try:
run_env = azure_cli_env(accounts["azure"])
except Exception as e: # noqa: BLE001 - fail closed
logger.error("bash_tool: could not resolve org Azure creds: {}", e)
return _blocked(f"Could not resolve organization Azure credentials: {e}")
elif accounts:
return _blocked("Azure is not available for this organization (no Azure account connected).")
elif binary == "kubectl" and accounts.get("azure") is not None:
else:
return _blocked(
"Azure is not available for this organization (no Azure account connected)."
)
elif accounts and binary == "kubectl" and accounts.get("azure") is not None:
# AKS: kubectl must read the per-org kubeconfig written by `az aks get-credentials`
# into the org's isolated config dir.
try:
Expand Down
Loading