Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,17 @@ Two ways to authenticate:
- **Session cookie (user context).** Log into Mantis, open devtools → Network, copy the `cookie`
header from any authenticated request, and pass it as `cookie`. Required keys include
`next-auth.session-token` and `sessionid`.
- **Internal-service (backend-to-backend).** Set `config.internal_user_id` (or `MANTIS_INTERNAL_USER_ID`);
the SDK then sends `X-Internal-Service: true` + `X-Internal-User-Id` instead of a cookie.
- **Internal-service (backend-to-backend).** Set `config.internal_user_id` and
`config.internal_service_secret` (or `MANTIS_INTERNAL_USER_ID` and `MANTIS_INTERNAL_SERVICE_SECRET`);
the SDK then sends authenticated internal-service headers. A cookie is not required.

If both methods are configured, the SDK sends both sets of headers and the backend uses the
authenticated internal-service identity. If a cookie is present but the internal user ID has no
service secret, the SDK sends only the cookie; without a cookie, incomplete internal configuration
fails before making a request.

`MantisClient.from_env()` reads `MANTIS_HOST`, `MANTIS_BACKEND_HOST`, `MANTIS_COOKIE`,
`MANTIS_BASE_URL`, and `MANTIS_INTERNAL_USER_ID`.
`MANTIS_BASE_URL`, `MANTIS_INTERNAL_USER_ID`, and `MANTIS_INTERNAL_SERVICE_SECRET`.

## Quick start

Expand Down
15 changes: 12 additions & 3 deletions mantis_sdk/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Any

from .config import ConfigurationManager
from .exceptions import ConfigurationError
from .transport import Transport

logger = logging.getLogger("mantis_sdk")
Expand All @@ -17,7 +18,7 @@ class HttpClient:

auth resolution (either or both may apply):
- cookie: a browser session cookie string (canonical for user auth).
- config.internal_user_id: enables X-Internal-Service backend-to-backend auth.
- config.internal_user_id + internal_service_secret: backend-to-backend auth.
"""

def __init__(
Expand Down Expand Up @@ -55,8 +56,16 @@ def auth_headers(self) -> dict[str, str]:
if self.cookie:
headers["cookie"] = self.cookie
if self.config.internal_user_id:
headers["X-Internal-Service"] = "true"
headers["X-Internal-User-Id"] = str(self.config.internal_user_id)
if not self.config.internal_service_secret:
# preserve cookie auth when only the ambient internal id is set.
if not self.cookie:
raise ConfigurationError(
"internal_service_secret is required when internal_user_id is set"
)
else:
headers["X-Internal-Service"] = "true"
headers["X-Internal-Secret"] = self.config.internal_service_secret
headers["X-Internal-User-Id"] = str(self.config.internal_user_id)
return headers

def request(
Expand Down
7 changes: 4 additions & 3 deletions mantis_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ def __init__(

if cookie is None and not self.config.internal_user_id:
raise ConfigurationError(
"no auth provided: pass a session cookie, or set internal_user_id on the config "
"(MANTIS_INTERNAL_USER_ID) for backend-to-backend auth."
"no auth provided: pass a session cookie, or set internal_user_id "
"(MANTIS_INTERNAL_USER_ID) and internal_service_secret "
"(MANTIS_INTERNAL_SERVICE_SECRET) for backend-to-backend auth."
)

self.http = HttpClient(base_url=base_url, cookie=cookie, config=self.config)
Expand All @@ -81,7 +82,7 @@ def __init__(
@classmethod
def from_env(cls, base_url: str | None = None) -> MantisClient:
"""build a client from MANTIS_* environment variables.
uses MANTIS_COOKIE for auth (or MANTIS_INTERNAL_USER_ID via the config)."""
uses MANTIS_COOKIE or the internal user id and service secret via the config."""
import os

config = ConfigurationManager()
Expand Down
5 changes: 2 additions & 3 deletions mantis_sdk/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,9 @@ def __init__(self) -> None:
# browser-side flag the sdk waits on before a space is considered ready.
self.wait_for = os.getenv("MANTIS_WAIT_FOR", "isLoaded")

# internal-service auth: set these to authenticate backend-to-backend without
# a session cookie. when internal_user_id is set the transport sends
# X-Internal-Service: true and X-Internal-User-Id headers.
# internal-service auth requires both identity and a provisioned credential.
self.internal_user_id: str | None = os.getenv("MANTIS_INTERNAL_USER_ID")
self.internal_service_secret: str | None = os.getenv("MANTIS_INTERNAL_SERVICE_SECRET")

# the agent runtime (client.agents) keys identity + capability gating on email, not
# user_id. set this (or MANTIS_USER_EMAIL) so agents.session() can default user_email.
Expand Down
8 changes: 7 additions & 1 deletion mantis_sdk/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@ def _redact(headers: dict[str, str]) -> dict[str, str]:
"""copy headers with auth-bearing values masked, for safe logging."""
redacted = dict(headers)
for key in list(redacted):
if key.lower() in {"cookie", "x-internal-user-id", "x-notebook-auth", "x-csrftoken"}:
if key.lower() in {
"cookie",
"x-internal-secret",
"x-internal-user-id",
"x-notebook-auth",
"x-csrftoken",
}:
redacted[key] = "<redacted>"
return redacted

Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def transport() -> RecordingTransport:
def client(transport: RecordingTransport) -> MantisClient:
config = ConfigurationManager()
config.internal_user_id = "11111111-1111-1111-1111-111111111111"
config.internal_service_secret = "test-internal-service-secret"
c = MantisClient("/api/proxy/", cookie=None, config=config)
# swap in the recording transport so nothing touches the network.
c.http.transport = transport
Expand Down
52 changes: 49 additions & 3 deletions tests/test_http_client.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
"""url building, trailing-slash handling, and auth header construction."""
from mantis_sdk import ConfigurationManager
import pytest

from mantis_sdk import ConfigurationError, ConfigurationManager, MantisClient
from mantis_sdk._http import HttpClient


def _http(base, cookie=None, internal=None):
def _http(base, cookie=None, internal=None, secret=None):
cfg = ConfigurationManager()
cfg.host = "http://localhost:3000"
cfg.internal_user_id = internal
cfg.internal_service_secret = secret
return HttpClient(base, cookie, cfg)


Expand All @@ -31,7 +34,50 @@ def test_cookie_auth_header():


def test_internal_service_auth_header():
h = _http("/api/proxy/", internal="user-123")
h = _http("/api/proxy/", internal="user-123", secret="service-secret")
headers = h.auth_headers()
assert headers["X-Internal-Service"] == "true"
assert headers["X-Internal-Secret"] == "service-secret"
assert headers["X-Internal-User-Id"] == "user-123"


def test_internal_service_auth_requires_secret():
h = _http("/api/proxy/", internal="user-123")

with pytest.raises(ConfigurationError, match="internal_service_secret is required"):
h.auth_headers()


def test_cookie_auth_ignores_incomplete_ambient_internal_auth():
h = _http("/api/proxy/", cookie="sessionid=abc", internal="user-123")

assert h.auth_headers() == {"cookie": "sessionid=abc"}


def test_complete_internal_auth_takes_priority_when_cookie_is_also_present():
h = _http(
"/api/proxy/", cookie="sessionid=abc", internal="user-123", secret="service-secret"
)

assert h.auth_headers() == {
"cookie": "sessionid=abc",
"X-Internal-Service": "true",
"X-Internal-Secret": "service-secret",
"X-Internal-User-Id": "user-123",
}


def test_missing_auth_error_describes_full_internal_contract():
config = ConfigurationManager()
config.internal_user_id = None

with pytest.raises(ConfigurationError, match="MANTIS_INTERNAL_SERVICE_SECRET"):
MantisClient("/api/proxy/", config=config)


def test_internal_service_secret_loads_from_environment(monkeypatch):
monkeypatch.setenv("MANTIS_INTERNAL_SERVICE_SECRET", "service-secret")

config = ConfigurationManager()

assert config.internal_service_secret == "service-secret"
26 changes: 24 additions & 2 deletions tests/test_transport.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""transport maps http status codes to typed exceptions and retries idempotent calls."""
import logging
from unittest.mock import MagicMock

import pytest
Expand Down Expand Up @@ -51,8 +52,29 @@ def test_connection_error_wrapped():
t.request("GET", "http://x/y")


def test_redact_masks_cookie():
redacted = _redact({"cookie": "secret", "X-Internal-User-Id": "u", "Accept": "json"})
def test_redact_masks_auth_headers():
redacted = _redact({
"cookie": "secret",
"X-Internal-Secret": "service-secret",
"X-Internal-User-Id": "u",
"Accept": "json",
})
assert redacted["cookie"] == "<redacted>"
assert redacted["X-Internal-Secret"] == "<redacted>"
assert redacted["X-Internal-User-Id"] == "<redacted>"
assert redacted["Accept"] == "json"


def test_debug_log_redacts_internal_service_secret(caplog):
transport = Transport()
transport.session.request = MagicMock(return_value=_response(200, {"ok": True}))
caplog.set_level(logging.DEBUG, logger="mantis_sdk")

transport.request(
"GET",
"http://x/y",
headers={"X-Internal-Secret": "service-secret"},
)

assert "service-secret" not in caplog.text
assert "<redacted>" in caplog.text
Loading