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"