dmslanes/lanes

Lanes - team task boards with roles, history and an MCP endpoint for Claude and ChatGPT

A remix template · 0 remixes · Remix dmslanes/lanes

app/infrastructure/identity.py

"""Who is calling, from the headers the Dodil Ignite gateway injects.

With a user pool attached, the gateway signs the person in (browser or an AI
client over OAuth), strips any inbound copy of these headers and adds:

    X-Dodil-User   {"sub", "email", "connection", "app_roles"}   (JSON)

So the app ships no login code: it reads the email and lets the use cases decide
what that person may see and do. One parser for both doors (web and MCP).

Local development without the gateway: DEV_ALLOW_ANON=1 makes every request
DEV_USER_EMAIL, and an `X-Dev-User: someone@x.io` header switches person (to try
the roles). Neither works with DEV_ALLOW_ANON unset, which is the deployed default.
"""
from __future__ import annotations

import json
import os

USER_HEADER = "x-dodil-user"
DEV_HEADER = "x-dev-user"


class Unauthenticated(Exception):
    pass


def dev_mode() -> bool:
    return os.environ.get("DEV_ALLOW_ANON", "").strip().lower() in {"1", "true", "yes", "on"}


def email_from_headers(headers) -> str:
    h = {str(k).lower(): v for k, v in (headers or {}).items()}
    raw = h.get(USER_HEADER)
    if raw:
        try:
            claims = json.loads(raw)
        except ValueError:
            raise Unauthenticated("malformed identity header") from None
        email = (claims.get("email") or "").strip().lower()
        if not email:
            raise Unauthenticated("the signed-in identity carries no email")
        return email
    if dev_mode():
        return (h.get(DEV_HEADER) or os.environ.get("DEV_USER_EMAIL", "dev@localhost.test")).strip().lower()
    raise Unauthenticated("sign in first")