Skip to content

feat(security): prevent merchant credentials in templates and resolve at request time inmemory - #961

Open
MonishJuspay wants to merge 1 commit into
juspay:releasefrom
MonishJuspay:prevent-loading-credential-into-templates
Open

feat(security): prevent merchant credentials in templates and resolve at request time inmemory#961
MonishJuspay wants to merge 1 commit into
juspay:releasefrom
MonishJuspay:prevent-loading-credential-into-templates

Conversation

@MonishJuspay

@MonishJuspay MonishJuspay commented Jul 29, 2026

Copy link
Copy Markdown
Contributor
  • add global, reseller-shared, and merchant credential scopes
  • resolve bearer, API key, basic, and custom credentials at request time
  • support credential IDs in HTTP functions, hooks, pre-checks, and MCP
  • keep credential-ID secrets in memory only during outbound requests

Current PR follows the safe migration path:
Where the default credentials from the DB reseller levell is currently refered by the multiple templates, so after the current PR is getting released, those templates can be added with the new credential_id based flow instead of having the {token}, post the template token migration, we can remove the credential to template_vars for blocking the path of credential to LLM leakage completely.

Test proof:

Global HTTP function

image image image

Hook Path Test:

image image image image

Callexecution-config Pre-check credential loading path

image image

MCP-server credential loading path

image image

Credential_type: Basic

image image

Deprecated Alert
For migration of the existing templates
image

Summary by CodeRabbit

  • New Features
    • Added merchant-scoped credential management, including creation, filtering, and access controls.
    • Added credential-based authentication for external HTTP requests and MCP connections.
    • Added support for custom authentication headers.
    • Credentials are now resolved securely at request time, supporting credential rotation without cache invalidation.
  • Bug Fixes
    • Improved validation and scope handling for reseller and merchant credentials.
    • Prevented unauthorized or inactive credentials from being used.

Copilot AI review requested due to automatic review settings July 29, 2026 17:51
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 941f5af4-4bec-4ad1-8ce1-792431ccf4fc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Adds merchant-scoped credentials, server-side credential resolution, custom HTTP authentication, and credential-aware MCP loading. Credential APIs, database access, pre-checks, HTTP handlers, templates, and chat/voice integrations now pass reseller and merchant scope.

Changes

Credential-scoped authentication

Layer / File(s) Summary
Merchant credential storage and scope
app/schemas/breeze_buddy/credentials.py, app/database/migrations/..., app/database/queries/..., app/database/accessor/..., app/database/decoder/...
Credential models, database indexes, queries, accessors, and decoding now support optional merchant scope and scoped active-credential lookup.
Credential-based HTTP auth contract
app/ai/voice/agents/breeze_buddy/template/types.py, app/services/credential_auth.py, app/ai/voice/agents/breeze_buddy/template/generator/prompts.py
HTTP auth supports credential references and custom header bindings; validation and server-side resolution cover bearer, API-key, basic, and custom authentication.
Resolved HTTP request execution
app/ai/voice/agents/breeze_buddy/handlers/transport/*, app/ai/voice/agents/breeze_buddy/managers/pre_checks.py, app/ai/voice/agents/breeze_buddy/template/hooks.py
Request paths resolve scoped credentials before execution and inject resolved or custom headers.
MCP credential-aware tool loading
app/ai/voice/agents/breeze_buddy/mcp/*, app/ai/voice/agents/breeze_buddy/agent/__init__.py, app/ai/voice/agents/breeze_buddy/chat/agent.py
Voice and chat MCP loading now receives reseller and merchant identifiers and resolves authentication at connection time.
Credential API scope handling
app/api/routers/breeze_buddy/credentials/*
Credential endpoints validate reseller/merchant scope access and support merchant-filtered listing and creation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant http_function_handler
  participant resolve_credential_auth
  participant CredentialDatabase
  participant executor.execute
  Caller->>http_function_handler: provide request configuration
  http_function_handler->>resolve_credential_auth: resolve scoped credential
  resolve_credential_auth->>CredentialDatabase: fetch active credential
  CredentialDatabase-->>resolve_credential_auth: credential value
  resolve_credential_auth-->>http_function_handler: resolved auth
  http_function_handler->>executor.execute: execute resolved request
  executor.execute-->>Caller: return response
Loading

Possibly related PRs

Suggested reviewers: copilot

Poem

I’m a rabbit with headers, hopping through the flow,
Credentials find their scope wherever calls may go.
Merchant keys and reseller trails
Guide MCP and HTTP sails.
Custom banners bloom just right—
Safe auth resolved at request night.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 86.96% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: moving merchant credentials out of templates and resolving them at request time.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@MonishJuspay MonishJuspay changed the title feat(security): prevent merchant credentials in templates and resolve… feat(security): prevent merchant credentials in templates and resolve at request time Jul 29, 2026
@MonishJuspay MonishJuspay changed the title feat(security): prevent merchant credentials in templates and resolve at request time feat(security): prevent merchant credentials in templates and resolve at request time inmemory Jul 29, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces credential scoping (global, reseller-shared, merchant-specific) and shifts HTTP/MCP authentication to reference credentials by ID, resolving secrets server-side at request/connection time so templates don’t embed merchant credential values.

Changes:

  • Add merchant_id scoping to credentials (schema, queries, decoder, migration, API handlers/RBAC).
  • Add HttpAuthConfig.credential_id + custom auth support, and resolve credential-backed auth server-side at execution time.
  • Thread credential resolution through hooks, HTTP global functions, pre-checks, and MCP tool discovery/connection setup.

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
app/services/credential_auth.py Adds server-side resolution of credential_id into in-memory HttpAuthConfig values.
app/schemas/breeze_buddy/credentials.py Adds merchant_id to credential create/model and validates scope rules.
app/database/queries/breeze_buddy/credentials.py Extends insert/list queries for merchant scope and adds scoped “active by id” query.
app/database/migrations/042_add_credential_merchant_scope.sql Adds merchant_id column and supporting indexes for scoped uniqueness/lookups.
app/database/decoder/breeze_buddy/credentials.py Decodes merchant_id; keeps legacy template var flattening with updated wording.
app/database/accessor/breeze_buddy/credentials.py Threads merchant_id through create/list; adds accessor for scoped active credential by id.
app/database/accessor/init.py Exposes get_active_credential_by_id_for_scope at accessor package level.
app/api/routers/breeze_buddy/credentials/handlers.py Passes merchant_id through create/list handlers; updates list logging.
app/api/routers/breeze_buddy/credentials/init.py Adds merchant_id filter and updates RBAC checks for reseller/merchant scopes.
app/ai/voice/agents/breeze_buddy/template/types.py Adds credential_id, custom auth, and validation preventing inline secrets with credential refs.
app/ai/voice/agents/breeze_buddy/template/loader.py Reframes legacy credential placeholder loading as a compatibility path.
app/ai/voice/agents/breeze_buddy/template/hooks.py Resolves credential-backed auth before executing external HTTP hooks.
app/ai/voice/agents/breeze_buddy/template/generator/prompts.py Updates prompt examples to use credential_id instead of {placeholder} secrets.
app/ai/voice/agents/breeze_buddy/mcp/cache.py Updates caching docs to reflect credential-id-based auth resolution.
app/ai/voice/agents/breeze_buddy/mcp/init.py Makes MCP auth/header building async and resolves auth via credential_id at connection time.
app/ai/voice/agents/breeze_buddy/managers/pre_checks.py Adds custom auth parsing and resolves credential_id before executing pre-check HTTP calls.
app/ai/voice/agents/breeze_buddy/handlers/transport/http_requester.py Adds support for injecting resolved custom headers into outbound HTTP requests.
app/ai/voice/agents/breeze_buddy/handlers/transport/http_handler.py Resolves credential_id-backed auth before executing HTTP global functions.
app/ai/voice/agents/breeze_buddy/chat/turn_core.py Minor renaming/clarity around legacy credential placeholder loading in chat.
app/ai/voice/agents/breeze_buddy/chat/agent.py Passes reseller/merchant IDs into cached MCP tool loading for auth resolution.
app/ai/voice/agents/breeze_buddy/agent/init.py Passes reseller/merchant IDs into MCP tool loading for auth resolution in voice agent.

Comment on lines +8 to +13
ALTER TABLE credentials
ADD COLUMN IF NOT EXISTS merchant_id VARCHAR(255);

CREATE INDEX IF NOT EXISTS idx_credentials_reseller_merchant_active
ON credentials(reseller_id, merchant_id)
WHERE is_active = TRUE;
Comment on lines 96 to 100
List credentials with optional merchant filter.

- Admin: sees all credentials or filtered by merchant
- Merchant: must provide reseller_id, sees merchant + global credentials
- Values are always masked in responses

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/ai/voice/agents/breeze_buddy/managers/pre_checks.py (1)

182-234: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Auth dict conversion silently drops ALL auth on a credential_id/inline-value conflict.

HttpAuthConfig(...) raises when credential_id and any inline secret (token/username/password/api_key_value) are both present in auth_dict (per the new validate_credential_reference validator in types.py) — e.g. a stale token field left behind when a pre-check config is migrated to credential_id. The except here only logs a warning and returns None, so run_pre_checks proceeds with auth=None, sending the pre-check's external request completely unauthenticated instead of failing the check. Since pre-check pass/fail gates whether the call proceeds, this can silently produce a wrong proceed/deny decision.

Consider stripping/ignoring stale inline fields when credential_id is present before constructing HttpAuthConfig, or treat a conversion failure as an execution error (matching the pattern already used for other pre-check failures) rather than silently falling through to no-auth.

🛡️ Proposed fix
-        return HttpAuthConfig(
-            type=auth_type,
-            credential_id=auth_dict.get("credential_id"),
-            token=auth_dict.get("token"),
-            username=auth_dict.get("username"),
-            password=auth_dict.get("password"),
-            api_key_name=auth_dict.get("api_key_name"),
-            api_key_value=auth_dict.get("api_key_value"),
-            header_bindings=auth_dict.get("header_bindings") or {},
-        )
+        credential_id = auth_dict.get("credential_id")
+        return HttpAuthConfig(
+            type=auth_type,
+            credential_id=credential_id,
+            token=None if credential_id else auth_dict.get("token"),
+            username=None if credential_id else auth_dict.get("username"),
+            password=None if credential_id else auth_dict.get("password"),
+            api_key_name=auth_dict.get("api_key_name"),
+            api_key_value=None if credential_id else auth_dict.get("api_key_value"),
+            header_bindings=auth_dict.get("header_bindings") or {},
+        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/ai/voice/agents/breeze_buddy/managers/pre_checks.py` around lines 182 -
234, Update _convert_auth_dict_to_config so credential_id conflicts with stale
inline secrets are handled before constructing HttpAuthConfig, preferably by
ignoring/removing token, username, password, and api_key_value when a
credential_id is present; alternatively propagate conversion failure as a
pre-check execution error instead of returning None. Preserve unauthenticated
behavior only for genuinely absent auth configuration.
🧹 Nitpick comments (2)
app/api/routers/breeze_buddy/credentials/__init__.py (1)

134-204: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding tests for the new scope-authorization matrix.

_has_scope-based checks now gate credential visibility/mutation across global/reseller-shared/merchant tiers. Given the security sensitivity, targeted unit tests for admin vs. non-admin, in-scope vs. out-of-scope reseller/merchant combinations would help guard against regressions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/routers/breeze_buddy/credentials/__init__.py` around lines 134 - 204,
Add targeted unit tests for get_credential_endpoint and
update_credential_endpoint covering admin access, non-admin access to global,
reseller-shared, and merchant-scoped credentials, plus in-scope and out-of-scope
reseller/merchant combinations. Assert that permitted requests proceed and
denied requests raise HTTPException with the expected 403 status, preserving the
existing _has_scope authorization matrix.
app/services/credential_auth.py (1)

18-85: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Consider caching resolved credentials per call/session.

resolve_credential_auth hits the DB on every invocation, and it's now called from HTTP functions, hooks, pre-checks, and MCP server connections — several of these can fire multiple times per call (e.g. one MCP connection per server, one hook per triggering function). A short-lived, per-call cache keyed by credential_id (e.g. attached to TemplateContext/call state) would cut redundant DB round-trips without weakening the "resolve at request time" security property, since the cache would still be scoped to a single call's lifetime.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/services/credential_auth.py` around lines 18 - 85, Add a short-lived
credential-resolution cache scoped to the current call or session state, keyed
by credential_id and reused by resolve_credential_auth and its HTTP, hook,
pre-check, and MCP callers. Preserve request-time resolution and existing
reseller_id/merchant_id scope validation, while avoiding repeated database calls
within the same call lifetime; keep independent calls from sharing cached
credentials.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/ai/voice/agents/breeze_buddy/handlers/transport/http_handler.py`:
- Around line 127-148: Extract the template-first, lead-fallback
reseller_id/merchant_id resolution into a shared helper and use it for
credential auth scope resolution. Update
app/ai/voice/agents/breeze_buddy/handlers/transport/http_handler.py:127-148 and
app/ai/voice/agents/breeze_buddy/template/hooks.py:403-414 to call the helper,
and update app/ai/voice/agents/breeze_buddy/managers/pre_checks.py:318-324 to
pass its existing template argument instead of resolving from lead alone;
preserve the template-over-lead precedence across all three call sites.

In `@app/ai/voice/agents/breeze_buddy/mcp/__init__.py`:
- Around line 495-512: Update _build_auth_headers to record whether server.auth
was originally credential-backed before calling resolve_credential_auth. When
the resolved auth contains SecretStr credential fields, use their underlying
values verbatim and do not pass them through resolve; retain template
substitution only for values supplied directly in the original template
configuration, including the related logic around lines 527-531.

In `@app/ai/voice/agents/breeze_buddy/template/generator/prompts.py`:
- Around line 331-340: Update the authentication guidance in the prompt template
documentation to include custom authentication alongside bearer, basic, and
API-key authentication. State that custom auth must use a configured
credential_id with header_bindings, and that its credential values are resolved
only when making the request rather than embedded in templates.

In `@app/api/routers/breeze_buddy/credentials/__init__.py`:
- Around line 147-162: The RBAC denial paths in get_credential_endpoint and
update_credential_endpoint must not expose credential.reseller_id or
credential.merchant_id. Replace both scope-specific 403 detail messages with a
generic access-denied message, preserving the existing scope checks and status
code.

---

Outside diff comments:
In `@app/ai/voice/agents/breeze_buddy/managers/pre_checks.py`:
- Around line 182-234: Update _convert_auth_dict_to_config so credential_id
conflicts with stale inline secrets are handled before constructing
HttpAuthConfig, preferably by ignoring/removing token, username, password, and
api_key_value when a credential_id is present; alternatively propagate
conversion failure as a pre-check execution error instead of returning None.
Preserve unauthenticated behavior only for genuinely absent auth configuration.

---

Nitpick comments:
In `@app/api/routers/breeze_buddy/credentials/__init__.py`:
- Around line 134-204: Add targeted unit tests for get_credential_endpoint and
update_credential_endpoint covering admin access, non-admin access to global,
reseller-shared, and merchant-scoped credentials, plus in-scope and out-of-scope
reseller/merchant combinations. Assert that permitted requests proceed and
denied requests raise HTTPException with the expected 403 status, preserving the
existing _has_scope authorization matrix.

In `@app/services/credential_auth.py`:
- Around line 18-85: Add a short-lived credential-resolution cache scoped to the
current call or session state, keyed by credential_id and reused by
resolve_credential_auth and its HTTP, hook, pre-check, and MCP callers. Preserve
request-time resolution and existing reseller_id/merchant_id scope validation,
while avoiding repeated database calls within the same call lifetime; keep
independent calls from sharing cached credentials.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 09c81280-95c2-4e46-a7e6-d6a1dc6b5816

📥 Commits

Reviewing files that changed from the base of the PR and between c7ac450 and e80efaf.

📒 Files selected for processing (21)
  • app/ai/voice/agents/breeze_buddy/agent/__init__.py
  • app/ai/voice/agents/breeze_buddy/chat/agent.py
  • app/ai/voice/agents/breeze_buddy/chat/turn_core.py
  • app/ai/voice/agents/breeze_buddy/handlers/transport/http_handler.py
  • app/ai/voice/agents/breeze_buddy/handlers/transport/http_requester.py
  • app/ai/voice/agents/breeze_buddy/managers/pre_checks.py
  • app/ai/voice/agents/breeze_buddy/mcp/__init__.py
  • app/ai/voice/agents/breeze_buddy/mcp/cache.py
  • app/ai/voice/agents/breeze_buddy/template/generator/prompts.py
  • app/ai/voice/agents/breeze_buddy/template/hooks.py
  • app/ai/voice/agents/breeze_buddy/template/loader.py
  • app/ai/voice/agents/breeze_buddy/template/types.py
  • app/api/routers/breeze_buddy/credentials/__init__.py
  • app/api/routers/breeze_buddy/credentials/handlers.py
  • app/database/accessor/__init__.py
  • app/database/accessor/breeze_buddy/credentials.py
  • app/database/decoder/breeze_buddy/credentials.py
  • app/database/migrations/042_add_credential_merchant_scope.sql
  • app/database/queries/breeze_buddy/credentials.py
  • app/schemas/breeze_buddy/credentials.py
  • app/services/credential_auth.py

Comment on lines +127 to +148
template = getattr(context.bot, "template", None)
reseller_id = getattr(template, "reseller_id", None)
merchant_id = getattr(template, "merchant_id", None)
if not reseller_id:
reseller_id = getattr(context.lead, "reseller_id", None)
if merchant_id is None:
merchant_id = getattr(context.lead, "merchant_id", None)
resolved_auth = await resolve_credential_auth(
config.http_request.auth,
reseller_id=reseller_id,
merchant_id=merchant_id,
)
request_config = config.http_request.model_copy(
update={"auth": resolved_auth}
)

# Step 2: Create executor
executor = HttpRequestExecutor(session=context.aiohttp_session)

logger.info(
f"[{function_name}] Executing HTTP {config.http_request.method.value} "
f"request to {config.http_request.url}"
f"[{function_name}] Executing HTTP {request_config.method.value} "
f"request to {request_config.url}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Credential-scope (reseller_id/merchant_id) derivation is duplicated and inconsistent across call sites.

http_handler.py and hooks.py both derive scope by trying context.bot.template.reseller_id/merchant_id first and falling back to context.lead, while pre_checks.py uses lead.reseller_id/lead.merchant_id directly even though run_pre_checks already receives the template parameter. This means the same logical operation — resolving a credential-scoped HttpAuthConfig for an outbound HTTP call — can pick a different reseller/merchant scope depending on whether it's triggered via an HTTP global function, a hook, or a pre-check, risking a credential lookup succeeding/failing (or resolving to the wrong scope) inconsistently between these paths.

  • app/ai/voice/agents/breeze_buddy/handlers/transport/http_handler.py#L127-L148: extract the template-then-lead fallback into a shared helper (e.g. in credential_auth.py or a small scope-resolution util) and use it here.
  • app/ai/voice/agents/breeze_buddy/template/hooks.py#L403-L414: use the same shared helper instead of re-implementing the fallback.
  • app/ai/voice/agents/breeze_buddy/managers/pre_checks.py#L318-L324: use the same shared helper (passing the already-available template argument) instead of resolving scope from lead alone, so pre-checks match the precedence used by HTTP functions/hooks.
📍 Affects 3 files
  • app/ai/voice/agents/breeze_buddy/handlers/transport/http_handler.py#L127-L148 (this comment)
  • app/ai/voice/agents/breeze_buddy/template/hooks.py#L403-L414
  • app/ai/voice/agents/breeze_buddy/managers/pre_checks.py#L318-L324
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/ai/voice/agents/breeze_buddy/handlers/transport/http_handler.py` around
lines 127 - 148, Extract the template-first, lead-fallback
reseller_id/merchant_id resolution into a shared helper and use it for
credential auth scope resolution. Update
app/ai/voice/agents/breeze_buddy/handlers/transport/http_handler.py:127-148 and
app/ai/voice/agents/breeze_buddy/template/hooks.py:403-414 to call the helper,
and update app/ai/voice/agents/breeze_buddy/managers/pre_checks.py:318-324 to
pass its existing template argument instead of resolving from lead alone;
preserve the template-over-lead precedence across all three call sites.

Comment on lines +495 to +512
async def _build_auth_headers(
server: McpServerConfig,
template_vars: Dict[str, Any],
reseller_id: Optional[str],
merchant_id: Optional[str],
) -> Dict[str, str]:
"""Resolve auth config into HTTP headers, substituting {variable} placeholders."""
"""Resolve MCP auth into HTTP headers without exposing credential values."""
if not server.auth or server.auth.type == HttpAuthType.NONE:
return {}

def resolve(value: str) -> str:
return _resolve_placeholders(value, template_vars)

auth = server.auth
auth = await resolve_credential_auth(
server.auth,
reseller_id=reseller_id,
merchant_id=merchant_id,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not apply template substitution to resolved credential secrets.

resolve_credential_auth() returns SecretStr credential values and clears credential_id. The subsequent resolve(...) calls therefore treat those secrets as template strings; any credential containing {...} can be rewritten using payload/template variables, causing authentication failures and allowing untrusted data to influence credential material.

Capture whether the original config was credential-backed and return those values verbatim; only interpolate values supplied directly as template configuration.

Based on the resolver contract, credential values are returned in memory as SecretStr fields.

Suggested fix
+    credential_backed = server.auth.credential_id is not None
+
     auth = await resolve_credential_auth(
         server.auth,
         reseller_id=reseller_id,
         merchant_id=merchant_id,
     )

     def resolve(value: str) -> str:
-        return _resolve_placeholders(value, template_vars)
+        return value if credential_backed else _resolve_placeholders(
+            value, template_vars
+        )

Also applies to: 527-531

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/ai/voice/agents/breeze_buddy/mcp/__init__.py` around lines 495 - 512,
Update _build_auth_headers to record whether server.auth was originally
credential-backed before calling resolve_credential_auth. When the resolved auth
contains SecretStr credential fields, use their underlying values verbatim and
do not pass them through resolve; retain template substitution only for values
supplied directly in the original template configuration, including the related
logic around lines 527-531.

Comment on lines +331 to +340
"auth": {"type": "bearer", "credential_id": "configured-credential-id"},
"body": {"booking_id": "{booking_id}", "refund_reason": "{refund_reason}"},
"timeout": 10,
"max_retries": 3
},
"cancel_on_interruption": false
}
```
Auth types: `none`, `bearer` (token), `basic` (username+password),
`api_key` (header name+value).
For bearer, basic, and API-key authentication, use a configured
`credential_id`; the credential value is resolved only while making the request.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Document custom credential resolution here too.

The new guidance covers bearer, basic, and API-key auth but omits custom, even though custom credentials use credential_id plus header_bindings. Without this, template authors may continue embedding custom header secrets in templates.

Based on the PR objectives, custom credentials are also required to remain server-side.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/ai/voice/agents/breeze_buddy/template/generator/prompts.py` around lines
331 - 340, Update the authentication guidance in the prompt template
documentation to include custom authentication alongside bearer, basic, and
API-key authentication. State that custom auth must use a configured
credential_id with header_bindings, and that its credential values are resolved
only when making the request rather than embedded in templates.

Comment on lines +147 to +162
# RBAC check: non-admin users can only access credentials in their scope.
if current_user.role != "admin":
if credential.reseller_id is not None:
if (
credential.reseller_id not in current_user.reseller_ids
and "*" not in current_user.reseller_ids
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied to credential for reseller {credential.reseller_id}",
)
if credential.reseller_id and not _has_scope(
current_user.reseller_ids, credential.reseller_id
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied to credential for reseller {credential.reseller_id}",
)
if credential.merchant_id and not _has_scope(
current_user.merchant_ids, credential.merchant_id
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied to credential for merchant {credential.merchant_id}",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

RBAC-denied responses leak the credential's scope identifiers to unauthorized callers.

Both get_credential_endpoint and update_credential_endpoint fetch the credential before checking scope, then echo credential.reseller_id/credential.merchant_id in the 403 detail when access is denied. An authenticated caller without access to a given reseller/merchant can submit an arbitrary credential_id and learn which reseller/merchant it belongs to purely from the error message.

Consider returning a generic "Access denied" (or 404, to avoid confirming existence) instead of echoing the resource's actual scope values.

🔒 Proposed fix: generic denial message
-        if credential.reseller_id and not _has_scope(
-            current_user.reseller_ids, credential.reseller_id
-        ):
-            raise HTTPException(
-                status_code=status.HTTP_403_FORBIDDEN,
-                detail=f"Access denied to credential for reseller {credential.reseller_id}",
-            )
-        if credential.merchant_id and not _has_scope(
-            current_user.merchant_ids, credential.merchant_id
-        ):
-            raise HTTPException(
-                status_code=status.HTTP_403_FORBIDDEN,
-                detail=f"Access denied to credential for merchant {credential.merchant_id}",
-            )
+        if credential.reseller_id and not _has_scope(
+            current_user.reseller_ids, credential.reseller_id
+        ):
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail="Access denied to this credential",
+            )
+        if credential.merchant_id and not _has_scope(
+            current_user.merchant_ids, credential.merchant_id
+        ):
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail="Access denied to this credential",
+            )

Also applies to: 191-202

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/routers/breeze_buddy/credentials/__init__.py` around lines 147 - 162,
The RBAC denial paths in get_credential_endpoint and update_credential_endpoint
must not expose credential.reseller_id or credential.merchant_id. Replace both
scope-specific 403 detail messages with a generic access-denied message,
preserving the existing scope checks and status code.

@narsimhaReddyJuspay

Copy link
Copy Markdown
Contributor

PR #961feat(security): prevent merchant credentials in templates; resolve at request time in-memory

Verdict: looks good — the PR achieves its security goal; no critical/major introduced. No inline comments.

✅ Verified clean (the goal is met)

  • Credentials no longer stored in templates: every GET/export/snapshot path masks (token/password/api_key_value********** or None); custom_headers is exclude=True, repr=False (never serialized). The resolved config (real secret values) is built as a local model_copy, passed only to the executor, never written back.
  • Per-merchant scope (no IDOR): get_active_credential_by_id_for_scope allows only global / caller-reseller-shared / caller-merchant credentials; REST create/list/get/update enforce reseller+merchant scope; list-without-merchant-id can't enumerate other merchants' creds; values always masked.
  • In-memory resolution correct at every call site (agent, chat, http_handler, hooks, pre_checks, mcp) — passes reseller_id+merchant_id from the template/lead. No global credential cache (mcp/cache caches tool metadata only, never credentials).
  • Secrets stay KMS-encrypted at rest; resolution logs only UUIDs/field-names, never values. Existing inline-token templates still work (validator early-returns when credential_id is None).

🟨 Notes (not blocking)

  • Migration 042 cross-PR collision: the open PR feat: buy phone numbers from telephony providers (Plivo) #897 also adds a 042_… migration. 042 is free vs release (tops at 041), but one of the two PRs must renumber to 043 or CI breaks for the second to land.
  • 🟨 Pre-existing (not introduced here): pre_checks._build_resolution_context loads body/header placeholder credentials via the unscoped get_credential_by_id (no reseller/merchant filter) — the new resolve_credential_auth scope-checks the auth path but this placeholder path doesn't. Low risk (pre_check.credential_id is operator-configured), but for consistency swap it for get_active_credential_by_id_for_scope.
  • 🟨 The legacy get_credentials_as_template_vars path no longer returns merchant-scoped credentials (silent behavior change) — confirm no live templates depend on merchant-scoped {credential} placeholders.

@MonishJuspay
MonishJuspay force-pushed the prevent-loading-credential-into-templates branch from e80efaf to 3e07751 Compare July 30, 2026 06:54
@MonishJuspay
MonishJuspay force-pushed the prevent-loading-credential-into-templates branch 2 times, most recently from ad62a3e to 29b1f20 Compare July 30, 2026 08:16
credential_type = credential.credential_type
value = credential.value
if auth_config.type == HttpAuthType.BEARER:
if credential_type != CredentialType.BEARER_TOKEN or not value.get("token"):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a safety check if the request is for Bearer token but the credential type stored in the credential table is not matching then raise the error

if auth_config.type == HttpAuthType.BEARER:
if credential_type != CredentialType.BEARER_TOKEN or not value.get("token"):
raise ValueError("Bearer auth requires a bearer_token credential")
updates = {"token": SecretStr(value["token"])}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SecretStr masking the token value now , so that anywhere in the logs the actual token wont be logged, the actual token from the SecretStr is extracted just before making the http request

"username": value["username"],
"password": SecretStr(value["password"]),
}
elif auth_config.type == HttpAuthType.CUSTOM:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CUSTOM type supports multiple credentials in the value json.

raise ValueError("Custom auth requires a custom credential")

custom_headers = {}
for header_name, credential_field in auth_config.header_bindings.items():

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the templateJSON.In a custom-auth HTTP function, the template contains something like:
"auth": {
"type": "custom",
"credential_id": "abc-123",
"header_bindings": {
"X-Client-Secret": "client_secret",
"X-Shop-Id": "shop_id"
}
}
when the header_bindings is loaded from the template, pydantic creates the httpAuthConfig object which has header_bindings made as python dictionary.
like:
"auth_config.header_bindings == {
"X-Client-Secret": "client_secret",
"X-Shop-Id": "shop_id",
}"

"Custom auth credential is missing a non-empty field "
f"'{credential_field}' for header '{header_name}'"
)
custom_headers[header_name] = SecretStr(field_value)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since the header_bindings has the
"auth_config.header_bindings == {
"X-Client-Secret": "client_secret",
"X-Shop-Id": "shop_id",
}"
and now the value from the DB is loaded and replaced with client_secret,shop_id as the key



def _build_server_params(
async def _build_server_params(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we have to make a db call now to get the credentials so await, so async in this method

"bearer": HttpAuthType.BEARER,
"basic": HttpAuthType.BASIC,
"api_key": HttpAuthType.API_KEY,
"custom": HttpAuthType.CUSTOM,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to the type_mapping

# Convert auth dict to HttpAuthConfig (if present)
converted_auth = _convert_auth_dict_to_config(http_cfg.auth)
# Convert raw pre-check JSON, then resolve any credential reference.
auth_config = _convert_auth_dict_to_config(http_cfg.auth)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

returns the object: HttpAuthConfig

# Convert raw pre-check JSON, then resolve any credential reference.
auth_config = _convert_auth_dict_to_config(http_cfg.auth)
reseller_id, merchant_id = resolve_credential_scope(template, lead)
resolved_auth = await resolve_credential_auth(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getting credentials for the pre-check

http_cfg.query_params
)

http_config = HttpRequestConfig(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating the python object that represents the full http request,
auth: has the values like:
"resolved_auth = {
type: "bearer",
credential_id: None,
token: SecretStr("real-token")
}"

But auth is not itself sent as JSON in the request body.

Later, HttpRequestExecutor reads this object and creates the real HTTP headers

@MonishJuspay
MonishJuspay force-pushed the prevent-loading-credential-into-templates branch 2 times, most recently from 1e70df7 to b08323e Compare July 30, 2026 13:48
credential_keys = set(credential_vars)
for override in overrides:
if override:
credential_keys.difference_update(override)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if in case the credential is being overrided by the another key in the lead_payload or template_secrets, remove that from checking, means that template is not just dependent on the keys coming from the credential table

if not credential_keys:
return

template_content = json.dumps(template.flow or {}, default=str)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flattening the python dictionary to the normal python string

from app.core.logger import logger


def log_legacy_credential_placeholder_usage(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used to check the template that is having the credential string usage in the template


template_content = json.dumps(template.flow or {}, default=str)
if template.configurations:
template_content += template.configurations.model_dump_json()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since the mcps are configured in the template so we have to check that as well for the key words

)

template_vars = {}
credential_vars = {}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we have to pass to the function : log_legacy_credential_placeholder_usage
for the checking if the credentials is being used by the template and thrown an alert

"""Get AIO Http Session instance"""
return self.bot.aiohttp_session

@property

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How it helps
HTTP function
-> context.credential_cache
-> cache is empty
-> fetch credential abc-123 from DB
-> save it in cache
-> make HTTP request

Later, a hook uses the same credential ID:

HTTP hook
-> context.credential_cache
-> finds abc-123 already there
-> no DB fetch
-> make HTTP request

return self.bot.aiohttp_session

@property
def credential_cache(self) -> Dict[str, Any]:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we having the credential_cache in the templatecontext object ?
This templateContext is created whenever a functionHandler is invoked so that the function can data related to that call. like lead,httpSession, credentialCache now..

This actually creates the credential_cache in the main agent object, so across the call, even for the different function handler, ( everytime a new templateContext object is created ) but the underlyting credential_cache remains same because the field is there in the agent object.

Why didnt create directly in the Agent class directly??
this is because of the lazy creation,
create the cache only when some HTTP handler actually needs a credential

@MonishJuspay
MonishJuspay force-pushed the prevent-loading-credential-into-templates branch from b08323e to aff5a11 Compare July 31, 2026 07:07
)
from app.ai.voice.agents.breeze_buddy.template.utils import render_messages_with_vars
from app.ai.voice.agents.breeze_buddy.template.utils import (
log_legacy_credential_placeholder_usage,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

def log_deprecated_fields

@MonishJuspay MonishJuspay Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here log_legacy_credential_placeholder_usage function checks whether a template is still using the old unsafe credential-loading method, so that in the template load path, it takes the template object, credential_vars and convert template flow,configurations(because it has mcp.., mcp uses token) into the string checks if the credentials vars is present or not

we cannot reuse the existing log_deprecated_fields() as it is, because it checks "Does this known field exist in this Python object?handles old JSON field names".
Our function checks: “Do any credential names loaded from the DB appear as {placeholders} inside this template?”

@MonishJuspay
MonishJuspay force-pushed the prevent-loading-credential-into-templates branch from aff5a11 to 286ddaf Compare July 31, 2026 10:50
@murdore

murdore commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Resolved credential secrets get re-run through replace_placeholders in HttpRequestExecutor._resolve_auth_config — a second, unflagged instance of the "template substitution on resolved secrets" issue.

This is the same bug class already flagged on mcp/__init__.py (_build_auth_headers's resolve()), but it's a separate, unaddressed occurrence in the shared HTTP path used by regular HTTP functions, hooks, and pre-checks — fixing the MCP spot alone (per the suggested diff there) does not touch this one.

Flow, all new in this PR:

  • http_handler.py:132-138 resolves the credential and copies the real secret into request_config.auth, then passes it to the executor:
resolved_auth = await resolve_credential_auth(
    config.http_request.auth,
    reseller_id=reseller_id,
    merchant_id=merchant_id,
    credential_cache=context.credential_cache,
)
request_config = config.http_request.model_copy(update={"auth": resolved_auth})
...
result = await executor.execute(
    config=request_config,

(hooks.py:403-414 and pre_checks.py:320-328 do the equivalent for hooks and pre-checks.)

  • Inside execute(), http_requester.py:112 calls self._resolve_auth_config(config.auth, resolved_fields) on that same already-resolved auth. For the new custom auth type this is 100% new code and always operates on real credential-store secrets (since custom_headerstypes.py's new field — is populated exclusively by resolve_credential_auth, never from template config, and validate_credential_reference requires credential_id for type == CUSTOM):
resolved_custom_headers = {
    header_name: SecretStr(
        replace_placeholders(value.get_secret_value(), resolved_fields)
    )
    for header_name, value in auth_config.custom_headers.items()
}

The same function also re-applies replace_placeholders to token/password/api_key_value (pre-existing lines), which is now reachable with real DB secret values too, since resolve_credential_auth populates those same fields for bearer/basic/api_key and always clears credential_id, so _resolve_auth_config can't tell "resolved secret" from "inline template value" apart.

Net effect: if a stored credential value happens to contain a {word} substring that matches a key in resolved_fields (LLM/payload-derived, i.e. attacker/user-influenceable), that portion of the secret is silently substituted with that untrusted value before being sent as an auth header/credential — corrupting the credential and letting untrusted data influence the outgoing auth material, for the primary HTTP-function/hook/pre-check credential path (not just MCP).

Suggest tracking whether auth_config.credential_id was set prior to resolve_credential_auth() and, if so, skip replace_placeholders entirely for the resolved fields (same fix shape as suggested for mcp/__init__.py), rather than only patching the MCP call site.

password: Optional[SecretStr] = None # For basic auth
api_key_name: Optional[str] = None # Header name for API key
api_key_value: Optional[SecretStr] = None # API key value
header_bindings: Dict[str, str] = Field(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why header_bindings ?

This is stored in the template. It says:

Which field from this custom credential should go into which HTTP header?

Example template configuration:

{
"type": "custom",
"credential_id": "abc-uuid",
"header_bindings": {
"X-Client-ID": "client_id",
"X-Client-Secret": "client_secret"
}
}

custom_headers

This is created only at runtime after the credential is fetched from DB.

Credential DB value:

{
"client_id": "real-id",
"client_secret": "real-secret"
}

Using header_bindings, our code creates:

custom_headers = {
"X-Client-ID": SecretStr("real-id"),
"X-Client-Secret": SecretStr("real-secret"),
}

Then the HTTP executor sends those as real headers.

@@ -6,10 +6,9 @@
same template + same resolved URL share one discovery for the TTL window.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just comment changes for developer ref

… them at request time

  - add global, reseller-shared, and merchant credential scopes
  - resolve bearer, API key, basic, and custom credentials at request time
  - support credential IDs in HTTP functions, hooks, pre-checks, and MCP
  - keep credential-ID secrets in memory only during outbound requests
@MonishJuspay
MonishJuspay force-pushed the prevent-loading-credential-into-templates branch from 286ddaf to f4bff35 Compare August 6, 2026 03:30
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied to credential for reseller {credential.reseller_id}",
)
if credential.merchant_id and not _has_scope(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

support for checking the merchantId, if a merchantId is passed but the current user dont have the access to this merchant credentials then return the FORBIDDEN error

return await list_credentials_handler(reseller_id, merchant_id, current_user)


@router.get("/credentials/{credential_id}", response_model=Credential)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This get endpoint wont return the plain credentials as it is. it returns the masked values, seems to be used by our dashboard

# values here so legacy MCP auth placeholders can be detected.
template_content += template.configurations.model_dump_json(
context={"reveal_secrets": True}
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SecretStr hides its content when converted to JSON:

token = SecretStr("{wismo_secret}")

model_dump_json()

would produce:

{
"token": "**********"
}

Then our detector cannot see:

{wismo_secret}

So it would miss the old unsafe use.

With:

model_dump_json(
context={"reveal_secrets": True}
)

it temporarily produces:

{
"token": "{wismo_secret}"
}

Now the detector can find it and log

http_request.auth,
reseller_id=reseller_id,
merchant_id=merchant_id,
credential_cache=context.credential_cache,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

credential cache is the object in the agent class to store the creds fetched in that agent conversation, so that any function which is going to reuse the credentials can reuse that without fetching from the DB.
preventing the DB call.

Flow:

  1. First HTTP global function or hook uses a credential_id.
  2. It fetches that credential from DB and saves it in the agent’s in-memory dictionary.
  3. Another HTTP function/hook in the same call using the same credential_id reuses it from memory.
  4. When that call’s agent ends, the cache disappears.

It is not sent to the LLM and is not stored in template_vars.

Currently, HTTP global functions and hooks share this cache. Pre-checks use their own cache for that pre-check run, and MCP does not yet pass this shared cache.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants