brain / src /services /auth.py
Leon4gr45's picture
Deploy complete BrainAPI source d15a5889e878
2f1e7ff verified
Raw History Blame Contribute Delete
6.74 kB
"""Shared identities and authorization for HTTP, MCP, and browser sessions."""
from dataclasses import dataclass, field
from hashlib import sha256
import logging
import secrets
import time
from src.services.auth_config import resolve_system_pat
_legacy_warning_at = 0
class AuthError(Exception):
def __init__(self, status=401, code="AUTH_INVALID"):
self.status = status
self.code = code
super().__init__(code)
@dataclass(frozen=True)
class Principal:
kind: str
subject_id: str
brain_id: str | None
permissions: frozenset[str]
token_id: str | None = None
legacy: bool = False
credential_hash: str | None = field(default=None, repr=False)
@property
def is_system(self):
return self.kind == "system" and "system:admin" in self.permissions
def public(self):
return dict(authenticated=True, kind=self.kind, is_system=self.is_system,
brain_id=self.brain_id, permissions=sorted(self.permissions), legacy=self.legacy)
def token_digest(token: str) -> str:
return sha256(token.encode()).hexdigest()
class AuthService:
def __init__(self, data, token_store=None):
self.data = data
self.token_store = token_store
def authenticate_token(self, token: str | None) -> Principal:
global _legacy_warning_at
if isinstance(token, Principal):
return self.revalidate(token)
if not token:
raise AuthError()
canonical = resolve_system_pat()
if canonical and secrets.compare_digest(token, canonical):
return Principal("system", "system", None, frozenset({"system:admin"}),
credential_hash=token_digest(canonical))
# New credentials have an unambiguous namespace and are never legacy PATs.
if token.startswith("bpat_") and self.token_store is not None:
row = self.token_store.find_by_hash(token_digest(token))
if row:
principal = self._token_principal(row)
self.token_store.touch(row["id"])
return principal
raise AuthError()
brain = self.data.get_brain_by_pat(token)
if brain and brain.pat and secrets.compare_digest(token, brain.pat):
if time.monotonic() - _legacy_warning_at > 300:
logging.getLogger(__name__).warning("Legacy Brain credential used; rotate to a named access token.")
_legacy_warning_at = time.monotonic()
return Principal("access_token", brain.id, brain.name_key,
frozenset({"memory:read", "memory:write", "brain:read"}),
legacy=True, credential_hash=token_digest(token))
if self.token_store is not None:
row = self.token_store.find_by_hash(token_digest(token))
if row:
principal = self._token_principal(row)
self.token_store.touch(row["id"])
return principal
raise AuthError()
def revalidate(self, principal):
if principal.is_system:
canonical = resolve_system_pat()
if canonical and secrets.compare_digest(token_digest(canonical), principal.credential_hash or ""):
return principal
elif principal.token_id:
row = self.token_store.get(principal.token_id)
if row:
return self._token_principal(row)
else:
brain = self.data.get_brain(principal.brain_id)
if (brain and brain.id == principal.subject_id and brain.pat
and secrets.compare_digest(token_digest(brain.pat), principal.credential_hash or "")):
return principal
raise AuthError()
def _token_principal(self, row):
if row["revoked_at"] or (row["expires_at"] and row["expires_at"].timestamp() <= time.time()):
raise AuthError()
brain = self.data.get_brain(row["brain_id"])
if brain is None or brain.id != row["brain_resource_id"]:
raise AuthError()
return Principal("access_token", row["id"], row["brain_id"],
frozenset(row["permissions"]), token_id=row["id"], legacy=bool(row.get("legacy", False)))
def _require_system(self, principal):
if not principal.is_system:
raise AuthError(403, "FORBIDDEN")
def create_access_token(self, principal, *, brain_id, label, permissions, expires_at=None):
self._require_system(principal)
from src.services.brain_identity import validate_brain_id, BrainNotFoundError
brain = self.data.get_brain(validate_brain_id(brain_id))
if brain is None:
raise BrainNotFoundError("Brain not found")
if not label.strip():
raise ValueError("A token label is required")
return self.token_store.create(brain=brain, label=label.strip(), permissions=permissions,
expires_at=expires_at, created_by=principal.subject_id)
def list_access_tokens(self, principal, brain_id=None):
self._require_system(principal)
return self.token_store.list(brain_id)
def revoke_access_token(self, principal, token_id):
self._require_system(principal)
return self.token_store.revoke(token_id)
def authorize_workspace(self, principal, brain_id, permission="memory:read"):
if principal.is_system:
return
if principal.brain_id != brain_id:
raise AuthError(403, "FORBIDDEN")
aliases = {"memory:read": "brain:read", "memory:write": "brain:write"}
if permission not in principal.permissions and aliases.get(permission) not in principal.permissions:
raise AuthError(403, "FORBIDDEN")
def get_auth_service(data=None):
if data is None:
from src.services.data.main import data_adapter
data = data_adapter
from src.services.credentials import token_repository
return AuthService(data, token_repository)
def bearer_token(headers):
legacy = headers.get("BrainPAT")
authorization = headers.get("Authorization", "")
scheme, _, value = authorization.partition(" ")
bearer = value.strip() if scheme.lower() == "bearer" else None
if legacy and bearer and not secrets.compare_digest(legacy, bearer):
raise AuthError()
return bearer or legacy
def request_permission(method, path):
# Retrieval POSTs are reads; all ingestion, lifecycle, and graph mutations are writes.
if method in {"GET", "HEAD", "OPTIONS"}:
return "memory:read"
if "/retrieve/" in path or path.endswith(("/search", "/context")):
return "memory:read"
return "memory:write"