Skip to content
Open
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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ 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 the authenticated internal-service headers instead of a cookie.

`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
8 changes: 7 additions & 1 deletion 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,7 +56,12 @@ def auth_headers(self) -> dict[str, str]:
if self.cookie:
headers["cookie"] = self.cookie
if self.config.internal_user_id:
if not self.config.internal_service_secret:
raise ConfigurationError(
"internal_service_secret is required when internal_user_id is set"
)
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

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
25 changes: 22 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
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,23 @@ 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_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