Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ POSTGRES_USER=postgres
POSTGRES_PASSWORD=change-me-local-only
DATABASE_URL=postgresql+asyncpg://postgres:change-me-local-only@localhost:5432/ai_email
DEBUG=false
AUTH_MODE=hybrid
TRUST_DEV_HEADERS=false
LEGACY_EMAIL_OWNER_USER_ID=
ENCRYPTION_KEY=

# AI features. Leave blank to disable LLM/embedding-backed flows locally.
Expand Down
100 changes: 88 additions & 12 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ The backend owns persistence, threading, search, AI summaries, and outbound
send orchestration. The frontend consumes the backend contracts and renders
inbox, detail, thread history, reply composer, and network graph surfaces.

The current frontend shell is organized around three operator-facing work
surfaces: inbox triage, detail/conversation review, and a context/judgment/
execution rail. Desktop now exposes board-style summary cards above the main
workspace, while mobile uses a five-item bottom navigation (`받은 메일`, `맥락
종합`, `판단 포인트`, `실행 항목`, `설정`) and swipe gestures to push email
threads into a persisted per-user `ExecutionItem` queue. The frontend still
keeps a user-scoped local cache for rendering/subscription, but queue creation
and completion now flow through `/api/execution-items`.

## Threading boundary

`backend/services/threading_service.py` is the canonical domain service for
Expand All @@ -23,10 +32,42 @@ documented in `docs/threading-contract.md`.

## Data and tenancy boundary

The current `emails` table does not have an owner/mailbox key. Email and
search behavior should therefore be treated as single-user local-development
behavior. Multi-user production safety requires a schema migration that adds
mailbox ownership and applies that filter to every email/search query.
The current `emails` table now has a minimal owner field (`Email.user_id`) and
the inbox/detail/thread read paths already filter on it. That closes the most
obvious single-user leakage, but it is still only a bridge. Multi-user
production safety still requires a real `Mailbox` aggregate with provider/source
identity plus mailbox/member/org scoped filtering for email, search, network,
attachments, and synthetic routing.

`MailboxAccount` is now the first mailbox-configuration aggregate: multiple
accounts per user, one default reply account, and explicit SMTP/IMAP credentials
separate from the legacy `TenantConfig` singleton. Compose/send flows can now
target a linked mailbox account, but stored emails still need a future
`mailbox_account_id` rollout across every real sync/import path before reply
routing becomes fully automatic for all stored messages. Fixture/ZIP imports now
attempt that routing, and the IMAP worker now enumerates active `MailboxAccount`
rows instead of the legacy singleton config, but real persisted IMAP/POP/OAuth
sync paths are still pending. The inbox
API and UI are now mailbox-aware as well: list items carry `mailbox_account_id`,
the inbox can filter by a linked mailbox account, and rows show their source
account label. Search now follows that same scope and keeps mailbox provenance in
its result items so the UI does not lose account context when a user searches.
During the bridge period, mailbox-filtered thread and search paths include
legacy `mailbox_account_id IS NULL` rows for the authenticated owner so restored
thread context is not lost before the mailbox migration is complete; the inbox UI
labels those bridge rows as `이전 복원 메일` when a mailbox filter is active.
Bootstrap now fails closed if ownerless legacy email rows remain and
`LEGACY_EMAIL_OWNER_USER_ID` is not set, preventing a silent deployment where
existing inbox/search/network data disappears behind the new owner filters.

`MailboxAccount` also now carries POP3 credentials and the background worker
enumerates active POP3 accounts from that aggregate with bounded socket timeouts,
and the app lifecycle now starts/stops that worker beside IMAP. Mailbox-account
create/update also normalizes mailbox strings, blocks internal-network mail
server hosts and non-mail ports, enforces server/port pair invariants, forces
default-reply accounts active, and returns stable 409/503 errors for duplicate
account or missing encryption-key failures. This is still only a
connection/scheduling foundation, not a persisted message ingestion path.

## Local deployment boundary

Expand Down Expand Up @@ -72,12 +113,47 @@ SMTP/IMAP providers as documented in
the current repo and physical replication/WAL restore remain future work per
`docs/operations/postgresql-physical-replication.md`.

Authentication still uses trusted request headers in local/dev, but the backend
now normalizes them into an auth context with `platform_admin`,
`organization_admin`, `group_admin`, and `member` roles plus optional
organization/group scope. This preserves the current `X-User-Id` development
flow while preparing the backend for future Keycloak/Casdoor claims and
organization-scoped resources that should not assume one workspace per user; see
`docs/operations/auth-key-management.md`. The current Kubernetes ingress assumes
NGINX, while Traefik is only an evaluated option in
Authentication now supports three backend modes: `header`, `hybrid`, and
`oidc`, with `hybrid` as the fail-closed default. Trusted header auth only runs
when `DEBUG=true` or `TRUST_DEV_HEADERS=true`; the raw backend default and the
repo-local dev/live-E2E Compose defaults remain fail-closed. Local developer or
live-E2E trusted-header runs must opt in explicitly with `AUTH_MODE=header` and
`TRUST_DEV_HEADERS=true`, and the provided Compose port bindings stay loopback
only (`127.0.0.1`) so that escape hatch is not advertised as a production
posture.

In `oidc`/`hybrid`, bearer tokens are normalized into the shared
`AuthContext` with `platform_admin`, `organization_admin`, `group_admin`, and
`member` roles plus optional organization/group scope. The backend currently
accepts HS256 shared-secret tokens and RS256 bearer tokens validated against a
configured JWKS URL, which is enough for staged Keycloak/Casdoor integration.

The frontend only exposes the dev identity shim on loopback hosts (`localhost`,
`127.0.0.1`) when no bearer token is present and `/api/runtime-config` confirms
trusted-header auth is enabled; local storage alone cannot mint trusted headers.
Admin settings tabs are claim-gated in the UI before the backend performs its
own authorization checks. Runner tokens are organization-scoped, while LLM
provider access is now org-scoped through `organization_id`. Shared prompt
templates also require organization scope and are only listed inside the same
organization; user-owned private prompts remain visible to their creator. Legacy
provider rows require an explicit bootstrap migration mapping
(`LEGACY_LLM_PROVIDER_ORGANIZATION_ID`) before they reappear in the new org
scoped APIs. See `docs/operations/auth-key-management.md`. The current
Kubernetes ingress assumes NGINX, while Traefik is only an evaluated option in
`docs/operations/traefik-evaluation.md`.

`ExecutionItem` is now a persisted personal queue fed from email swipe/actions.
It is intentionally self-owned (`user_id`) and currently uses a temporary
mailbox ownership check through `Email.user_id`, and list/detail/thread reads in
`/api/emails` now use that same owner field. This is the first real object-scope
boundary, but it is still only a per-user ownership column, not yet a full
`Mailbox` aggregate with account/provider/source provenance.

Execution items now also preserve source mailbox context and an excerpt from the
source email (`source_mailbox_account_id`, `source_snippet`) so the action board
can keep account provenance visible after the email leaves the inbox view.

Calendar sync is authenticated but intentionally has no body-token trust path:
`/api/calendar/sync` ignores any submitted `user_token` and returns 503 until a
server-side per-user calendar credential store is implemented. That keeps the UI
action honest without turning app identity claims into Google OAuth credentials.
173 changes: 163 additions & 10 deletions backend/api/auth.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import asyncio
from dataclasses import dataclass
from functools import lru_cache
from typing import Literal, cast, get_args

import jwt
from fastapi import Depends, Header, HTTPException
from pydantic import SecretStr

from core.config import settings


RoleName = Literal["platform_admin", "organization_admin", "group_admin", "member"]
SCOPED_ROLES: set[str] = set(get_args(RoleName))

Expand Down Expand Up @@ -43,7 +46,130 @@ def _derive_role(user_id: str, requested_role: str | None) -> RoleName:
def _parse_group_ids(group_ids_header: str | None) -> tuple[str, ...]:
if not group_ids_header:
return ()
return tuple(group_id.strip() for group_id in group_ids_header.split(",") if group_id.strip())
return tuple(
group_id.strip() for group_id in group_ids_header.split(",") if group_id.strip()
)


def _extract_role_from_claims(user_id: str, claims: dict) -> RoleName:
requested_role = claims.get("naruon_role") or claims.get("role")
if isinstance(requested_role, str) and requested_role in SCOPED_ROLES:
return cast(RoleName, requested_role)

roles = claims.get("roles")
if isinstance(roles, list):
for candidate in (
"platform_admin",
"organization_admin",
"group_admin",
"member",
):
if candidate in roles:
return cast(RoleName, candidate)

return "member"


def _build_context_from_claims(claims: dict) -> AuthContext:
user_id = claims.get("sub")
if not isinstance(user_id, str) or not user_id.strip():
raise HTTPException(status_code=401, detail="Authentication required")

organization_id = claims.get("organization_id") or claims.get("org_id")
if organization_id is not None and not isinstance(organization_id, str):
organization_id = None

raw_groups = claims.get("groups")
group_ids: tuple[str, ...]
if isinstance(raw_groups, list):
group_ids = tuple(
str(group).strip() for group in raw_groups if str(group).strip()
)
else:
group_ids = ()

role = _extract_role_from_claims(user_id, claims)
workspace_id = _derive_workspace_id(user_id, organization_id)
return AuthContext(
user_id=user_id,
role=role,
organization_id=organization_id,
group_ids=group_ids,
workspace_id=workspace_id,
)


@lru_cache(maxsize=8)
def _get_jwk_client(jwks_url: str) -> jwt.PyJWKClient:
return jwt.PyJWKClient(jwks_url)


def _require_oidc_claim_expectations() -> tuple[str, str]:
issuer = settings.OIDC_ISSUER.strip() if settings.OIDC_ISSUER else None
audience = settings.OIDC_AUDIENCE.strip() if settings.OIDC_AUDIENCE else None
if not issuer or not audience:
raise HTTPException(
status_code=503, detail="OIDC issuer and audience are not configured"
)
return issuer, audience


def _get_oidc_shared_secret() -> str | None:
secret = settings.OIDC_SHARED_SECRET
if isinstance(secret, SecretStr):
return secret.get_secret_value()
return secret


async def _decode_bearer_token(authorization: str) -> dict:
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Invalid bearer token")

token = authorization.split(" ", 1)[1].strip()
if not token:
raise HTTPException(status_code=401, detail="Invalid bearer token")

issuer, audience = _require_oidc_claim_expectations()
options = {"require": ["sub", "iss", "aud", "exp"]}
try:
header = jwt.get_unverified_header(token)
algorithm = header.get("alg")
if algorithm == "RS256":
jwks_url = settings.OIDC_JWKS_URL
if not jwks_url:
raise HTTPException(
status_code=503, detail="OIDC JWKS URL is not configured"
)
signing_key = await asyncio.to_thread(
_get_jwk_client(jwks_url).get_signing_key_from_jwt, token
)
return jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=audience,
issuer=issuer,
options=options,
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.

secret = _get_oidc_shared_secret()
if not secret:
raise HTTPException(
status_code=503, detail="OIDC shared secret is not configured"
)

return jwt.decode(
token,
secret,
algorithms=["HS256"],
audience=audience,
issuer=issuer,
options=options,
)
except HTTPException:
raise
except (jwt.PyJWTError, jwt.PyJWKClientError) as exc:
raise HTTPException(status_code=401, detail="Invalid bearer token") from exc


def _derive_workspace_id(
Expand All @@ -65,15 +191,37 @@ def ensure_organization_access(auth_context: AuthContext, organization_id: str)
if auth_context.role == "platform_admin":
return
if auth_context.organization_id != organization_id:
raise HTTPException(status_code=403, detail="Resource belongs to a different organization")
raise HTTPException(
status_code=403, detail="Resource belongs to a different organization"
)


async def get_auth_context(
authorization: str | None = Header(None, alias="Authorization"),
x_user_id: str | None = Header(None, alias="X-User-Id"),
x_user_role: str | None = Header(None, alias="X-User-Role"),
x_organization_id: str | None = Header(None, alias="X-Organization-Id"),
x_group_ids: str | None = Header(None, alias="X-Group-Ids"),
) -> AuthContext:
authorization_value = _normalize_header_value(authorization)
if authorization_value and settings.AUTH_MODE in {"oidc", "hybrid"}:
return _build_context_from_claims(
await _decode_bearer_token(authorization_value)
)

if settings.AUTH_MODE == "oidc":
raise HTTPException(status_code=401, detail="Authentication required")

if settings.AUTH_MODE == "hybrid" and not (
settings.DEBUG or settings.TRUST_DEV_HEADERS
):
raise HTTPException(status_code=401, detail="Authentication required")

if settings.AUTH_MODE == "header" and not (
settings.DEBUG or settings.TRUST_DEV_HEADERS
):
raise HTTPException(status_code=401, detail="Authentication required")

return build_auth_context(
x_user_id=x_user_id,
x_user_role=x_user_role,
Expand All @@ -88,7 +236,6 @@ def build_auth_context(
x_organization_id: object = None,
x_group_ids: object = None,
) -> AuthContext:

"""
Builds an auth context from the current request headers.
Today this still trusts local/dev headers, but the shape matches future
Expand All @@ -114,20 +261,26 @@ def build_auth_context(


async def get_current_user(
authorization: str | None = Header(None, alias="Authorization"),
x_user_id: str | None = Header(None, alias="X-User-Id"),
x_user_role: str | None = Header(None, alias="X-User-Role"),
x_organization_id: str | None = Header(None, alias="X-Organization-Id"),
x_group_ids: str | None = Header(None, alias="X-Group-Ids"),
) -> str:
return build_auth_context(
x_user_id=x_user_id,
x_user_role=x_user_role,
x_organization_id=x_organization_id,
x_group_ids=x_group_ids,
return (
await get_auth_context(
authorization=authorization,
x_user_id=x_user_id,
x_user_role=x_user_role,
x_organization_id=x_organization_id,
x_group_ids=x_group_ids,
)
).user_id


async def get_current_workspace_id(auth_context: AuthContext = Depends(get_auth_context)) -> str:
async def get_current_workspace_id(
auth_context: AuthContext = Depends(get_auth_context),
) -> str:
return auth_context.workspace_id


Expand Down
Loading
Loading