From bb25ac9b8ccee7212dee12037591462b83186cff Mon Sep 17 00:00:00 2001 From: Michael Chou Date: Tue, 4 Aug 2026 15:35:54 -0700 Subject: [PATCH 1/2] refactor(agentex): Slack gateway reads bot credentials from env, not DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway acts as ONE fixed identity — a dedicated bot service account, not a per-user proxy — so its credentials (signing secret, bot token, acting API key + account) are static, app-level values. Move them out of the throwaway agent_api_keys DB store into env / k8s-secret, which is where static deployment secrets belong. - Drop _gateway_secret and the DB-store name constants; _fetch_bot_token, _fetch_signing_secret, and _acting_identity now read env directly. - Rename the acting identity user -> bot (SLACK_GATEWAY_ACTING_BOT_API_KEY) to reflect that the bot is its own entity, not the invoking user. - Remove the now-unused get_by_name_and_type repository method. - Update unit tests to the env-based reads. Co-Authored-By: Claude Opus 4.8 --- .../repositories/agent_api_key_repository.py | 19 --- .../use_cases/slack_gateway_use_case.py | 121 +++++------------- .../use_cases/test_slack_gateway_use_case.py | 92 ++++--------- 3 files changed, 58 insertions(+), 174 deletions(-) diff --git a/agentex/src/domain/repositories/agent_api_key_repository.py b/agentex/src/domain/repositories/agent_api_key_repository.py index 666d1b50..9ffd1a27 100644 --- a/agentex/src/domain/repositories/agent_api_key_repository.py +++ b/agentex/src/domain/repositories/agent_api_key_repository.py @@ -112,25 +112,6 @@ async def get_by_agent_id_and_name( row = result.scalars().first() return AgentAPIKeyEntity.model_validate(row) if row else None - async def get_by_name_and_type( - self, name: str, api_key_type: AgentAPIKeyType - ) -> AgentAPIKeyEntity | None: - """Get an API key by (name, type) alone — agent-agnostic. The Slack gateway's - shared app isn't tied to one agent, so it stores its signing secret / bot token - here keyed by name (api_app_id and api_app_id:bot). Returns the first match.""" - async with self.start_async_db_session(allow_writes=False) as session: - query = ( - select(AgentAPIKeyORM) - .where( - AgentAPIKeyORM.name == name, - AgentAPIKeyORM.api_key_type == api_key_type, - ) - .limit(1) - ) - result = await session.execute(query) - row = result.scalars().first() - return AgentAPIKeyEntity.model_validate(row) if row else None - async def get_by_agent_name_and_key_name( self, agent_name: str, key_name: str, api_key_type: AgentAPIKeyType ) -> AgentAPIKeyEntity | None: diff --git a/agentex/src/domain/use_cases/slack_gateway_use_case.py b/agentex/src/domain/use_cases/slack_gateway_use_case.py index a6db76bb..ef7331c8 100644 --- a/agentex/src/domain/use_cases/slack_gateway_use_case.py +++ b/agentex/src/domain/use_cases/slack_gateway_use_case.py @@ -11,21 +11,16 @@ per-agent verifying proxy. This is its own module so Slack-specific logic stays out of the generic path. -v1 identity: every Slack turn acts as ONE shared SGP identity — the acting-user API -key (``SLACK_GATEWAY_ACTING_USER_API_KEY``, env/secret only). It's forwarded as -``x-api-key``, which the platform (a) verifies -> principal for authz and (b) converts -to ``x-acting-user-api-key`` so the agent's tools act as that user via -resolve_user_secrets. Everyone shares its access: fine for a controlled internal -deploy, NOT customer/multi-tenant. STILL STUBBED (marked TODO): the signing-secret/ -bot-token fetch (sgp-secrets microservice), name->config resolution against -``agent_configs``, and reply delivery. Dispatch, delegation, and idempotency are real. - -Future — per-user identity (post-v1): resolve the Slack user -> SGP user via a verified -link. When a user is unlinked, nudge them with an **ephemeral in-channel message** -(``chat.postEphemeral`` — needs only ``chat:write``, no DM/``im:write``) carrying a -signed one-time link to ``/link/slack``; the SGP OIDC login on that page proves the SGP -side and the callback writes ``(team_id, slack_user_id) -> sgp_user_id``. Don't dispatch -until linked. +Identity: every Slack turn acts as the gateway's own SGP identity — a dedicated bot +service account (``SLACK_GATEWAY_ACTING_BOT_API_KEY`` + ``SLACK_GATEWAY_ACCOUNT_ID``, +env / k8s-secret only). The key is forwarded as ``x-api-key``, which the platform (a) +verifies -> principal for authz and (b) converts to ``x-acting-user-api-key`` so the +agent's tools act as the bot via resolve_user_secrets. The bot is a first-class entity, +not a proxy for the invoking user: all Slack traffic shares its account and its tasks +are owned by it — fine for a controlled internal deploy, NOT per-user multi-tenant. +Deliberately NOT per-user: we don't conflate the invoking user's SGP identity with the +bot's. The bot's Slack credentials (signing secret, bot token) live in the same +env / k8s-secret set. Dispatch, delegation, and idempotency are real. """ from __future__ import annotations @@ -49,7 +44,6 @@ database_async_read_write_engine, database_async_read_write_session_maker, ) -from src.domain.entities.agent_api_keys import AgentAPIKeyType from src.domain.entities.agents import ACPType, AgentStatus from src.domain.entities.agents_rpc import ( AgentRPCMethod, @@ -62,7 +56,6 @@ TextContentEntity, TextFormat, ) -from src.domain.repositories.agent_api_key_repository import AgentAPIKeyRepository from src.domain.repositories.agent_repository import AgentRepository from src.utils.logging import make_logger @@ -73,17 +66,16 @@ # agent is "golden-agent", not "golden_agent" (verified against the sgp-dev directory). _DEFAULT_AGENT_NAME = "golden-agent" -# v1: every Slack turn acts as ONE shared SGP identity — this acting-user API key. -# Forwarded as x-api-key: the platform verifies it -> principal (authz) and converts it -# to x-acting-user-api-key downstream (tools act as that user via resolve_user_secrets). -# Everyone shares its access — controlled-internal only, NOT customer/multi-tenant. -# Secret lives in env only, never in code. TODO: replace with a per-user key resolved -# from verified Slack->SGP linking. -_ACTING_USER_API_KEY = os.getenv("SLACK_GATEWAY_ACTING_USER_API_KEY", "") - -# Account the shared identity acts within. REQUIRED alongside the API key — the backend -# authenticates on (x-api-key + x-selected-account-id) together; the key alone 401s -# (verified against sgp-dev). Also forwarded downstream so the agent runs in this account. +# Every Slack turn acts as the gateway's own SGP identity — a dedicated bot service +# account. Forwarded as x-api-key: the platform verifies it -> principal (authz) and +# converts it to x-acting-user-api-key downstream (tools act as the bot via +# resolve_user_secrets). The bot is its own entity, not a proxy for the invoking user; +# all Slack traffic shares its account. Env / k8s-secret only, never in code. +_ACTING_BOT_API_KEY = os.getenv("SLACK_GATEWAY_ACTING_BOT_API_KEY", "") + +# Account the bot acts within. REQUIRED alongside the API key — the backend authenticates +# on (x-api-key + x-selected-account-id) together; the key alone 401s. Also forwarded +# downstream so the agent runs in this account. _ACTING_ACCOUNT_ID = os.getenv("SLACK_GATEWAY_ACCOUNT_ID", "") # DEV ONLY. When true, skip Slack signature verification so the backend pipeline can be @@ -136,18 +128,6 @@ if m.strip() ] -# Fixed, descriptive names for the gateway's own Slack credentials in the throwaway -# agent_api_keys store. One shared app, so we key by these readable names rather than -# the cryptic api_app_id (also avoids colliding with per-agent webhook rows, which use -# name=api_app_id). TODO: replace this whole store with sgp-secrets user-scope. -_BOT_TOKEN_NAME = "slack-bot-token" -_SIGNING_SECRET_NAME = "slack-signing-secret" -# v1 shared acting identity (SGP acting-user API key + account) — same throwaway -# DB store as the tokens, so a deployed (authz-on) gateway can dispatch with a real -# principal. Not a Slack token, but kept in the one gateway-config store for now. -_ACTING_API_KEY_NAME = "slack-acting-user-api-key" -_ACTING_ACCOUNT_ID_NAME = "slack-acting-account-id" - # --------------------------------------------------------------------------- shaping @@ -639,20 +619,15 @@ async def _collect_reply( return last async def _acting_identity(self) -> tuple[Any, dict[str, str]]: - """v1 shared identity. The acting-user API key + account come from the DB - (agent_api_keys, same throwaway store as the tokens), env fallback. Verify the - key -> principal (for authz), and return the credential headers (delegated to - the agent, where x-api-key becomes x-acting-user-api-key). Auth needs BOTH - x-api-key and x-selected-account-id — the key alone 401s. No key configured -> - (None, {}) i.e. dev/authz-bypass.""" - api_key = ( - await self._gateway_secret(_ACTING_API_KEY_NAME) or _ACTING_USER_API_KEY - ) + """The gateway's bot identity. The bot API key + account come from env / + k8s-secret. Verify the key -> principal (for authz), and return the credential + headers (delegated to the agent, where x-api-key becomes x-acting-user-api-key). + Auth needs BOTH x-api-key and x-selected-account-id — the key alone 401s. No key + configured -> (None, {}) i.e. dev/authz-bypass.""" + api_key = _ACTING_BOT_API_KEY if not api_key: return None, {} - account_id = ( - await self._gateway_secret(_ACTING_ACCOUNT_ID_NAME) or _ACTING_ACCOUNT_ID - ) + account_id = _ACTING_ACCOUNT_ID # Local imports avoid an import cycle at module load. from src.adapters.authentication.adapter_agentex_authn_proxy import ( AgentexAuthenticationProxy, @@ -672,39 +647,10 @@ async def _acting_identity(self) -> tuple[Any, dict[str, str]]: principal = await authn.verify_headers(headers) return principal, headers - # --- STUBS (each is a real net-new piece; do NOT ship as-is) ---------------- - - async def _gateway_secret(self, name: str) -> str: - """Read a Slack gateway secret from the ``agent_api_keys`` table by (name, - SLACK). THROWAWAY store — plaintext, reusing the existing table by naming - convention (api_app_id = signing secret, api_app_id:bot = bot token) so it - needs no migration. To be replaced by sgp-secrets user-scope. Fail-safe: any - error (incl. no DB in unit tests) returns "" so callers fall back to env.""" - if not name: - return "" - try: - engine = database_async_read_write_engine() - repo = AgentAPIKeyRepository( - database_async_read_write_session_maker(engine), - database_async_read_only_session_maker(engine), - ) - row = await repo.get_by_name_and_type(name, AgentAPIKeyType.SLACK) - return row.api_key if row else "" - except Exception: # noqa: BLE001 - fail-safe to env fallback - logger.debug( - "gateway-secret DB read failed for %r; env fallback", - name, - exc_info=True, - ) - return "" - async def _fetch_signing_secret(self, api_app_id: str) -> str: - # Throwaway DB store (agent_api_keys, name="slack-signing-secret", type SLACK); - # env fallback for local dev. Empty => verify_signature fails closed. api_app_id - # is unused (one shared app) but kept on the interface. - return await self._gateway_secret(_SIGNING_SECRET_NAME) or os.getenv( - "SLACK_SIGNING_SECRET", "" - ) + # Signing secret from env / k8s-secret. Empty => verify_signature fails closed. + # api_app_id is unused (one shared app) but kept on the interface. + return os.getenv("SLACK_SIGNING_SECRET", "") async def _resolve_account(self, team_id: str) -> str: # TODO: Slack team_id -> SGP account (tenant-aware from day one). v1 derives the @@ -781,11 +727,8 @@ async def _authorize(self, target: Target) -> bool: return True async def _fetch_bot_token(self) -> str: - # Throwaway DB store (agent_api_keys, name="slack-bot-token", type SLACK); env - # fallback for local dev. - return await self._gateway_secret(_BOT_TOKEN_NAME) or os.getenv( - "SLACK_BOT_TOKEN", "" - ) + # Bot token from env / k8s-secret. + return os.getenv("SLACK_BOT_TOKEN", "") async def _set_status(self, inbound: InboundSlack, status: str) -> None: """AI-app 'thinking…' indicator (assistant.threads.setStatus). Shows in the diff --git a/agentex/tests/unit/use_cases/test_slack_gateway_use_case.py b/agentex/tests/unit/use_cases/test_slack_gateway_use_case.py index 1d586e9d..74ef76b9 100644 --- a/agentex/tests/unit/use_cases/test_slack_gateway_use_case.py +++ b/agentex/tests/unit/use_cases/test_slack_gateway_use_case.py @@ -370,7 +370,7 @@ class TestDispatch: @pytest.mark.asyncio async def test_new_thread_creates_task_then_sends_event(self, monkeypatch): monkeypatch.setattr( - sg, "_ACTING_USER_API_KEY", "" + sg, "_ACTING_BOT_API_KEY", "" ) # -> (None, {}), no authn proxy monkeypatch.setattr(sg, "GlobalDependencies", MagicMock()) acp, _ = _fake_acp(existing_task=None) # new thread → create @@ -416,7 +416,7 @@ async def test_new_thread_creates_task_then_sends_event(self, monkeypatch): async def test_existing_thread_skips_create_but_sends_event(self, monkeypatch): # Same-thread follow-up: the task/workflow already exists, so we must NOT # TASK_CREATE (it would re-start the running workflow) — only EVENT_SEND. - monkeypatch.setattr(sg, "_ACTING_USER_API_KEY", "") + monkeypatch.setattr(sg, "_ACTING_BOT_API_KEY", "") monkeypatch.setattr(sg, "GlobalDependencies", MagicMock()) existing = SimpleNamespace(id="task_1", task_metadata=None) acp, _ = _fake_acp(existing_task=existing) @@ -447,7 +447,7 @@ async def test_concurrent_first_turn_falls_back_to_existing_task(self, monkeypat # globally-unique task name is already taken), so TASK_CREATE raises # DuplicateItemError. It must fall back to the task the winner created and # still send its own event rather than dropping the turn. - monkeypatch.setattr(sg, "_ACTING_USER_API_KEY", "") + monkeypatch.setattr(sg, "_ACTING_BOT_API_KEY", "") monkeypatch.setattr(sg, "GlobalDependencies", MagicMock()) winner_task = SimpleNamespace(id="task_1", task_metadata=None) acp = MagicMock() @@ -494,7 +494,7 @@ async def test_async_create_race_retries_lookup_through_replica_lag( # After the create-race, the fallback get_task reads the replica — which may lag # and miss the winner's task. Retry until it catches up rather than dropping the # turn with ItemDoesNotExist. - monkeypatch.setattr(sg, "_ACTING_USER_API_KEY", "") + monkeypatch.setattr(sg, "_ACTING_BOT_API_KEY", "") monkeypatch.setattr(sg, "GlobalDependencies", MagicMock()) monkeypatch.setattr(sg, "_CREATE_RACE_BACKOFF_S", 0.0) winner_task = SimpleNamespace(id="task_1", task_metadata=None) @@ -541,7 +541,7 @@ async def rpc(*, method, **_): async def test_sync_agent_uses_message_send_and_returns_reply(self, monkeypatch): # A SYNC agent has no event stream: dispatch does ONE message/send (get-or-create # + reply), not task/create + event/send + poll. - monkeypatch.setattr(sg, "_ACTING_USER_API_KEY", "") + monkeypatch.setattr(sg, "_ACTING_BOT_API_KEY", "") monkeypatch.setattr(sg, "GlobalDependencies", MagicMock()) acp, _ = _fake_acp(acp_type=sg.ACPType.SYNC) reply_msg = SimpleNamespace( @@ -575,7 +575,7 @@ async def test_sync_agent_uses_message_send_and_returns_reply(self, monkeypatch) @staticmethod def _sync_race_acp(monkeypatch, side_effect): - monkeypatch.setattr(sg, "_ACTING_USER_API_KEY", "") + monkeypatch.setattr(sg, "_ACTING_BOT_API_KEY", "") monkeypatch.setattr(sg, "GlobalDependencies", MagicMock()) monkeypatch.setattr( sg, "_CREATE_RACE_BACKOFF_S", 0.0 @@ -792,7 +792,7 @@ async def test_event_routes_to_non_golden_runtime_end_to_end(self, monkeypatch): @pytest.mark.asyncio async def test_dispatch_looks_up_the_target_agent_by_name(self, monkeypatch): - monkeypatch.setattr(sg, "_ACTING_USER_API_KEY", "") + monkeypatch.setattr(sg, "_ACTING_BOT_API_KEY", "") monkeypatch.setattr(sg, "GlobalDependencies", MagicMock()) acp, _ = _fake_acp(existing_task=None) monkeypatch.setattr( @@ -970,41 +970,27 @@ async def test_no_token_skips_without_error(self, monkeypatch): @pytest.mark.unit class TestGatewaySecrets: - """The throwaway DB store: bot token / signing secret read from agent_api_keys - (via _gateway_secret), env fallback, and fail-safe when there's no DB.""" + """Bot token / signing secret read straight from env / k8s-secret.""" @pytest.mark.asyncio - async def test_bot_token_from_db(self, monkeypatch): - uc = SlackGatewayUseCase() - monkeypatch.setattr( - uc, "_gateway_secret", AsyncMock(return_value="xoxb-from-db") - ) - assert await uc._fetch_bot_token() == "xoxb-from-db" - uc._gateway_secret.assert_awaited_once_with("slack-bot-token") + async def test_bot_token_from_env(self, monkeypatch): + monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-env") + assert await SlackGatewayUseCase()._fetch_bot_token() == "xoxb-env" @pytest.mark.asyncio - async def test_bot_token_env_fallback(self, monkeypatch): - monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-env") - uc = SlackGatewayUseCase() - monkeypatch.setattr(uc, "_gateway_secret", AsyncMock(return_value="")) - assert await uc._fetch_bot_token() == "xoxb-env" + async def test_bot_token_absent_is_empty(self, monkeypatch): + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + assert await SlackGatewayUseCase()._fetch_bot_token() == "" @pytest.mark.asyncio - async def test_signing_secret_from_db(self, monkeypatch): - uc = SlackGatewayUseCase() - monkeypatch.setattr(uc, "_gateway_secret", AsyncMock(return_value="sign-db")) - assert await uc._fetch_signing_secret("A123") == "sign-db" - uc._gateway_secret.assert_awaited_once_with("slack-signing-secret") + async def test_signing_secret_from_env(self, monkeypatch): + monkeypatch.setenv("SLACK_SIGNING_SECRET", "sign-env") + assert await SlackGatewayUseCase()._fetch_signing_secret("A123") == "sign-env" @pytest.mark.asyncio - async def test_gateway_secret_fail_safe_without_db(self, monkeypatch): - # No engine / GlobalDependencies in a unit test → fail-safe to "". - monkeypatch.setattr( - sg, - "database_async_read_write_engine", - MagicMock(side_effect=RuntimeError("no db")), - ) - assert await SlackGatewayUseCase()._gateway_secret("A123:bot") == "" + async def test_signing_secret_absent_is_empty(self, monkeypatch): + monkeypatch.delenv("SLACK_SIGNING_SECRET", raising=False) + assert await SlackGatewayUseCase()._fetch_signing_secret("A123") == "" def _patch_authn(monkeypatch, user_id="u1"): @@ -1025,50 +1011,24 @@ def _patch_authn(monkeypatch, user_id="u1"): class TestActingIdentity: @pytest.mark.asyncio async def test_no_key_is_dev_bypass(self, monkeypatch): - monkeypatch.setattr(sg, "_ACTING_USER_API_KEY", "") - uc = SlackGatewayUseCase() - monkeypatch.setattr( - uc, "_gateway_secret", AsyncMock(return_value="") - ) # no DB value - principal, headers = await uc._acting_identity() + monkeypatch.setattr(sg, "_ACTING_BOT_API_KEY", "") + principal, headers = await SlackGatewayUseCase()._acting_identity() assert principal is None assert headers == {} @pytest.mark.asyncio - async def test_env_fallback_sends_both_headers(self, monkeypatch): - # DB empty → fall back to env; auth needs x-api-key AND x-selected-account-id. - monkeypatch.setattr(sg, "_ACTING_USER_API_KEY", "ssk_test") + async def test_sends_both_headers(self, monkeypatch): + # Auth needs x-api-key AND x-selected-account-id together. + monkeypatch.setattr(sg, "_ACTING_BOT_API_KEY", "ssk_test") monkeypatch.setattr(sg, "_ACTING_ACCOUNT_ID", "acct_1") - uc = SlackGatewayUseCase() - monkeypatch.setattr(uc, "_gateway_secret", AsyncMock(return_value="")) fake_authn = _patch_authn(monkeypatch) - principal, headers = await uc._acting_identity() + principal, headers = await SlackGatewayUseCase()._acting_identity() assert headers == {"x-api-key": "ssk_test", "x-selected-account-id": "acct_1"} fake_authn.verify_headers.assert_awaited_once_with(headers) assert principal.user_id == "u1" - @pytest.mark.asyncio - async def test_db_takes_precedence_over_env(self, monkeypatch): - # DB values (slack-acting-user-api-key / slack-acting-account-id) win over env. - monkeypatch.setattr(sg, "_ACTING_USER_API_KEY", "env-key") - monkeypatch.setattr(sg, "_ACTING_ACCOUNT_ID", "env-acct") - uc = SlackGatewayUseCase() - - async def fake_secret(name): - return { - "slack-acting-user-api-key": "db-key", - "slack-acting-account-id": "db-acct", - }.get(name, "") - - monkeypatch.setattr(uc, "_gateway_secret", AsyncMock(side_effect=fake_secret)) - _patch_authn(monkeypatch) - - _, headers = await uc._acting_identity() - - assert headers == {"x-api-key": "db-key", "x-selected-account-id": "db-acct"} - @pytest.mark.unit class TestGetAgentByNameRepo: From 64dada9020e00e48276aee310c7fe2f40454c635 Mon Sep 17 00:00:00 2001 From: Michael Chou Date: Tue, 4 Aug 2026 15:53:13 -0700 Subject: [PATCH 2/2] fix(agentex): Slack gateway fails closed when the bot key is missing under authz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the agent_api_keys fallback widened an existing fail-open: with the credential sourced only from env, a deployment that upgrades without setting SLACK_GATEWAY_ACTING_BOT_API_KEY dropped to the (None, {}) dev bypass and _run_turn dispatched with no principal and no auth headers — an unauthenticated downstream request / authz-boundary bypass. _acting_identity now fails closed: when the bot key is absent AND authz is enabled (AGENTEX_AUTH_URL set), it raises instead of returning the empty identity, so the turn stops before target resolution and dispatch. The empty (None, {}) bypass is retained only when AGENTEX_AUTH_URL is unset (local, authz-off) dev. Gated on os.getenv so the check reads env directly. Co-Authored-By: Claude Opus 4.8 --- .../domain/use_cases/slack_gateway_use_case.py | 16 +++++++++++++--- .../use_cases/test_slack_gateway_use_case.py | 12 +++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/agentex/src/domain/use_cases/slack_gateway_use_case.py b/agentex/src/domain/use_cases/slack_gateway_use_case.py index ef7331c8..ae3097b3 100644 --- a/agentex/src/domain/use_cases/slack_gateway_use_case.py +++ b/agentex/src/domain/use_cases/slack_gateway_use_case.py @@ -622,11 +622,21 @@ async def _acting_identity(self) -> tuple[Any, dict[str, str]]: """The gateway's bot identity. The bot API key + account come from env / k8s-secret. Verify the key -> principal (for authz), and return the credential headers (delegated to the agent, where x-api-key becomes x-acting-user-api-key). - Auth needs BOTH x-api-key and x-selected-account-id — the key alone 401s. No key - configured -> (None, {}) i.e. dev/authz-bypass.""" + Auth needs BOTH x-api-key and x-selected-account-id — the key alone 401s. + + Missing bot key: FAIL CLOSED when authz is enabled (AGENTEX_AUTH_URL set), so a + misconfigured deploy never dispatches unauthenticated (which would run with no + principal, bypassing the per-turn authz boundary). Only the authz-off local case + (no AGENTEX_AUTH_URL) is allowed to run with no principal — the dev bypass.""" api_key = _ACTING_BOT_API_KEY if not api_key: - return None, {} + if os.getenv("AGENTEX_AUTH_URL"): + raise RuntimeError( + "SLACK_GATEWAY_ACTING_BOT_API_KEY is unset while authz is enabled " + "(AGENTEX_AUTH_URL); refusing to dispatch a Slack turn without a bot " + "principal." + ) + return None, {} # authz off (local dev) — run with no principal account_id = _ACTING_ACCOUNT_ID # Local imports avoid an import cycle at module load. from src.adapters.authentication.adapter_agentex_authn_proxy import ( diff --git a/agentex/tests/unit/use_cases/test_slack_gateway_use_case.py b/agentex/tests/unit/use_cases/test_slack_gateway_use_case.py index 74ef76b9..4af7f611 100644 --- a/agentex/tests/unit/use_cases/test_slack_gateway_use_case.py +++ b/agentex/tests/unit/use_cases/test_slack_gateway_use_case.py @@ -1010,12 +1010,22 @@ def _patch_authn(monkeypatch, user_id="u1"): @pytest.mark.unit class TestActingIdentity: @pytest.mark.asyncio - async def test_no_key_is_dev_bypass(self, monkeypatch): + async def test_no_key_authz_off_is_dev_bypass(self, monkeypatch): + # authz off (no AGENTEX_AUTH_URL) + no bot key -> local dev bypass, no principal. monkeypatch.setattr(sg, "_ACTING_BOT_API_KEY", "") + monkeypatch.delenv("AGENTEX_AUTH_URL", raising=False) principal, headers = await SlackGatewayUseCase()._acting_identity() assert principal is None assert headers == {} + @pytest.mark.asyncio + async def test_no_key_authz_on_fails_closed(self, monkeypatch): + # authz on (AGENTEX_AUTH_URL set) + no bot key -> refuse to dispatch unauthenticated. + monkeypatch.setattr(sg, "_ACTING_BOT_API_KEY", "") + monkeypatch.setenv("AGENTEX_AUTH_URL", "http://auth") + with pytest.raises(RuntimeError, match="refusing to dispatch"): + await SlackGatewayUseCase()._acting_identity() + @pytest.mark.asyncio async def test_sends_both_headers(self, monkeypatch): # Auth needs x-api-key AND x-selected-account-id together.