Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
# https://bailian.console.aliyun.com/?spm=a2c4g.11186623.0.0.2f2165b08fRk4l&tab=api#/api
# After successful application, obtain API_KEY and BASE_URL, example configuration below

# REST API authentication is on by default; set MASTER_KEY_HASH to the SHA-256 of your key
MASTER_KEY_HASH=

# OpenAI API Key (use Bailian's API_KEY)
OPENAI_API_KEY=you_bailian_api_key
# OpenAI API Base URL
Expand Down
1 change: 1 addition & 0 deletions docker/.env.example-full
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ MOS_CUBE_PATH=/tmp/data_test # local data path
MEMOS_BASE_PATH=. # CLI/SDK cache path
MOS_ENABLE_DEFAULT_CUBE_CONFIG=true # enable default cube config
MOS_ENABLE_REORGANIZE=false # enable memory reorg
MASTER_KEY_HASH= # SHA-256 of the API key; auth is on by default
# MOS Text Memory Type
MOS_TEXT_MEM_TYPE=general_text # general_text | tree_text
ASYNC_MODE=sync # async/sync, used in default cube config
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ tavily = [
"tavily-python (>=0.5.0,<1.0.0)",
]

# OceanBase / seekdb unified vector + graph provider
ob-mem = [
"pyseekdb (>=1.4.0,<1.5.0)", # Unified client for seekdb / OceanBase (Collection + raw SQL)
]

# All optional dependencies
# Allow users to install with `pip install MemoryOS[all]`
all = [
Expand Down Expand Up @@ -140,6 +145,7 @@ all = [
"rake-nltk (>=1.0.6,<1.1.0)",
"alibabacloud-oss-v2 (>=1.2.2,<1.2.3)",
"tavily-python (>=0.5.0,<1.0.0)",
"pyseekdb (>=1.4.0,<1.5.0)",

# Uncategorized dependencies
]
Expand Down
24 changes: 24 additions & 0 deletions src/memos/api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,30 @@ def get_postgres_config(user_id: str | None = None) -> dict[str, Any]:
"maxconn": int(os.getenv("POSTGRES_MAX_CONN", "20")),
}

@staticmethod
def get_oceanbase_config(user_id: str | None = None) -> dict[str, Any]:
"""Get OceanBase / seekdb configuration for MemOS graph storage.

Single MySQL-compatible database with logical tenant isolation by
``user_name``; nodes/edges live in ``{table_prefix}_nodes`` / ``_edges``.
"""
user_name = os.getenv("MEMOS_USER_NAME", "default")
if user_id:
user_name = f"memos_{user_id.replace('-', '')}"

return {
"host": os.getenv("OCEANBASE_HOST", "localhost"),
"port": int(os.getenv("OCEANBASE_PORT", "2881")),
"user": os.getenv("OCEANBASE_USER", "root"),
"password": os.getenv("OCEANBASE_PASSWORD", ""),
"db_name": os.getenv("OCEANBASE_DB", "memos"),
"table_prefix": os.getenv("OCEANBASE_TABLE_PREFIX", "memos_graph"),
"user_name": user_name,
"use_multi_db": False,
"embedding_dimension": int(os.getenv("EMBEDDING_DIMENSION", "1024")),
"maxconn": int(os.getenv("OCEANBASE_MAX_CONN", "20")),
}

@staticmethod
def get_mysql_config() -> dict[str, Any]:
"""Get MySQL configuration."""
Expand Down
2 changes: 2 additions & 0 deletions src/memos/api/handlers/config_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ def build_graph_db_config(user_id: str = "default") -> dict[str, Any]:
"neo4j": APIConfig.get_neo4j_config(user_id=user_id),
"polardb": APIConfig.get_polardb_config(user_id=user_id),
"postgres": APIConfig.get_postgres_config(user_id=user_id),
"oceanbase": APIConfig.get_oceanbase_config(user_id=user_id),
"seekdb": APIConfig.get_oceanbase_config(user_id=user_id),
}

# Support both GRAPH_DB_BACKEND and legacy NEO4J_BACKEND env vars
Expand Down
63 changes: 48 additions & 15 deletions src/memos/api/middleware/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

import hashlib
import hmac
import os
import time

Expand All @@ -23,9 +24,14 @@
API_KEY_HEADER = APIKeyHeader(name="Authorization", auto_error=False)

# Environment configuration
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "false").lower() == "true"
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "true").lower() == "true"
MASTER_KEY_HASH = os.getenv("MASTER_KEY_HASH") # SHA-256 hash of master key
INTERNAL_SERVICE_IPS = {"127.0.0.1", "::1", "memos-mcp", "moltbot", "clawdbot"}

if AUTH_ENABLED and not MASTER_KEY_HASH:
logger.warning(
"Authentication is enabled but MASTER_KEY_HASH is unset; requests without a valid "
"API key from the api_keys table will be rejected with 401"
)

# Connection pool for auth queries (lazy init)
_auth_pool = None
Expand Down Expand Up @@ -142,16 +148,16 @@ async def lookup_api_key(key_hash: str) -> dict[str, Any] | None:


def is_internal_request(request: Request) -> bool:
"""Check if request is from internal service."""
client_host = request.client.host if request.client else None

# Check internal IPs
if client_host in INTERNAL_SERVICE_IPS:
return True
"""Check if request carries the internal service secret.

# Check internal header (for container-to-container)
internal_header = request.headers.get("X-Internal-Service")
return internal_header == os.getenv("INTERNAL_SERVICE_SECRET")
Source IP is not evidence of an internal caller, and an unset secret must not
match an absent header.
"""
expected = os.getenv("INTERNAL_SERVICE_SECRET")
provided = request.headers.get("X-Internal-Service")
if not expected or not provided:
return False
return hmac.compare_digest(provided.encode(), expected.encode())


async def verify_api_key(
Expand All @@ -169,11 +175,12 @@ async def verify_api_key(
Raises:
HTTPException 401 if authentication fails
"""
# Skip auth if disabled
# Skip auth if disabled. The identity is pinned to the configured tenant so that
# a request cannot declare which user it acts as via X-User-Name.
if not AUTH_ENABLED:
return {
"user_name": request.headers.get("X-User-Name", "default"),
"scopes": ["all"],
"user_name": os.getenv("MOS_USER_ID", "root"),
"scopes": ["read", "write"],
"is_master_key": False,
"auth_bypassed": True,
}
Expand Down Expand Up @@ -204,7 +211,7 @@ async def verify_api_key(

# Check against master key first (has different format: mk_*)
key_hash = hash_api_key(api_key)
if MASTER_KEY_HASH and key_hash == MASTER_KEY_HASH:
if MASTER_KEY_HASH and hmac.compare_digest(key_hash.encode(), MASTER_KEY_HASH.encode()):
logger.info("Master key authentication")
return {
"user_name": "admin",
Expand Down Expand Up @@ -262,6 +269,32 @@ async def scope_checker(
return scope_checker


def resolve_authorized_user_id(
auth: dict[str, Any],
requested_user_id: str | None,
) -> str | None:
"""Bind a tenant-scoped request to the authenticated principal.

An API key's ``user_name`` is the Product API user ID it may act as, so a
non-privileged caller can only address its own user ID. Master key, internal
service and admin-scoped principals stay free to act for any user.
"""
scopes = auth.get("scopes", [])
privileged = not auth.get("auth_bypassed") and (
auth.get("is_master_key") or auth.get("is_internal") or "admin" in scopes or "all" in scopes
)
if privileged:
return requested_user_id

principal_user_id = auth.get("user_name")
if not principal_user_id:
raise HTTPException(status_code=403, detail="Authenticated principal has no user identity")
if requested_user_id is None or requested_user_id == principal_user_id:
return principal_user_id

raise HTTPException(status_code=403, detail="Cannot access another user's resources")


# Convenience dependencies
require_read = require_scope("read")
require_write = require_scope("write")
Expand Down
2 changes: 1 addition & 1 deletion src/memos/api/routers/admin_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ def generate_new_master_key(
)
def admin_health():
"""Health check for admin endpoints."""
auth_enabled = os.getenv("AUTH_ENABLED", "false").lower() == "true"
auth_enabled = os.getenv("AUTH_ENABLED", "true").lower() == "true"
master_key_configured = bool(os.getenv("MASTER_KEY_HASH"))

return {
Expand Down
Loading
Loading