Skip to content

Commit beb6d4c

Browse files
committed
allow only SPLUNK_AO_SF_API_TOKEN for CRUD use
1 parent 38a9d98 commit beb6d4c

5 files changed

Lines changed: 111 additions & 22 deletions

File tree

src/splunk_ao/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from splunk_ao.collaborator import Collaborator, CollaboratorRole
2020
from splunk_ao.configuration import Configuration
2121
from splunk_ao.dataset import Dataset
22-
from splunk_ao.decorator import SplunkAODecorator, splunk_ao_context, log, start_session
22+
from splunk_ao.decorator import SplunkAODecorator, log, splunk_ao_context, start_session
2323
from splunk_ao.exceptions import (
2424
AuthenticationError,
2525
BadRequestError,
@@ -132,12 +132,12 @@
132132
"create_api_key",
133133
"delete_api_key",
134134
"enable_console_logging",
135-
"splunk_ao_context",
136135
"get_agent_control_target",
137136
"get_tracing_headers",
138137
"is_dependency_available",
139138
"list_api_keys",
140139
"log",
141140
"setup_agent_control_bridge",
141+
"splunk_ao_context",
142142
"start_session",
143143
]

src/splunk_ao/config.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ async def arequest(self, method: RequestMethod, path: str, *args: Any, **kwargs:
4444
def stream_request(self, method: RequestMethod, path: str, *args: Any, **kwargs: Any) -> Iterator[Response]:
4545
return super().stream_request(method, self._prefixed(path), *args, **kwargs)
4646

47+
4748
# Mapping of SPLUNK_AO_* → GALILEO_* env var pairs used by the bridge.
4849
# Defined at module level so both _bridge_env_vars() and reset() can reference
4950
# the same authoritative list without duplication.
@@ -175,7 +176,8 @@ def _check_auth_config(kwargs: dict) -> str | None:
175176
message identifying what's missing.
176177
177178
Auth methods supported by the underlying config model:
178-
- SF tokens (o11y): SPLUNK_AO_SF_TOKEN and optional SPLUNK_AO_SF_API_TOKEN env vars
179+
- SF tokens (o11y): SPLUNK_AO_REALM and at least one of
180+
SPLUNK_AO_SF_TOKEN or SPLUNK_AO_SF_API_TOKEN env vars
179181
- API key (standalone): api_key kwarg or SPLUNK_AO_API_KEY env
180182
- Pre-exchanged JWT (standalone): jwt_token or SPLUNK_AO_JWT_TOKEN
181183
- SSO (paired): sso_id_token + sso_provider, both kwargs and env vars
@@ -192,7 +194,14 @@ def _val(kwarg_name: str, env_name: str) -> str | None:
192194
return str(value)
193195
return os.environ.get(env_name)
194196

195-
if os.environ.get("SPLUNK_AO_SF_TOKEN") or os.environ.get("SPLUNK_AO_SF_API_TOKEN"):
197+
realm = os.environ.get("SPLUNK_AO_REALM")
198+
sf_token = os.environ.get("SPLUNK_AO_SF_TOKEN")
199+
sf_api_token = os.environ.get("SPLUNK_AO_SF_API_TOKEN")
200+
if realm or sf_token or sf_api_token:
201+
if not realm:
202+
return "O11y authentication requires SPLUNK_AO_REALM to be set."
203+
if not sf_token and not sf_api_token:
204+
return "O11y authentication requires SPLUNK_AO_SF_TOKEN or SPLUNK_AO_SF_API_TOKEN to be set."
196205
return None
197206

198207
# Standalone methods — either alone is sufficient.
@@ -239,7 +248,8 @@ def _val(kwarg_name: str, env_name: str) -> str | None:
239248
# Nothing configured anywhere.
240249
return (
241250
"No Splunk AO authentication detected. Set one of: SPLUNK_AO_REALM with "
242-
"SPLUNK_AO_SF_TOKEN; SPLUNK_AO_API_KEY; SPLUNK_AO_SSO_ID_TOKEN with SPLUNK_AO_SSO_PROVIDER; "
251+
"SPLUNK_AO_SF_TOKEN or SPLUNK_AO_SF_API_TOKEN; SPLUNK_AO_API_KEY; "
252+
"SPLUNK_AO_SSO_ID_TOKEN with SPLUNK_AO_SSO_PROVIDER; "
243253
"or SPLUNK_AO_USERNAME with SPLUNK_AO_PASSWORD. "
244254
"Alternatively, pass the equivalent kwargs to SplunkAOConfig.get(). "
245255
"See https://docs.splunk.com for setup instructions."

src/splunk_ao/deployment.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,19 @@ class O11yConfig:
5151
"""Configuration for a Splunk Observability Cloud deployment."""
5252

5353
realm: str
54-
sf_token: SecretStr
54+
sf_token: SecretStr | None = None
5555
sf_api_token: SecretStr | None = None
5656

5757
def __post_init__(self) -> None:
58-
if not isinstance(self.sf_token, SecretStr):
58+
missing = []
59+
if not self.realm:
60+
missing.append("SPLUNK_AO_REALM")
61+
if self.sf_token is None and self.sf_api_token is None:
62+
missing.append("one of SPLUNK_AO_SF_TOKEN or SPLUNK_AO_SF_API_TOKEN")
63+
if missing:
64+
raise MissingConfigurationError(f"O11y deployment requires {' and '.join(missing)} to be set.")
65+
66+
if self.sf_token is not None and not isinstance(self.sf_token, SecretStr):
5967
self.sf_token = SecretStr(self.sf_token)
6068
if self.sf_api_token is not None and not isinstance(self.sf_api_token, SecretStr):
6169
self.sf_api_token = SecretStr(self.sf_api_token)
@@ -65,16 +73,11 @@ def from_env(cls) -> "O11yConfig":
6573
"""Load and validate o11y configuration from the environment."""
6674
realm = _env("SPLUNK_AO_REALM")
6775
sf_token = _env("SPLUNK_AO_SF_TOKEN")
68-
69-
if realm is None or sf_token is None:
70-
missing = [
71-
name for name, value in (("SPLUNK_AO_REALM", realm), ("SPLUNK_AO_SF_TOKEN", sf_token)) if value is None
72-
]
73-
raise MissingConfigurationError(f"O11y deployment requires {' and '.join(missing)} to be set.")
74-
7576
sf_api_token = _env("SPLUNK_AO_SF_API_TOKEN")
7677
return cls(
77-
realm=realm, sf_token=SecretStr(sf_token), sf_api_token=SecretStr(sf_api_token) if sf_api_token else None
78+
realm=realm or "",
79+
sf_token=SecretStr(sf_token) if sf_token else None,
80+
sf_api_token=SecretStr(sf_api_token) if sf_api_token else None,
7881
)
7982

8083
@property
@@ -85,7 +88,20 @@ def otlp_endpoint(self) -> str:
8588
@property
8689
def crud_token(self) -> SecretStr:
8790
"""Return the API token when set, otherwise the ingest token."""
88-
return self.sf_api_token if self.sf_api_token is not None else self.sf_token
91+
if self.sf_api_token is not None:
92+
return self.sf_api_token
93+
if self.sf_token is not None:
94+
return self.sf_token
95+
raise MissingConfigurationError("O11y CRUD requires SPLUNK_AO_SF_API_TOKEN or SPLUNK_AO_SF_TOKEN to be set.")
96+
97+
def require_ingest_token(self) -> SecretStr:
98+
"""Return the token required for OTLP trace export."""
99+
if self.sf_token is None:
100+
raise MissingConfigurationError(
101+
"O11y OTLP trace export requires SPLUNK_AO_SF_TOKEN. "
102+
"SPLUNK_AO_SF_API_TOKEN supports CRUD operations only."
103+
)
104+
return self.sf_token
89105

90106
@property
91107
def api_root(self) -> str:

tests/test_deployment.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ def test_o11y_config_from_env() -> None:
101101
assert cfg.sf_api_token.get_secret_value() == "api-tok"
102102

103103

104+
def test_o11y_config_from_env_accepts_crud_only_api_token() -> None:
105+
with env(SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_API_TOKEN="api-tok"):
106+
cfg = O11yConfig.from_env()
107+
108+
assert cfg.sf_token is None
109+
assert cfg.crud_token.get_secret_value() == "api-tok"
110+
111+
104112
def test_otlp_endpoint_derived_from_realm() -> None:
105113
cfg = O11yConfig(realm="us1", sf_token="tok")
106114
assert cfg.otlp_endpoint == "https://ingest.us1.observability.splunkcloud.com/v2/trace/otlp"
@@ -116,16 +124,19 @@ def test_crud_token_falls_back_to_sf_token() -> None:
116124
assert cfg.crud_token.get_secret_value() == "tok"
117125

118126

119-
def test_missing_realm_raises() -> None:
120-
with env(SPLUNK_AO_SF_TOKEN="tok"):
127+
@pytest.mark.parametrize("token_var", ["SPLUNK_AO_SF_TOKEN", "SPLUNK_AO_SF_API_TOKEN"])
128+
def test_missing_realm_raises(token_var: str) -> None:
129+
with env(**{token_var: "tok"}):
121130
with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_REALM"):
122131
O11yConfig.from_env()
123132

124133

125-
def test_missing_sf_token_raises() -> None:
134+
def test_missing_both_o11y_tokens_raises() -> None:
126135
with env(SPLUNK_AO_REALM="us1"):
127-
with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_SF_TOKEN"):
136+
with pytest.raises(MissingConfigurationError) as exc_info:
128137
O11yConfig.from_env()
138+
assert "SPLUNK_AO_SF_TOKEN" in str(exc_info.value)
139+
assert "SPLUNK_AO_SF_API_TOKEN" in str(exc_info.value)
129140

130141

131142
def test_missing_o11y_config_names_both_required_variables() -> None:
@@ -134,6 +145,18 @@ def test_missing_o11y_config_names_both_required_variables() -> None:
134145
O11yConfig.from_env()
135146
assert "SPLUNK_AO_REALM" in str(exc_info.value)
136147
assert "SPLUNK_AO_SF_TOKEN" in str(exc_info.value)
148+
assert "SPLUNK_AO_SF_API_TOKEN" in str(exc_info.value)
149+
150+
151+
def test_require_ingest_token_returns_sf_token() -> None:
152+
cfg = O11yConfig(realm="us1", sf_token="ingest-tok", sf_api_token="api-tok")
153+
assert cfg.require_ingest_token().get_secret_value() == "ingest-tok"
154+
155+
156+
def test_require_ingest_token_rejects_crud_only_config() -> None:
157+
cfg = O11yConfig(realm="us1", sf_api_token="api-tok")
158+
with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_SF_TOKEN"):
159+
cfg.require_ingest_token()
137160

138161

139162
def test_api_root_derives_from_realm() -> None:

tests/test_o11y_config.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from galileo_core.helpers.api_client import ApiClient
1515
from galileo_core.schemas.base_config import GalileoConfig
1616
from splunk_ao.config import O11yApiClient, SplunkAOConfig
17-
from splunk_ao.shared.exceptions import AmbiguousConfigurationError
17+
from splunk_ao.shared.exceptions import AmbiguousConfigurationError, MissingConfigurationError
1818

1919
_CONFIG_ENV_VARS = (
2020
"SPLUNK_AO_REALM",
@@ -133,10 +133,29 @@ def fake_stream_request(
133133

134134
@pytest.mark.parametrize("token_var", ["SPLUNK_AO_SF_TOKEN", "SPLUNK_AO_SF_API_TOKEN"])
135135
def test_o11y_auth_guard_accepts_environment_tokens(token_var: str) -> None:
136-
with config_env(**{token_var: "tok"}):
136+
with config_env(SPLUNK_AO_REALM="us1", **{token_var: "tok"}):
137137
assert SplunkAOConfig._check_auth_config({}) is None
138138

139139

140+
def test_o11y_auth_guard_requires_realm() -> None:
141+
with config_env(SPLUNK_AO_SF_API_TOKEN="tok"):
142+
assert "SPLUNK_AO_REALM" in (SplunkAOConfig._check_auth_config({}) or "")
143+
144+
145+
def test_o11y_get_with_api_token_but_no_realm_fails_clearly(monkeypatch: pytest.MonkeyPatch) -> None:
146+
monkeypatch.setattr(SplunkAOConfig, "_instance", None)
147+
with config_env(SPLUNK_AO_SF_API_TOKEN="tok"):
148+
with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_REALM"):
149+
SplunkAOConfig.get()
150+
151+
152+
def test_o11y_auth_guard_requires_at_least_one_token() -> None:
153+
with config_env(SPLUNK_AO_REALM="us1"):
154+
error = SplunkAOConfig._check_auth_config({}) or ""
155+
assert "SPLUNK_AO_SF_TOKEN" in error
156+
assert "SPLUNK_AO_SF_API_TOKEN" in error
157+
158+
140159
def test_o11y_auth_guard_does_not_accept_token_kwargs() -> None:
141160
with config_env():
142161
assert SplunkAOConfig._check_auth_config({"sf_token": "tok"}) is not None
@@ -204,6 +223,27 @@ async def async_fail(*args: object, **kwargs: object) -> None:
204223
assert client.ssl_context is False
205224

206225

226+
def test_o11y_get_supports_crud_only_api_token(monkeypatch: pytest.MonkeyPatch) -> None:
227+
def fail(*args: object, **kwargs: object) -> None:
228+
raise AssertionError("o11y configuration must not use standalone validation")
229+
230+
async def async_fail(*args: object, **kwargs: object) -> None:
231+
fail()
232+
233+
monkeypatch.setattr(SplunkAOConfig, "_instance", None)
234+
monkeypatch.setattr(GalileoConfig, "get_jwt_token", staticmethod(fail))
235+
monkeypatch.setattr(ApiClient, "make_request", staticmethod(async_fail))
236+
monkeypatch.setattr(ApiClient, "request", fail)
237+
238+
with config_env(SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_API_TOKEN="api-token"):
239+
cfg = SplunkAOConfig.get(ssl_context=False)
240+
client = cfg.api_client
241+
242+
assert cfg.jwt_token is None
243+
assert isinstance(client, O11yApiClient)
244+
assert client.auth_header == {"X-SF-Token": "api-token"}
245+
246+
207247
def test_ambiguous_environment_fails_before_config_construction(monkeypatch: pytest.MonkeyPatch) -> None:
208248
monkeypatch.setattr(SplunkAOConfig, "_instance", None)
209249
with config_env(SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_TOKEN="tok", SPLUNK_AO_API_KEY="key"):

0 commit comments

Comments
 (0)