91 lines
3.0 KiB
Python
91 lines
3.0 KiB
Python
"""Resolve Jira credentials for an identity without leaking them.
|
|
|
|
Credentials come from the existing ``SecretStore`` interface so tests can
|
|
inject a fake and production uses the OS keyring. The resolver never caches
|
|
credentials beyond the call scope and never includes them in error messages,
|
|
logs, or MCP payloads.
|
|
|
|
Key naming convention:
|
|
- Per-project: ``jira:<cowork_project_id>``
|
|
- Global fallback: ``jira:default``
|
|
|
|
The email is stored alongside the token under the same key as a JSON pair
|
|
``{"email": "...", "api_token": "..."}`` so one secret-store entry carries
|
|
both values atomically.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
from ...infrastructure.secrets.secret_store import SecretStore
|
|
from ...mcp_servers.project_context.foundation import IdentityContext, ProviderError
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class JiraCredentials:
|
|
"""Immutable credential pair resolved for one call."""
|
|
email: str
|
|
api_token: str
|
|
|
|
|
|
def _secret_key(cowork_project_id: str) -> str:
|
|
return f"jira:{cowork_project_id}"
|
|
|
|
|
|
_GLOBAL_KEY = "jira:default"
|
|
|
|
|
|
class JiraCredentialResolver:
|
|
"""Resolve ``(email, api_token)`` from the secret store for one identity.
|
|
|
|
Raises ``UNAVAILABLE`` when no credentials are configured — never returns
|
|
empty strings that would cause a silent 401 at the HTTP layer.
|
|
"""
|
|
|
|
def __init__(self, store: SecretStore) -> None:
|
|
self._store = store
|
|
|
|
def resolve(self, identity: IdentityContext) -> JiraCredentials:
|
|
"""Look up credentials by project-specific key, then global fallback.
|
|
|
|
Raises:
|
|
ProviderError: When neither key exists or the stored value is
|
|
malformed.
|
|
"""
|
|
raw = self._store.get(_secret_key(identity.project))
|
|
if not raw:
|
|
raw = self._store.get(_GLOBAL_KEY)
|
|
if not raw:
|
|
raise ProviderError(
|
|
"UNAVAILABLE",
|
|
"Jira credentials are not configured for this project.",
|
|
retryable=False,
|
|
)
|
|
try:
|
|
parsed = json.loads(raw)
|
|
except (json.JSONDecodeError, TypeError):
|
|
raise ProviderError(
|
|
"UNAVAILABLE",
|
|
"Stored Jira credentials are malformed; re-enter them in Connectors.",
|
|
retryable=False,
|
|
)
|
|
if not isinstance(parsed, dict):
|
|
raise ProviderError(
|
|
"UNAVAILABLE",
|
|
"Stored Jira credentials are malformed; re-enter them in Connectors.",
|
|
retryable=False,
|
|
)
|
|
email = str(parsed.get("email", "")).strip()
|
|
api_token = str(parsed.get("api_token", "")).strip()
|
|
if not email or not api_token:
|
|
raise ProviderError(
|
|
"UNAVAILABLE",
|
|
"Stored Jira credentials are incomplete; re-enter them in Connectors.",
|
|
retryable=False,
|
|
)
|
|
return JiraCredentials(email=email, api_token=api_token)
|
|
|
|
|
|
__all__ = ["JiraCredentialResolver", "JiraCredentials"] |