memuron 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- memuron/__init__.py +3 -0
- memuron/actions/__init__.py +12 -0
- memuron/actions/context.py +63 -0
- memuron/actions/helpers.py +88 -0
- memuron/actions/memory.py +340 -0
- memuron/actions/memory_write.py +290 -0
- memuron/actions/nodes.py +340 -0
- memuron/actions/registry.py +5 -0
- memuron/actions/runtime.py +37 -0
- memuron/actions/spaces_documents.py +720 -0
- memuron/actions/sync.py +155 -0
- memuron/application/__init__.py +1 -0
- memuron/application/api.py +206 -0
- memuron/application/app.py +103 -0
- memuron/application/capabilities.py +82 -0
- memuron/application/cli.py +35 -0
- memuron/application/config.py +176 -0
- memuron/application/mcp.py +44 -0
- memuron/application/mcp_oauth.py +290 -0
- memuron/application/registry.py +52 -0
- memuron/context.py +532 -0
- memuron/documents/__init__.py +1 -0
- memuron/documents/link_guardian.py +192 -0
- memuron/documents/linking.py +292 -0
- memuron/documents/parser.py +1152 -0
- memuron/documents/storage.py +151 -0
- memuron/documents/url_ingest.py +375 -0
- memuron/domain/__init__.py +1 -0
- memuron/domain/decoders.py +1 -0
- memuron/domain/encoders.py +185 -0
- memuron/domain/lifecycles.py +8 -0
- memuron/domain/limits.py +6 -0
- memuron/domain/representations.py +56 -0
- memuron/domain/schemas.py +581 -0
- memuron/domain/scope_filter.py +104 -0
- memuron/graphfs/__init__.py +1 -0
- memuron/graphfs/manual.py +635 -0
- memuron/graphfs/projection.py +578 -0
- memuron/graphfs/query.py +1782 -0
- memuron/graphfs/read_model.py +574 -0
- memuron/ingest/__init__.py +1 -0
- memuron/ingest/guardian.py +213 -0
- memuron/ingest/jobs.py +424 -0
- memuron/ingest/prompts.py +147 -0
- memuron/memory/__init__.py +1 -0
- memuron/memory/engine.py +35 -0
- memuron/memory/projections.py +452 -0
- memuron/memory/recipes.py +3247 -0
- memuron/persistence/__init__.py +1 -0
- memuron/persistence/db_pool.py +57 -0
- memuron/persistence/identity_store.py +918 -0
- memuron/persistence/store_helpers.py +16 -0
- memuron/search/__init__.py +1 -0
- memuron/search/fulltext.py +110 -0
- memuron/search/hybrid.py +284 -0
- memuron/search/pgvector.py +252 -0
- memuron/security/__init__.py +1 -0
- memuron/security/auth.py +143 -0
- memuron/security/auth_provider.py +119 -0
- memuron/security/authorization.py +53 -0
- memuron/security/clerk_scopes.py +94 -0
- memuron/security/clerk_webhooks.py +61 -0
- memuron/security/jwt_tokens.py +53 -0
- memuron/security/passwords.py +38 -0
- memuron/security/tenant.py +58 -0
- memuron/spaces/__init__.py +1 -0
- memuron/spaces/model.py +35 -0
- memuron/spaces/service.py +155 -0
- memuron/sync/__init__.py +25 -0
- memuron/sync/folder.py +828 -0
- memuron-0.1.1.dist-info/METADATA +242 -0
- memuron-0.1.1.dist-info/RECORD +74 -0
- memuron-0.1.1.dist-info/WHEEL +4 -0
- memuron-0.1.1.dist-info/entry_points.txt +4 -0
memuron/security/auth.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Authentication for Memuron and mounted engine routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
|
|
7
|
+
from fastapi import HTTPException, Request, Security
|
|
8
|
+
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
|
|
9
|
+
from artha_engine.runtime.auth import AuthContext
|
|
10
|
+
|
|
11
|
+
from memuron.application.config import settings
|
|
12
|
+
from memuron.security.jwt_tokens import TokenError, decode_access_token
|
|
13
|
+
from memuron.security.tenant import role_scopes
|
|
14
|
+
|
|
15
|
+
_bearer = HTTPBearer(auto_error=False)
|
|
16
|
+
_api_key_header = APIKeyHeader(name="X-Memuron-Api-Key", auto_error=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def extract_bearer_token(
|
|
20
|
+
request: Request,
|
|
21
|
+
bearer: HTTPAuthorizationCredentials | None = None,
|
|
22
|
+
header_key: str | None = None,
|
|
23
|
+
) -> str | None:
|
|
24
|
+
if bearer and bearer.credentials:
|
|
25
|
+
return bearer.credentials
|
|
26
|
+
auth_header = request.headers.get("authorization")
|
|
27
|
+
if auth_header and auth_header.lower().startswith("bearer "):
|
|
28
|
+
return auth_header[7:].strip()
|
|
29
|
+
if header_key:
|
|
30
|
+
return header_key
|
|
31
|
+
return request.headers.get("x-memuron-api-key")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def verify_request_api_key(request: Request) -> bool:
|
|
35
|
+
configured = settings.api_key
|
|
36
|
+
if not configured:
|
|
37
|
+
return True
|
|
38
|
+
provided = extract_bearer_token(request)
|
|
39
|
+
if not provided:
|
|
40
|
+
return False
|
|
41
|
+
return secrets.compare_digest(provided, configured)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _csv_header(value: str | None) -> list[str]:
|
|
45
|
+
if not value:
|
|
46
|
+
return []
|
|
47
|
+
return [part.strip() for part in value.split(",") if part.strip()]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _auth_context_from_request(request: Request, *, authenticated: bool) -> AuthContext:
|
|
51
|
+
actor_id = request.headers.get("x-memuron-actor-id")
|
|
52
|
+
tenant_id = request.headers.get("x-memuron-tenant-id")
|
|
53
|
+
scopes = _csv_header(request.headers.get("x-memuron-scopes"))
|
|
54
|
+
if actor_id is None:
|
|
55
|
+
actor_id = "api_key" if authenticated else "anonymous"
|
|
56
|
+
return AuthContext(
|
|
57
|
+
actor_id=actor_id,
|
|
58
|
+
tenant_id=tenant_id,
|
|
59
|
+
scopes=scopes,
|
|
60
|
+
claims={
|
|
61
|
+
"auth_scheme": "api_key" if authenticated else "none",
|
|
62
|
+
"service": "memuron",
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _auth_context_from_jwt(payload: dict[str, object]) -> AuthContext:
|
|
68
|
+
role = str(payload.get("role") or "")
|
|
69
|
+
return AuthContext(
|
|
70
|
+
actor_id=str(payload["sub"]),
|
|
71
|
+
tenant_id=str(payload["org_id"]) if payload.get("org_id") else None,
|
|
72
|
+
scopes=role_scopes(role),
|
|
73
|
+
claims={
|
|
74
|
+
"auth_scheme": "jwt",
|
|
75
|
+
"service": "memuron",
|
|
76
|
+
"email": str(payload.get("email") or ""),
|
|
77
|
+
"org_slug": str(payload.get("org_slug") or ""),
|
|
78
|
+
"role": role,
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _is_public_path(path: str) -> bool:
|
|
84
|
+
normalized = path.rstrip("/")
|
|
85
|
+
if normalized.endswith("/health"):
|
|
86
|
+
return True
|
|
87
|
+
if normalized.endswith("/auth/register") or normalized.endswith("/auth/login"):
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _looks_like_jwt(token: str) -> bool:
|
|
93
|
+
return token.count(".") == 2
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def require_auth_context(
|
|
97
|
+
request: Request,
|
|
98
|
+
bearer: HTTPAuthorizationCredentials | None = Security(_bearer),
|
|
99
|
+
header_key: str | None = Security(_api_key_header),
|
|
100
|
+
) -> AuthContext:
|
|
101
|
+
if _is_public_path(request.url.path):
|
|
102
|
+
return AuthContext(actor_id="public", scopes=["health:read"])
|
|
103
|
+
|
|
104
|
+
token = extract_bearer_token(request, bearer, header_key)
|
|
105
|
+
if token and _looks_like_jwt(token):
|
|
106
|
+
try:
|
|
107
|
+
payload = decode_access_token(token)
|
|
108
|
+
return _auth_context_from_jwt(payload)
|
|
109
|
+
except TokenError as exc:
|
|
110
|
+
raise HTTPException(status_code=401, detail="Invalid or expired token") from exc
|
|
111
|
+
|
|
112
|
+
if settings.api_key:
|
|
113
|
+
if token and secrets.compare_digest(token, settings.api_key):
|
|
114
|
+
return _auth_context_from_request(request, authenticated=True)
|
|
115
|
+
raise HTTPException(status_code=401, detail="Invalid or missing credentials")
|
|
116
|
+
|
|
117
|
+
if settings.auth_required:
|
|
118
|
+
raise HTTPException(status_code=401, detail="Authentication required")
|
|
119
|
+
|
|
120
|
+
return _auth_context_from_request(request, authenticated=False)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def require_api_key(
|
|
124
|
+
request: Request,
|
|
125
|
+
bearer: HTTPAuthorizationCredentials | None = Security(_bearer),
|
|
126
|
+
header_key: str | None = Security(_api_key_header),
|
|
127
|
+
) -> None:
|
|
128
|
+
require_auth_context(request, bearer, header_key)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def verify_engine_request(request: Request) -> bool:
|
|
132
|
+
if not (settings.auth_required or settings.api_key):
|
|
133
|
+
return True
|
|
134
|
+
token = extract_bearer_token(request)
|
|
135
|
+
if token and _looks_like_jwt(token):
|
|
136
|
+
try:
|
|
137
|
+
decode_access_token(token)
|
|
138
|
+
return True
|
|
139
|
+
except TokenError:
|
|
140
|
+
return False
|
|
141
|
+
if settings.api_key:
|
|
142
|
+
return verify_request_api_key(request)
|
|
143
|
+
return not settings.auth_required
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Memuron authentication providers (Clerk + API key for agents)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from artha_engine.app.auth import (
|
|
11
|
+
AnonymousAuthProvider,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
AuthConfigurationError,
|
|
14
|
+
AuthProvider,
|
|
15
|
+
ClerkAuthProvider,
|
|
16
|
+
)
|
|
17
|
+
from artha_engine.app.managed_api_keys import (
|
|
18
|
+
CompositeAuthProvider,
|
|
19
|
+
ManagedApiKeyAuthProvider,
|
|
20
|
+
)
|
|
21
|
+
from artha_engine.runtime.auth import AuthContext
|
|
22
|
+
from artha_engine.store.api_key_store import ApiKeyStore
|
|
23
|
+
|
|
24
|
+
from memuron.security.clerk_scopes import enrich_auth_context
|
|
25
|
+
from memuron.application.config import settings
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _bearer_token(request: Any) -> str | None:
|
|
29
|
+
header = request.headers.get("authorization")
|
|
30
|
+
if header and header.lower().startswith("bearer "):
|
|
31
|
+
return header[7:].strip()
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _csv_header(value: str | None) -> list[str]:
|
|
36
|
+
return [part.strip() for part in (value or "").split(",") if part.strip()]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class MemuronApiKeyAuthProvider:
|
|
41
|
+
"""API key auth with Memuron and Artha header conventions."""
|
|
42
|
+
|
|
43
|
+
env_var: str = "MEMURON_API_KEY"
|
|
44
|
+
actor_id: str = "api_key"
|
|
45
|
+
scopes: tuple[str, ...] = ("*",)
|
|
46
|
+
|
|
47
|
+
async def authenticate_http(self, request: Any) -> AuthContext:
|
|
48
|
+
expected = os.environ.get(self.env_var, "").strip() or settings.api_key
|
|
49
|
+
if not expected:
|
|
50
|
+
if not settings.auth_required:
|
|
51
|
+
return AuthContext(actor_id="anonymous", scopes=["*"])
|
|
52
|
+
raise AuthConfigurationError(
|
|
53
|
+
f"Set {self.env_var} before serving protected Memuron actions"
|
|
54
|
+
)
|
|
55
|
+
provided = (
|
|
56
|
+
_bearer_token(request)
|
|
57
|
+
or request.headers.get("x-memuron-api-key")
|
|
58
|
+
or request.headers.get("x-artha-api-key")
|
|
59
|
+
)
|
|
60
|
+
if not provided or not secrets.compare_digest(provided, expected):
|
|
61
|
+
raise AuthenticationError("Invalid or missing API key")
|
|
62
|
+
actor_id = (
|
|
63
|
+
request.headers.get("x-memuron-actor-id")
|
|
64
|
+
or request.headers.get("x-artha-actor-id")
|
|
65
|
+
or self.actor_id
|
|
66
|
+
)
|
|
67
|
+
tenant_id = request.headers.get("x-memuron-tenant-id") or request.headers.get(
|
|
68
|
+
"x-artha-tenant-id"
|
|
69
|
+
)
|
|
70
|
+
header_scopes = _csv_header(request.headers.get("x-memuron-scopes")) or _csv_header(
|
|
71
|
+
request.headers.get("x-artha-scopes")
|
|
72
|
+
)
|
|
73
|
+
return AuthContext(
|
|
74
|
+
actor_id=actor_id,
|
|
75
|
+
tenant_id=tenant_id,
|
|
76
|
+
scopes=header_scopes or list(self.scopes),
|
|
77
|
+
claims={"auth_scheme": "api_key", "service": "memuron"},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class MemuronClerkAuthProvider:
|
|
83
|
+
inner: ClerkAuthProvider
|
|
84
|
+
|
|
85
|
+
async def authenticate_http(self, request: Any) -> AuthContext:
|
|
86
|
+
auth = await self.inner.authenticate_http(request)
|
|
87
|
+
return enrich_auth_context(auth)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def create_memuron_auth_provider(
|
|
91
|
+
*,
|
|
92
|
+
api_key_store: ApiKeyStore | None = None,
|
|
93
|
+
) -> AuthProvider:
|
|
94
|
+
provider = os.environ.get("ARTHA_AUTH_PROVIDER", "clerk_api_key").strip().lower()
|
|
95
|
+
if provider in {"none", "anonymous"} or not settings.auth_required:
|
|
96
|
+
return AnonymousAuthProvider()
|
|
97
|
+
if provider in {"api-key", "api_key"}:
|
|
98
|
+
if api_key_store is not None:
|
|
99
|
+
return ManagedApiKeyAuthProvider(
|
|
100
|
+
store=api_key_store,
|
|
101
|
+
bootstrap_env_var="MEMURON_API_KEY",
|
|
102
|
+
alternate_header_names=("x-memuron-api-key",),
|
|
103
|
+
)
|
|
104
|
+
return MemuronApiKeyAuthProvider()
|
|
105
|
+
clerk = MemuronClerkAuthProvider(ClerkAuthProvider())
|
|
106
|
+
if provider == "clerk":
|
|
107
|
+
return clerk
|
|
108
|
+
if provider in {"composite", "clerk_api_key"}:
|
|
109
|
+
fallback: AuthProvider
|
|
110
|
+
if api_key_store is not None:
|
|
111
|
+
fallback = ManagedApiKeyAuthProvider(
|
|
112
|
+
store=api_key_store,
|
|
113
|
+
bootstrap_env_var="MEMURON_API_KEY",
|
|
114
|
+
alternate_header_names=("x-memuron-api-key",),
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
fallback = MemuronApiKeyAuthProvider()
|
|
118
|
+
return CompositeAuthProvider(primary=clerk, fallback=fallback)
|
|
119
|
+
raise AuthConfigurationError(f"Unknown ARTHA_AUTH_PROVIDER: {provider}")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Memuron authorization policy for Artha actions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from artha_engine.app.actions import ActionDefinition
|
|
8
|
+
from artha_engine.app.authorization import AuthorizationDenied, ScopeAuthorizationPolicy
|
|
9
|
+
from artha_engine.app.context import ActionContext
|
|
10
|
+
|
|
11
|
+
ORG_SCOPED_PREFIXES = ("space.", "memory.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class MemuronAuthorizationPolicy:
|
|
16
|
+
"""Scope checks plus org context for tenant-scoped actions."""
|
|
17
|
+
|
|
18
|
+
scopes: ScopeAuthorizationPolicy | None = None
|
|
19
|
+
|
|
20
|
+
def __post_init__(self) -> None:
|
|
21
|
+
object.__setattr__(self, "scopes", self.scopes or ScopeAuthorizationPolicy())
|
|
22
|
+
|
|
23
|
+
def authorize(self, action: ActionDefinition, context: ActionContext) -> None:
|
|
24
|
+
self.scopes.authorize(action, context)
|
|
25
|
+
if action.public:
|
|
26
|
+
return
|
|
27
|
+
if not _requires_org_context(action.name):
|
|
28
|
+
return
|
|
29
|
+
if context.auth.tenant_id:
|
|
30
|
+
return
|
|
31
|
+
raise AuthorizationDenied(
|
|
32
|
+
f"Action {action.name} requires an active organization (Clerk org_id)"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _requires_org_context(action_name: str) -> bool:
|
|
37
|
+
if action_name.startswith(ORG_SCOPED_PREFIXES):
|
|
38
|
+
return True
|
|
39
|
+
if action_name in {
|
|
40
|
+
"node.create",
|
|
41
|
+
"node.link",
|
|
42
|
+
"collection.create",
|
|
43
|
+
"collection.place",
|
|
44
|
+
"collection.members",
|
|
45
|
+
"document.ingest",
|
|
46
|
+
"graph.export",
|
|
47
|
+
"graph.hubs",
|
|
48
|
+
"graph.neighborhood",
|
|
49
|
+
"graph.path",
|
|
50
|
+
"graph.traverse",
|
|
51
|
+
}:
|
|
52
|
+
return True
|
|
53
|
+
return False
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Map Clerk session claims to Memuron action scopes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from artha_engine.runtime.auth import AuthContext
|
|
8
|
+
|
|
9
|
+
MEMURON_OWNER_SCOPES = [
|
|
10
|
+
"memory:read",
|
|
11
|
+
"memory:write",
|
|
12
|
+
"memory:delete",
|
|
13
|
+
"space:admin",
|
|
14
|
+
"org:admin",
|
|
15
|
+
"artha:admin",
|
|
16
|
+
"api_key:manage",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
MEMURON_MEMBER_SCOPES = [
|
|
20
|
+
"memory:read",
|
|
21
|
+
"memory:write",
|
|
22
|
+
"memory:delete",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def clerk_org_claims(claims: dict[str, Any]) -> tuple[str | None, str | None]:
|
|
27
|
+
"""Extract org id and role from Clerk JWT v1 or v2 session tokens."""
|
|
28
|
+
org_object = claims.get("o")
|
|
29
|
+
if claims.get("v") == 2 and isinstance(org_object, dict):
|
|
30
|
+
org_id = org_object.get("id")
|
|
31
|
+
raw_role = org_object.get("rol")
|
|
32
|
+
if raw_role:
|
|
33
|
+
role_text = str(raw_role).lower()
|
|
34
|
+
org_role = role_text if role_text.startswith("org:") else f"org:{role_text}"
|
|
35
|
+
else:
|
|
36
|
+
org_role = None
|
|
37
|
+
return (
|
|
38
|
+
str(org_id) if org_id is not None else None,
|
|
39
|
+
org_role,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
org_id = claims.get("org_id")
|
|
43
|
+
raw_role = claims.get("org_role") or claims.get("role")
|
|
44
|
+
org_role = str(raw_role).lower() if raw_role else None
|
|
45
|
+
return (
|
|
46
|
+
str(org_id) if org_id is not None else None,
|
|
47
|
+
org_role,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def scopes_from_clerk_claims(claims: dict[str, Any]) -> list[str]:
|
|
52
|
+
_, org_role = clerk_org_claims(claims)
|
|
53
|
+
if not org_role:
|
|
54
|
+
org_role = str(claims.get("org_role") or claims.get("role") or "").lower()
|
|
55
|
+
if org_role in {"org:admin", "admin"}:
|
|
56
|
+
return list(MEMURON_OWNER_SCOPES)
|
|
57
|
+
if org_role in {"org:member", "basic_member", "member"}:
|
|
58
|
+
return list(MEMURON_MEMBER_SCOPES)
|
|
59
|
+
for claim_name in ("permissions", "org_permissions", "scope", "scp"):
|
|
60
|
+
value = claims.get(claim_name)
|
|
61
|
+
if isinstance(value, str):
|
|
62
|
+
parts = [part.strip() for part in value.replace(",", " ").split() if part.strip()]
|
|
63
|
+
if parts:
|
|
64
|
+
return parts
|
|
65
|
+
if isinstance(value, list) and value:
|
|
66
|
+
return [str(item) for item in value]
|
|
67
|
+
org_object = claims.get("o")
|
|
68
|
+
if isinstance(org_object, dict):
|
|
69
|
+
perms = org_object.get("per")
|
|
70
|
+
if isinstance(perms, str):
|
|
71
|
+
parts = [part.strip() for part in perms.replace(",", " ").split() if part.strip()]
|
|
72
|
+
if parts:
|
|
73
|
+
return parts
|
|
74
|
+
return list(MEMURON_MEMBER_SCOPES)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def enrich_auth_context(auth: AuthContext) -> AuthContext:
|
|
78
|
+
if auth.claims.get("auth_scheme") == "api_key":
|
|
79
|
+
return auth
|
|
80
|
+
scopes = scopes_from_clerk_claims(auth.claims)
|
|
81
|
+
if "*" in auth.scopes:
|
|
82
|
+
return auth
|
|
83
|
+
merged = list(auth.scopes)
|
|
84
|
+
for scope in scopes:
|
|
85
|
+
if scope not in merged:
|
|
86
|
+
merged.append(scope)
|
|
87
|
+
org_id, _ = clerk_org_claims(auth.claims)
|
|
88
|
+
tenant_id = auth.tenant_id or org_id
|
|
89
|
+
return AuthContext(
|
|
90
|
+
actor_id=auth.actor_id,
|
|
91
|
+
tenant_id=tenant_id,
|
|
92
|
+
scopes=merged,
|
|
93
|
+
claims=auth.claims,
|
|
94
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Clerk webhook handlers for org/space provisioning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
10
|
+
|
|
11
|
+
from memuron.persistence.identity_store import IdentityStore
|
|
12
|
+
|
|
13
|
+
router = APIRouter(tags=["webhooks"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _verify_clerk_webhook(request: Request, body: bytes) -> dict[str, Any]:
|
|
17
|
+
secret = os.environ.get("CLERK_WEBHOOK_SECRET", "").strip()
|
|
18
|
+
if not secret:
|
|
19
|
+
raise HTTPException(status_code=503, detail="CLERK_WEBHOOK_SECRET is not configured")
|
|
20
|
+
try:
|
|
21
|
+
from svix.webhooks import Webhook
|
|
22
|
+
except ImportError as exc:
|
|
23
|
+
raise HTTPException(
|
|
24
|
+
status_code=503,
|
|
25
|
+
detail="Install svix to verify Clerk webhooks",
|
|
26
|
+
) from exc
|
|
27
|
+
headers = {
|
|
28
|
+
"svix-id": request.headers.get("svix-id", ""),
|
|
29
|
+
"svix-timestamp": request.headers.get("svix-timestamp", ""),
|
|
30
|
+
"svix-signature": request.headers.get("svix-signature", ""),
|
|
31
|
+
}
|
|
32
|
+
try:
|
|
33
|
+
payload = Webhook(secret).verify(body.decode("utf-8"), headers)
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
raise HTTPException(status_code=400, detail="Invalid Clerk webhook signature") from exc
|
|
36
|
+
if isinstance(payload, str):
|
|
37
|
+
return json.loads(payload)
|
|
38
|
+
return payload
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.post("/memuron/webhooks/clerk")
|
|
42
|
+
async def clerk_webhook(request: Request) -> dict[str, str]:
|
|
43
|
+
body = await request.body()
|
|
44
|
+
event = _verify_clerk_webhook(request, body)
|
|
45
|
+
event_type = str(event.get("type") or "")
|
|
46
|
+
data = event.get("data") or {}
|
|
47
|
+
identity: IdentityStore | None = getattr(request.app.state, "identity_store", None)
|
|
48
|
+
if identity is None:
|
|
49
|
+
raise HTTPException(status_code=503, detail="Identity store unavailable")
|
|
50
|
+
|
|
51
|
+
if event_type == "organization.created":
|
|
52
|
+
org_id = str(data.get("id") or "")
|
|
53
|
+
if org_id:
|
|
54
|
+
identity.ensure_org_spaces(org_id, user_id="system")
|
|
55
|
+
elif event_type in {"organizationMembership.created", "organizationMembership.updated"}:
|
|
56
|
+
org_id = str(data.get("organization_id") or data.get("organization", {}).get("id") or "")
|
|
57
|
+
user_id = str(data.get("public_user_data", {}).get("user_id") or data.get("user_id") or "")
|
|
58
|
+
if org_id and user_id:
|
|
59
|
+
identity.ensure_org_spaces(org_id, user_id)
|
|
60
|
+
|
|
61
|
+
return {"status": "ok"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""JWT access tokens for Memuron users."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import jwt
|
|
9
|
+
|
|
10
|
+
from memuron.application.config import settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TokenError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_access_token(
|
|
18
|
+
*,
|
|
19
|
+
user_id: str,
|
|
20
|
+
email: str,
|
|
21
|
+
org_id: str | None,
|
|
22
|
+
org_slug: str | None,
|
|
23
|
+
role: str | None,
|
|
24
|
+
) -> str:
|
|
25
|
+
now = datetime.now(UTC)
|
|
26
|
+
payload = {
|
|
27
|
+
"sub": user_id,
|
|
28
|
+
"email": email,
|
|
29
|
+
"org_id": org_id,
|
|
30
|
+
"org_slug": org_slug,
|
|
31
|
+
"role": role,
|
|
32
|
+
"iat": int(now.timestamp()),
|
|
33
|
+
"exp": int((now + timedelta(hours=settings.jwt_expire_hours)).timestamp()),
|
|
34
|
+
"iss": settings.jwt_issuer,
|
|
35
|
+
"typ": "access",
|
|
36
|
+
}
|
|
37
|
+
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def decode_access_token(token: str) -> dict[str, Any]:
|
|
41
|
+
try:
|
|
42
|
+
payload = jwt.decode(
|
|
43
|
+
token,
|
|
44
|
+
settings.jwt_secret,
|
|
45
|
+
algorithms=[settings.jwt_algorithm],
|
|
46
|
+
issuer=settings.jwt_issuer,
|
|
47
|
+
options={"require": ["exp", "sub", "iat", "iss"]},
|
|
48
|
+
)
|
|
49
|
+
except jwt.PyJWTError as exc:
|
|
50
|
+
raise TokenError(str(exc)) from exc
|
|
51
|
+
if payload.get("typ") != "access":
|
|
52
|
+
raise TokenError("invalid token type")
|
|
53
|
+
return payload
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Password hashing helpers (stdlib only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import secrets
|
|
8
|
+
|
|
9
|
+
_PBKDF2_ITERATIONS = 600_000
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def hash_password(password: str) -> str:
|
|
13
|
+
if len(password) < 8:
|
|
14
|
+
raise ValueError("password must be at least 8 characters")
|
|
15
|
+
salt = secrets.token_hex(16)
|
|
16
|
+
digest = hashlib.pbkdf2_hmac(
|
|
17
|
+
"sha256",
|
|
18
|
+
password.encode("utf-8"),
|
|
19
|
+
salt.encode("utf-8"),
|
|
20
|
+
_PBKDF2_ITERATIONS,
|
|
21
|
+
)
|
|
22
|
+
return f"pbkdf2_sha256${_PBKDF2_ITERATIONS}${salt}${digest.hex()}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def verify_password(password: str, stored: str) -> bool:
|
|
26
|
+
try:
|
|
27
|
+
scheme, iterations, salt, digest_hex = stored.split("$", 3)
|
|
28
|
+
except ValueError:
|
|
29
|
+
return False
|
|
30
|
+
if scheme != "pbkdf2_sha256":
|
|
31
|
+
return False
|
|
32
|
+
computed = hashlib.pbkdf2_hmac(
|
|
33
|
+
"sha256",
|
|
34
|
+
password.encode("utf-8"),
|
|
35
|
+
salt.encode("utf-8"),
|
|
36
|
+
int(iterations),
|
|
37
|
+
)
|
|
38
|
+
return hmac.compare_digest(computed.hex(), digest_hex)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Org tenancy and space scope helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def org_scope_token(org_id: str) -> str:
|
|
7
|
+
return f"org:{org_id}"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def normalize_tenant_scope(scope: list[str] | None, org_id: str | None) -> list[str]:
|
|
11
|
+
"""Drop foreign org:* tokens and ensure the caller's org token is present."""
|
|
12
|
+
if not org_id:
|
|
13
|
+
return list(scope or [])
|
|
14
|
+
token = org_scope_token(org_id)
|
|
15
|
+
cleaned = [item for item in (scope or []) if not str(item).startswith("org:")]
|
|
16
|
+
return [token, *cleaned]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def merge_org_scope(scope: list[str] | None, org_id: str | None) -> list[str]:
|
|
20
|
+
return normalize_tenant_scope(scope, org_id)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def merge_space_scope(
|
|
24
|
+
scope: list[str] | None,
|
|
25
|
+
*,
|
|
26
|
+
org_id: str | None,
|
|
27
|
+
space_token: str | None,
|
|
28
|
+
) -> list[str]:
|
|
29
|
+
merged = merge_org_scope(scope, org_id)
|
|
30
|
+
if not space_token:
|
|
31
|
+
return merged
|
|
32
|
+
if space_token not in merged:
|
|
33
|
+
insert_at = 1 if org_id and merged else 0
|
|
34
|
+
merged.insert(insert_at, space_token)
|
|
35
|
+
return merged
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ensure_space_token(scope: list[str], space_token: str) -> list[str]:
|
|
39
|
+
if space_token in scope:
|
|
40
|
+
return scope
|
|
41
|
+
return [space_token, *scope]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def tenant_scope_query(org_id: str | None, scope: str | None) -> str | None:
|
|
45
|
+
if not org_id:
|
|
46
|
+
return scope
|
|
47
|
+
org_pattern = org_scope_token(org_id)
|
|
48
|
+
if scope:
|
|
49
|
+
return f"{org_pattern},{scope}"
|
|
50
|
+
return org_pattern
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def role_scopes(role: str | None) -> list[str]:
|
|
54
|
+
if role == "owner":
|
|
55
|
+
return ["memory:read", "memory:write", "memory:delete", "org:admin"]
|
|
56
|
+
if role == "member":
|
|
57
|
+
return ["memory:read", "memory:write", "memory:delete"]
|
|
58
|
+
return []
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Space token model and space-scoping services."""
|
memuron/spaces/model.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Space token helpers — compile names to scope tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
SPACE_TOKEN_PREFIX = "space."
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def slugify_space(value: str) -> str:
|
|
11
|
+
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
12
|
+
return slug or "space"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def compile_space_token(slug: str) -> str:
|
|
16
|
+
normalized = slug.strip().lower()
|
|
17
|
+
if normalized.startswith(SPACE_TOKEN_PREFIX):
|
|
18
|
+
return normalized
|
|
19
|
+
return f"{SPACE_TOKEN_PREFIX}{normalized}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def default_personal_space(*, org_id: str) -> dict[str, str]:
|
|
23
|
+
slug = "personal"
|
|
24
|
+
return {
|
|
25
|
+
"slug": slug,
|
|
26
|
+
"name": "Personal",
|
|
27
|
+
"token": compile_space_token(slug),
|
|
28
|
+
"description": "General personal notes and preferences",
|
|
29
|
+
"guardian_prompt": (
|
|
30
|
+
"General personal notes, preferences, and life context. "
|
|
31
|
+
"Exclude work-specific project details unless explicitly personal."
|
|
32
|
+
),
|
|
33
|
+
"is_default": "true",
|
|
34
|
+
"org_id": org_id,
|
|
35
|
+
}
|