Files
ai-agent/backend/users/access.py
Gabriel Vidal 39a12ca498 feat(users): guests, a built-in login and a caller on every request
users.json holds guests (per-project grants view/chat/ship, models,
monthly budget, notification channels); auth.json holds one-time login
links and browser sessions (sha256 of the tokens only). Login is a
magic link mailed over SMTP (SMTP_* env), or copied from the owner's
user admin. The gate now resolves a Caller: a guest session cookie
decides the request outright and may only reach GUEST_ROUTES (default
deny, same-origin for writes); on GUEST_HOSTS nothing is ever the owner;
every existing path (Authelia via Traefik, loopback, token, read-only
key) is the owner exactly as before.

Routes: /api/auth/{request-link,login,logout}, /api/me (GET/PUT prefs),
/api/users CRUD + login-link + revoke, /api/conversation-share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:24:24 +02:00

79 lines
2.6 KiB
Python

"""
Conversation-level access for guests.
A conversation belongs to a guest when its metadata says ``owner: <user id>``
(stamped by the spawn — a conversation without one is the owner's), and is
**shared** with the ids in ``participants``. A guest sees exactly those two
sets; the owner sees everything. A conversation id can be a root transcript
(``<dir>/<sid>.jsonl``), a subagent's (``<dir>/<sid>/subagents/agent-….jsonl``)
or a bare session id — :func:`session_of` maps all three to the root session
whose metadata decides.
"""
from __future__ import annotations
import pathlib
from fastapi import HTTPException
from users.caller import Caller
def session_of(conv_id: str) -> str:
parts = pathlib.PurePosixPath(conv_id or "").parts
if "subagents" in parts:
i = parts.index("subagents")
if i > 0:
return parts[i - 1]
name = parts[-1] if parts else ""
return name[:-6] if name.endswith(".jsonl") else name
def meta_visible(caller: Caller, meta: dict) -> bool:
if caller.is_owner:
return True
return (meta.get("owner") == caller.id
or caller.id in (meta.get("participants") or ()))
def visible(deps, caller: Caller, conv_id_or_sid: str) -> bool:
if caller.is_owner:
return True
return meta_visible(caller, deps.meta_store.peek(session_of(conv_id_or_sid)))
def ensure_visible(deps, caller: Caller, conv_id_or_sid: str) -> None:
"""404 — not 403 — so a guest can't probe which conversations exist."""
if not visible(deps, caller, conv_id_or_sid):
raise HTTPException(404, "not found")
def project_of(meta: dict) -> str | None:
ps = meta.get("projects") or []
return ps[0] if ps else None
def can_send(deps, caller: Caller, sid: str) -> bool:
"""May ``caller`` add a turn (or stop the run) of conversation ``sid``?
The owner always. A participant always — sharing a conversation *is* the
invitation to talk in it. The guest who started it while they still hold
``chat`` on its project (a revoked grant stops them)."""
if caller.is_owner:
return True
meta = deps.meta_store.peek(sid)
if caller.id in (meta.get("participants") or ()):
return True
return meta.get("owner") == caller.id and caller.can(project_of(meta), "chat")
def filter_cards(caller: Caller, cards: list[dict]) -> list[dict]:
if caller.is_owner:
return cards
return [c for c in cards if meta_visible(caller, c.get("meta") or {})]
def is_shared(meta: dict) -> bool:
"""Several people may write here: the thread labels who said what."""
return bool(meta.get("owner") or meta.get("participants"))