Spaces:
Running
Running
File size: 6,744 Bytes
2f1e7ff | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | """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"
|