From 75b2a995379849831307bb7d5efe875fa86b2086 Mon Sep 17 00:00:00 2001 From: jinjing Date: Sun, 8 Mar 2026 15:17:21 +0800 Subject: [PATCH 1/3] feat: add user secrets management with encrypted storage Add a complete user secrets subsystem for managing encrypted key-value secrets scoped to users and zones. Secrets can be referenced in plugin and agent configs using the nexus-secret:NAME pattern. - UserSecretModel with Fernet encryption (AES-128-CBC + HMAC-SHA256) - UserSecretsService for CRUD operations with audit logging - SecretsCrypto with standalone encryption key (separate from OAuth) - SecretResolver for recursive config pattern resolution - REST API endpoints (POST/GET/DELETE /api/v2/secrets) - CLI commands (nexus secrets set/get/list/delete) - PluginRegistry integration for automatic secret injection - Fix stale import in memory_with_paging.py (backends.base -> backends.backend) - Integration tests covering full lifecycle Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 + src/nexus/bricks/auth/secrets/__init__.py | 7 + src/nexus/bricks/auth/secrets/crypto.py | 110 +++++++++ src/nexus/bricks/auth/secrets/resolver.py | 86 +++++++ src/nexus/bricks/auth/secrets/service.py | 171 ++++++++++++++ src/nexus/bricks/memory/memory_with_paging.py | 2 +- src/nexus/cli/commands/__init__.py | 3 + src/nexus/cli/commands/secrets.py | 195 ++++++++++++++++ src/nexus/plugins/registry.py | 43 ++++ src/nexus/server/api/v2/routers/secrets.py | 131 +++++++++++ src/nexus/server/api/v2/versioning.py | 8 + src/nexus/server/app_state.py | 1 + src/nexus/server/lifespan/services.py | 34 +++ src/nexus/storage/models/__init__.py | 1 + src/nexus/storage/models/auth.py | 41 ++++ tests/unit/bricks/secrets/__init__.py | 0 .../unit/bricks/secrets/test_user_secrets.py | 211 ++++++++++++++++++ 17 files changed, 1045 insertions(+), 1 deletion(-) create mode 100644 src/nexus/bricks/auth/secrets/__init__.py create mode 100644 src/nexus/bricks/auth/secrets/crypto.py create mode 100644 src/nexus/bricks/auth/secrets/resolver.py create mode 100644 src/nexus/bricks/auth/secrets/service.py create mode 100644 src/nexus/cli/commands/secrets.py create mode 100644 src/nexus/server/api/v2/routers/secrets.py create mode 100644 tests/unit/bricks/secrets/__init__.py create mode 100644 tests/unit/bricks/secrets/test_user_secrets.py diff --git a/.gitignore b/.gitignore index e399401153..cc437d1443 100644 --- a/.gitignore +++ b/.gitignore @@ -151,6 +151,8 @@ config.yml *.local.yaml *.local.yml secrets/ +!src/**/secrets/ +!tests/**/secrets/ *.encrypted # Data directories diff --git a/src/nexus/bricks/auth/secrets/__init__.py b/src/nexus/bricks/auth/secrets/__init__.py new file mode 100644 index 0000000000..015d5cd7b1 --- /dev/null +++ b/src/nexus/bricks/auth/secrets/__init__.py @@ -0,0 +1,7 @@ +"""User secrets management — encrypted key-value storage per user/zone.""" + +from nexus.bricks.auth.secrets.crypto import SecretsCrypto +from nexus.bricks.auth.secrets.resolver import SecretResolver +from nexus.bricks.auth.secrets.service import UserSecretsService + +__all__ = ["SecretsCrypto", "SecretResolver", "UserSecretsService"] diff --git a/src/nexus/bricks/auth/secrets/crypto.py b/src/nexus/bricks/auth/secrets/crypto.py new file mode 100644 index 0000000000..c2de72f941 --- /dev/null +++ b/src/nexus/bricks/auth/secrets/crypto.py @@ -0,0 +1,110 @@ +"""Fernet encryption for user secrets. + +Standalone crypto service for the secrets subsystem — does NOT share +keys with OAuthCrypto. Each subsystem gets its own encryption key +stored under a distinct SystemSettings key. +""" + +import logging +from typing import TYPE_CHECKING + +from cryptography.fernet import Fernet, InvalidToken + +if TYPE_CHECKING: + from nexus.storage.record_store import RecordStoreABC + +logger = logging.getLogger(__name__) + +SECRETS_ENCRYPTION_KEY_NAME = "user_secrets_encryption_key" + + +class SecretsCrypto: + """Fernet encryption service for user secrets. + + Key management mirrors OAuthCrypto's pattern but uses a separate + ``SystemSettingsModel`` row (``user_secrets_encryption_key``) so + rotating one subsystem's key never affects the other. + """ + + def __init__( + self, + encryption_key: str | None = None, + *, + record_store: "RecordStoreABC | None" = None, + ) -> None: + self._session_factory = record_store.session_factory if record_store else None + + if encryption_key is not None: + self._init_fernet(encryption_key) + return + + if record_store: + db_key = self._load_or_create_key_from_db() + if db_key: + self._init_fernet(db_key) + return + + logger.warning( + "Generating random secrets encryption key. This key will NOT persist " + "across restarts! Pass encryption_key or record_store for production use." + ) + self._init_fernet(Fernet.generate_key().decode("utf-8")) + + def _init_fernet(self, encryption_key: str) -> None: + try: + self._fernet = Fernet(encryption_key.encode("utf-8")) + except Exception as e: + raise ValueError(f"Invalid encryption key: {e}") from e + + def _load_or_create_key_from_db(self) -> str | None: + try: + from sqlalchemy import select + + from nexus.storage.models import SystemSettingsModel + + if self._session_factory is None: + return None + + with self._session_factory() as session: + stmt = select(SystemSettingsModel).where( + SystemSettingsModel.key == SECRETS_ENCRYPTION_KEY_NAME + ) + setting = session.execute(stmt).scalar_one_or_none() + + if setting: + return str(setting.value) + + new_key = Fernet.generate_key().decode("utf-8") + new_setting = SystemSettingsModel( + key=SECRETS_ENCRYPTION_KEY_NAME, + value=new_key, + description="Fernet encryption key for user secrets", + is_sensitive=1, + ) + session.add(new_setting) + session.commit() + logger.info("Generated and stored new secrets encryption key in database") + return new_key + + except Exception: + logger.warning("Failed to load/store secrets encryption key", exc_info=True) + return None + + @staticmethod + def generate_key() -> str: + return Fernet.generate_key().decode("utf-8") + + def encrypt(self, plaintext: str) -> str: + if not plaintext: + raise ValueError("Plaintext cannot be empty") + return self._fernet.encrypt(plaintext.encode("utf-8")).decode("utf-8") + + def decrypt(self, ciphertext: str) -> str: + if not ciphertext: + raise ValueError("Ciphertext cannot be empty") + try: + return self._fernet.decrypt(ciphertext.encode("utf-8")).decode("utf-8") + except InvalidToken as e: + raise InvalidToken( + "Failed to decrypt secret. Value may be corrupted or key may have changed." + ) from e diff --git a/src/nexus/bricks/auth/secrets/resolver.py b/src/nexus/bricks/auth/secrets/resolver.py new file mode 100644 index 0000000000..0cd4d79ac7 --- /dev/null +++ b/src/nexus/bricks/auth/secrets/resolver.py @@ -0,0 +1,86 @@ +"""Secret resolver — scans configs for nexus-secret:NAME patterns and injects values. + +Used by PluginRegistry and agent config loading to resolve secret references +into actual decrypted values before execution. +""" + +import logging +import re +from typing import Any + +logger = logging.getLogger(__name__) + +# Pattern: nexus-secret:SECRET_NAME +SECRET_PATTERN = re.compile(r"nexus-secret:([A-Za-z0-9_.\-]+)") + + +class SecretResolver: + """Resolves nexus-secret:NAME references in configuration dicts/strings. + + Args: + secrets_service: UserSecretsService instance for value lookups. + user_id: The user whose secrets to resolve. + zone_id: The zone scope for secret lookups. + """ + + def __init__( + self, + secrets_service: Any, + user_id: str, + zone_id: str | None = None, + ) -> None: + from nexus.contracts.constants import ROOT_ZONE_ID + + self._service = secrets_service + self._user_id = user_id + self._zone_id = zone_id or ROOT_ZONE_ID + + def resolve_string(self, value: str) -> str: + """Replace all nexus-secret:NAME patterns in a string with decrypted values. + + If a secret is not found, the pattern is left unchanged and a warning is logged. + """ + + def _replacer(match: re.Match[str]) -> str: + secret_name = match.group(1) + resolved = self._service.get_secret_value( + user_id=self._user_id, + name=secret_name, + zone_id=self._zone_id, + ) + if resolved is None: + logger.warning( + "Secret %r not found for user=%s zone=%s", + secret_name, + self._user_id, + self._zone_id, + ) + return str(match.group(0)) # leave unresolved + return str(resolved) + + return SECRET_PATTERN.sub(_replacer, value) + + def resolve_config(self, config: Any) -> Any: + """Recursively resolve nexus-secret:NAME patterns in a config structure. + + Handles dicts, lists, and string values. Non-string leaves are returned as-is. + """ + if isinstance(config, str): + if SECRET_PATTERN.search(config): + return self.resolve_string(config) + return config + elif isinstance(config, dict): + return {k: self.resolve_config(v) for k, v in config.items()} + elif isinstance(config, list): + return [self.resolve_config(item) for item in config] + return config + + def has_secrets(self, config: Any) -> bool: + """Check if a config structure contains any nexus-secret:NAME references.""" + if isinstance(config, str): + return bool(SECRET_PATTERN.search(config)) + elif isinstance(config, dict): + return any(self.has_secrets(v) for v in config.values()) + elif isinstance(config, list): + return any(self.has_secrets(item) for item in config) + return False diff --git a/src/nexus/bricks/auth/secrets/service.py b/src/nexus/bricks/auth/secrets/service.py new file mode 100644 index 0000000000..b19b9399e4 --- /dev/null +++ b/src/nexus/bricks/auth/secrets/service.py @@ -0,0 +1,171 @@ +"""User secrets service — encrypted key-value storage per user/zone. + +Provides set/get/list/delete operations for user-managed secrets. +Values are encrypted at rest via SecretsCrypto (Fernet AES-128-CBC + HMAC-SHA256). +Every secret access emits an audit event to SecretsAuditLogger. +""" + +import logging +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +from sqlalchemy import select + +from nexus.contracts.constants import ROOT_ZONE_ID +from nexus.storage.models.auth import UserSecretModel +from nexus.storage.models.secrets_audit_log import SecretsAuditEventType + +if TYPE_CHECKING: + from nexus.bricks.auth.secrets.crypto import SecretsCrypto + from nexus.storage.record_store import RecordStoreABC + from nexus.storage.secrets_audit_logger import SecretsAuditLogger + +logger = logging.getLogger(__name__) + + +class UserSecretsService: + """Encrypted key-value secret storage scoped to (user_id, zone_id). + + Args: + record_store: RecordStoreABC providing session factories. + crypto: SecretsCrypto instance for Fernet encrypt/decrypt. + audit_logger: SecretsAuditLogger for access auditing (optional). + """ + + def __init__( + self, + record_store: "RecordStoreABC", + crypto: "SecretsCrypto", + audit_logger: "SecretsAuditLogger | None" = None, + ) -> None: + self._session_factory = record_store.session_factory + self._crypto = crypto + self._audit_logger = audit_logger + + def set_secret( + self, + *, + user_id: str, + name: str, + value: str, + zone_id: str = ROOT_ZONE_ID, + ) -> str: + """Create or update a user secret. Returns the secret_id.""" + encrypted = self._crypto.encrypt(value) + + with self._session_factory() as session: + stmt = select(UserSecretModel).where( + UserSecretModel.user_id == user_id, + UserSecretModel.zone_id == zone_id, + UserSecretModel.name == name, + ) + existing = session.execute(stmt).scalar_one_or_none() + + if existing: + existing.encrypted_value = encrypted + existing.updated_at = datetime.now(UTC) + secret_id = existing.secret_id + session.commit() + logger.info("Updated secret %r for user=%s zone=%s", name, user_id, zone_id) + else: + row = UserSecretModel( + user_id=user_id, + zone_id=zone_id, + name=name, + encrypted_value=encrypted, + ) + session.add(row) + session.flush() + secret_id = row.secret_id + session.commit() + logger.info("Created secret %r for user=%s zone=%s", name, user_id, zone_id) + + return secret_id + + def get_secret_value( + self, + *, + user_id: str, + name: str, + zone_id: str = ROOT_ZONE_ID, + ip_address: str | None = None, + ) -> str | None: + """Retrieve and decrypt a secret value. Emits audit event on access.""" + with self._session_factory() as session: + stmt = select(UserSecretModel).where( + UserSecretModel.user_id == user_id, + UserSecretModel.zone_id == zone_id, + UserSecretModel.name == name, + ) + row = session.execute(stmt).scalar_one_or_none() + + if row is None: + return None + + value = self._crypto.decrypt(row.encrypted_value) + + if self._audit_logger: + try: + self._audit_logger.log_event( + event_type=SecretsAuditEventType.KEY_ACCESSED, + actor_id=user_id, + credential_id=row.secret_id, + zone_id=zone_id, + ip_address=ip_address, + details={"secret_name": name}, + ) + except Exception: + logger.warning("Failed to log secret access audit event", exc_info=True) + + return value + + def list_secrets( + self, + *, + user_id: str, + zone_id: str = ROOT_ZONE_ID, + ) -> list[dict[str, Any]]: + """List secret metadata (names only, never values) for a user/zone.""" + with self._session_factory() as session: + stmt = ( + select(UserSecretModel) + .where( + UserSecretModel.user_id == user_id, + UserSecretModel.zone_id == zone_id, + ) + .order_by(UserSecretModel.name) + ) + rows = session.execute(stmt).scalars().all() + + return [ + { + "secret_id": row.secret_id, + "name": row.name, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } + for row in rows + ] + + def delete_secret( + self, + *, + user_id: str, + name: str, + zone_id: str = ROOT_ZONE_ID, + ) -> bool: + """Delete a user secret. Returns True if found and deleted.""" + with self._session_factory() as session: + stmt = select(UserSecretModel).where( + UserSecretModel.user_id == user_id, + UserSecretModel.zone_id == zone_id, + UserSecretModel.name == name, + ) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + return False + session.delete(row) + session.commit() + + logger.info("Deleted secret %r for user=%s zone=%s", name, user_id, zone_id) + return True diff --git a/src/nexus/bricks/memory/memory_with_paging.py b/src/nexus/bricks/memory/memory_with_paging.py index be030a1133..40ffd0b0d7 100644 --- a/src/nexus/bricks/memory/memory_with_paging.py +++ b/src/nexus/bricks/memory/memory_with_paging.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: from sqlalchemy.orm import Session - from nexus.backends.base import Backend + from nexus.backends.backend import Backend from nexus.storage.models import MemoryModel logger = logging.getLogger(__name__) diff --git a/src/nexus/cli/commands/__init__.py b/src/nexus/cli/commands/__init__.py index 3853438378..bbf7e5868e 100644 --- a/src/nexus/cli/commands/__init__.py +++ b/src/nexus/cli/commands/__init__.py @@ -39,6 +39,7 @@ rebac, sandbox, search, + secrets, server, skills, tls, @@ -82,6 +83,7 @@ def register_all_commands(cli: click.Group) -> None: cli.add_command(admin.admin) # v0.5.1: Admin API commands for user management cli.add_command(sandbox.sandbox) # v0.8.0: Sandbox management commands (Issue #372) cli.add_command(oauth.oauth) # v0.7.0: OAuth credential management (Issue #137) + cli.add_command(secrets.secrets) # User secrets management cli.add_command(zone_mod.zone) # v0.8.0: Zone federation + portability (Issue #1161, #1326) migrate.register_commands(cli) # v1.0.0: Migration tools (Issue #165) context.register_commands(cli) # Issue #1315: Context versioning @@ -105,6 +107,7 @@ def register_all_commands(cli: click.Group) -> None: "oauth", "sandbox", "search", + "secrets", "rebac", "skills", "versions", diff --git a/src/nexus/cli/commands/secrets.py b/src/nexus/cli/commands/secrets.py new file mode 100644 index 0000000000..f90fcb4cfa --- /dev/null +++ b/src/nexus/cli/commands/secrets.py @@ -0,0 +1,195 @@ +"""User secrets management CLI commands. + +Provides commands for managing encrypted user secrets: +- nexus secrets set NAME VALUE +- nexus secrets get NAME +- nexus secrets list +- nexus secrets delete NAME +""" + +import sys +from typing import TYPE_CHECKING + +import click +from rich.table import Table + +from nexus.cli.utils import console, handle_error + +if TYPE_CHECKING: + from nexus.bricks.auth.secrets.service import UserSecretsService + + +@click.group() +def secrets() -> None: + """Manage user secrets (encrypted key-value store). + + Store API keys, tokens, and other sensitive values that can be + referenced in plugin/agent configs using nexus-secret:NAME. + + \b + Examples: + nexus secrets set OPENAI_API_KEY sk-... + nexus secrets get OPENAI_API_KEY + nexus secrets list + nexus secrets delete OPENAI_API_KEY + """ + + +def _get_service(db_path: str | None = None) -> "UserSecretsService": + """Build a UserSecretsService from local database.""" + import os + from pathlib import Path + + # Import model so its table is registered on Base.metadata + import nexus.storage.models.auth # noqa: F401 + from nexus.bricks.auth.secrets.crypto import SecretsCrypto + from nexus.bricks.auth.secrets.service import UserSecretsService + from nexus.storage.models._base import Base + from nexus.storage.record_store import SQLAlchemyRecordStore + + db = db_path or os.environ.get("NEXUS_DB_PATH") + if not db: + default_db = Path.home() / ".nexus" / "nexus.db" + if default_db.exists(): + db = str(default_db) + else: + console.print( + "[red]Error:[/red] No database found. Set NEXUS_DB_PATH or use --db-path." + ) + sys.exit(1) + + db_url = f"sqlite:///{db}" if not db.startswith("sqlite") else db + record_store = SQLAlchemyRecordStore(db_url=db_url) + Base.metadata.create_all(record_store.engine, checkfirst=True) + + crypto = SecretsCrypto(record_store=record_store) + return UserSecretsService(record_store=record_store, crypto=crypto) + + +def _get_user_id() -> str: + """Get current user ID from environment or default.""" + import os + + return os.environ.get("NEXUS_USER_ID", os.environ.get("USER", "default")) + + +@secrets.command("set") +@click.argument("name") +@click.argument("value") +@click.option("--db-path", type=str, default=None, help="Path to database") +@click.option("--zone-id", type=str, default=None, help="Zone ID (default: root)") +def set_secret(name: str, value: str, db_path: str | None, zone_id: str | None) -> None: + """Set a secret value (creates or updates).""" + try: + service = _get_service(db_path) + user_id = _get_user_id() + + kwargs: dict = { + "user_id": user_id, + "name": name, + "value": value, + } + if zone_id: + kwargs["zone_id"] = zone_id + + secret_id = service.set_secret(**kwargs) + console.print(f"[green]Secret {name!r} saved[/green] (id={secret_id})") + except Exception as e: + handle_error(e) + + +@secrets.command("get") +@click.argument("name") +@click.option("--db-path", type=str, default=None, help="Path to database") +@click.option("--zone-id", type=str, default=None, help="Zone ID (default: root)") +def get_secret(name: str, db_path: str | None, zone_id: str | None) -> None: + """Get a secret value (prints to stdout).""" + try: + service = _get_service(db_path) + user_id = _get_user_id() + + kwargs: dict = { + "user_id": user_id, + "name": name, + } + if zone_id: + kwargs["zone_id"] = zone_id + + value = service.get_secret_value(**kwargs) + if value is None: + console.print(f"[yellow]Secret {name!r} not found[/yellow]") + sys.exit(1) + else: + # Print raw value (no formatting) for piping + click.echo(value) + except Exception as e: + handle_error(e) + + +@secrets.command("list") +@click.option("--db-path", type=str, default=None, help="Path to database") +@click.option("--zone-id", type=str, default=None, help="Zone ID (default: root)") +@click.option("--json", "json_output", is_flag=True, help="Output as JSON") +def list_secrets(db_path: str | None, zone_id: str | None, json_output: bool) -> None: + """List all secret names (values are never shown).""" + try: + service = _get_service(db_path) + user_id = _get_user_id() + + kwargs: dict = {"user_id": user_id} + if zone_id: + kwargs["zone_id"] = zone_id + + secrets_list = service.list_secrets(**kwargs) + + if not secrets_list: + console.print("[yellow]No secrets found[/yellow]") + return + + if json_output: + import json + + click.echo(json.dumps(secrets_list, indent=2)) + else: + table = Table(title="User Secrets") + table.add_column("Name", style="cyan") + table.add_column("Created", style="dim") + table.add_column("Updated", style="dim") + + for s in secrets_list: + table.add_row(s["name"], s.get("created_at", ""), s.get("updated_at", "")) + + console.print(table) + except Exception as e: + handle_error(e) + + +@secrets.command("delete") +@click.argument("name") +@click.option("--db-path", type=str, default=None, help="Path to database") +@click.option("--zone-id", type=str, default=None, help="Zone ID (default: root)") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") +def delete_secret(name: str, db_path: str | None, zone_id: str | None, yes: bool) -> None: + """Delete a secret by name.""" + try: + if not yes and not click.confirm(f"Delete secret {name!r}?"): + return + + service = _get_service(db_path) + user_id = _get_user_id() + + kwargs: dict = { + "user_id": user_id, + "name": name, + } + if zone_id: + kwargs["zone_id"] = zone_id + + deleted = service.delete_secret(**kwargs) + if deleted: + console.print(f"[green]Secret {name!r} deleted[/green]") + else: + console.print(f"[yellow]Secret {name!r} not found[/yellow]") + sys.exit(1) + except Exception as e: + handle_error(e) diff --git a/src/nexus/plugins/registry.py b/src/nexus/plugins/registry.py index bf3c945a43..5ecad3fa24 100644 --- a/src/nexus/plugins/registry.py +++ b/src/nexus/plugins/registry.py @@ -114,6 +114,7 @@ async def _load_plugin(self, info: PluginInfo) -> NexusPlugin | None: plugin = plugin_class(self._nexus_fs) config = self._load_plugin_config(info.name) + config = self._resolve_secrets_in_config(config) await plugin.initialize(config) loaded_plugin: NexusPlugin = plugin @@ -293,6 +294,48 @@ def _load_plugin_config(self, plugin_name: str) -> dict[str, Any]: logger.error("Failed to load config for %s: %s", plugin_name, e) return {} + def _resolve_secrets_in_config(self, config: dict[str, Any]) -> dict[str, Any]: + """Resolve nexus-secret:NAME patterns in plugin config. + + Requires a NexusFS instance with a user_secrets_service available. + Falls back silently if secrets service is unavailable. + """ + if not config: + return config + + try: + import os + + from nexus.bricks.auth.secrets.resolver import SecretResolver + + # Get secrets service from NexusFS app state + nx = self._nexus_fs + if nx is None: + return config + + secrets_service = getattr(nx, "_user_secrets_service", None) + if secrets_service is None: + return config + + user_id = os.environ.get("NEXUS_USER_ID", os.environ.get("USER", "default")) + resolver = SecretResolver( + secrets_service=secrets_service, + user_id=user_id, + ) + + if not resolver.has_secrets(config): + return config + + resolved = resolver.resolve_config(config) + logger.debug("Resolved secrets in plugin config") + return cast(dict[str, Any], resolved) + + except ImportError: + return config + except Exception: + logger.debug("Secret resolution failed, using raw config", exc_info=True) + return config + def save_plugin_config(self, plugin_name: str, config: dict[str, Any]) -> None: """Save configuration for a plugin. diff --git a/src/nexus/server/api/v2/routers/secrets.py b/src/nexus/server/api/v2/routers/secrets.py new file mode 100644 index 0000000000..bc1776884e --- /dev/null +++ b/src/nexus/server/api/v2/routers/secrets.py @@ -0,0 +1,131 @@ +"""User secrets management REST API. + +Endpoints for managing user-scoped encrypted secrets: +- POST /api/v2/secrets — set (create/update) a secret +- GET /api/v2/secrets — list secret names +- DELETE /api/v2/secrets/{name} — delete a secret +""" + +import logging +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field + +from nexus.contracts.constants import ROOT_ZONE_ID +from nexus.server.api.v2.dependencies import _get_operation_context, get_auth_result + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/api/v2/secrets", + tags=["secrets"], +) + + +# --------------------------------------------------------------------------- +# Request / Response models +# --------------------------------------------------------------------------- + + +class SetSecretRequest(BaseModel): + """Request body for setting a secret.""" + + name: str = Field(..., min_length=1, max_length=255, description="Secret name") + value: str = Field(..., min_length=1, description="Secret value (will be encrypted)") + + +class SecretMetadata(BaseModel): + """Secret metadata (never includes value).""" + + secret_id: str + name: str + created_at: str | None = None + updated_at: str | None = None + + +class SecretListResponse(BaseModel): + """Response for listing secrets.""" + + secrets: list[SecretMetadata] + count: int + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _get_secrets_service(request: Request) -> Any: + """Resolve UserSecretsService from app state.""" + service = getattr(request.app.state, "user_secrets_service", None) + if service is None: + raise HTTPException(status_code=503, detail="User secrets service not configured") + return service + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.post("", status_code=200) +async def set_secret( + body: SetSecretRequest, + request: Request, + auth_result: dict[str, Any] = Depends(get_auth_result), +) -> dict[str, Any]: + """Create or update a user secret.""" + context = _get_operation_context(auth_result) + service = _get_secrets_service(request) + + secret_id = service.set_secret( + user_id=context.user_id, + name=body.name, + value=body.value, + zone_id=context.zone_id or ROOT_ZONE_ID, + ) + + return {"secret_id": secret_id, "name": body.name} + + +@router.get("") +async def list_secrets( + request: Request, + auth_result: dict[str, Any] = Depends(get_auth_result), +) -> SecretListResponse: + """List all secret names for the current user (values never returned).""" + context = _get_operation_context(auth_result) + service = _get_secrets_service(request) + + secrets = service.list_secrets( + user_id=context.user_id, + zone_id=context.zone_id or ROOT_ZONE_ID, + ) + + return SecretListResponse( + secrets=[SecretMetadata(**s) for s in secrets], + count=len(secrets), + ) + + +@router.delete("/{name}") +async def delete_secret( + name: str, + request: Request, + auth_result: dict[str, Any] = Depends(get_auth_result), +) -> dict[str, Any]: + """Delete a user secret by name.""" + context = _get_operation_context(auth_result) + service = _get_secrets_service(request) + + deleted = service.delete_secret( + user_id=context.user_id, + name=name, + zone_id=context.zone_id or ROOT_ZONE_ID, + ) + + if not deleted: + raise HTTPException(status_code=404, detail=f"Secret {name!r} not found") + + return {"deleted": True, "name": name} diff --git a/src/nexus/server/api/v2/versioning.py b/src/nexus/server/api/v2/versioning.py index 55db82f13a..11f0125801 100644 --- a/src/nexus/server/api/v2/versioning.py +++ b/src/nexus/server/api/v2/versioning.py @@ -386,6 +386,14 @@ def build_v2_registry( except ImportError as e: logger.warning("Failed to import Cache routes: %s", e) + # ---- User secrets router ---- + try: + from nexus.server.api.v2.routers.secrets import router as secrets_router + + registry.add(RouterEntry(router=secrets_router, name="secrets", endpoint_count=3)) + except ImportError as e: + logger.warning("Failed to import Secrets routes: %s", e) + # ---- x402 protocol router (Issue #1206) ---- try: from nexus.server.api.v2.routers.x402 import router as x402_router diff --git a/src/nexus/server/app_state.py b/src/nexus/server/app_state.py index 96aadab195..f65042ae37 100644 --- a/src/nexus/server/app_state.py +++ b/src/nexus/server/app_state.py @@ -87,6 +87,7 @@ class NexusAppState: agent_event_log: Any = None transactional_snapshot_service: Any = None memory_service: Any = None + user_secrets_service: Any = None # === Realtime === subscription_manager: Any = None diff --git a/src/nexus/server/lifespan/services.py b/src/nexus/server/lifespan/services.py index 352f313eaf..1331b70693 100644 --- a/src/nexus/server/lifespan/services.py +++ b/src/nexus/server/lifespan/services.py @@ -37,6 +37,7 @@ async def startup_services(app: "FastAPI", svc: "LifespanServices") -> list[asyn _startup_sandbox_auth(app, svc) _startup_transactional_snapshot(app, svc) _startup_rlm_service(app, svc) + _startup_user_secrets_service(app, svc) # Agent background tasks depend on agent_registry agent_tasks = _startup_agent_tasks(app, svc) @@ -497,6 +498,39 @@ def _startup_rlm_service(app: "FastAPI", svc: "LifespanServices") -> None: logger.warning("[RLM] Failed to initialize RLMInferenceService: %s", e, exc_info=True) +def _startup_user_secrets_service(app: "FastAPI", svc: "LifespanServices") -> None: + """Initialize UserSecretsService for encrypted user secret storage.""" + if svc.record_store is None: + app.state.user_secrets_service = None + return + + try: + from nexus.bricks.auth.secrets.crypto import SecretsCrypto + from nexus.bricks.auth.secrets.service import UserSecretsService + from nexus.storage.secrets_audit_logger import SecretsAuditLogger + + crypto = SecretsCrypto(record_store=svc.record_store) + audit_logger = SecretsAuditLogger(record_store=svc.record_store) + + # Ensure user_secrets table exists + from sqlalchemy import Table + + from nexus.storage.models.auth import UserSecretModel + + if svc.sql_engine is not None: + cast(Table, UserSecretModel.__table__).create(svc.sql_engine, checkfirst=True) + + app.state.user_secrets_service = UserSecretsService( + record_store=svc.record_store, + crypto=crypto, + audit_logger=audit_logger, + ) + logger.info("[Secrets] UserSecretsService initialized") + except Exception as e: + logger.warning("[Secrets] Failed to initialize UserSecretsService: %s", e, exc_info=True) + app.state.user_secrets_service = None + + def _startup_agent_tasks(app: "FastAPI", svc: "LifespanServices") -> list[asyncio.Task]: """Start agent heartbeat and stale detection background tasks (Issue #1240).""" if not app.state.agent_registry: diff --git a/src/nexus/storage/models/__init__.py b/src/nexus/storage/models/__init__.py index e26ea28385..9b492df34f 100644 --- a/src/nexus/storage/models/__init__.py +++ b/src/nexus/storage/models/__init__.py @@ -62,6 +62,7 @@ from nexus.storage.models.auth import OAuthCredentialModel as OAuthCredentialModel from nexus.storage.models.auth import UserModel as UserModel from nexus.storage.models.auth import UserOAuthAccountModel as UserOAuthAccountModel +from nexus.storage.models.auth import UserSecretModel as UserSecretModel from nexus.storage.models.auth import ZoneModel as ZoneModel # Domain: Context Branching (Issue #1315) diff --git a/src/nexus/storage/models/auth.py b/src/nexus/storage/models/auth.py index 72d201b956..0118bf72f0 100644 --- a/src/nexus/storage/models/auth.py +++ b/src/nexus/storage/models/auth.py @@ -344,6 +344,47 @@ def __repr__(self) -> str: ) +class UserSecretModel(Base): + """User-managed secrets with Fernet encryption. + + Stores encrypted secret values scoped to (user_id, zone_id, name). + Encryption/decryption is handled by SecretsCrypto at the service layer. + """ + + __tablename__ = "user_secrets" + + secret_id: Mapped[str] = uuid_pk() + + user_id: Mapped[str] = mapped_column( + String(255), + ForeignKey("users.user_id", ondelete="CASCADE"), + nullable=False, + ) + zone_id: Mapped[str] = mapped_column(String(255), nullable=False, default=ROOT_ZONE_ID) + name: Mapped[str] = mapped_column(String(255), nullable=False) + encrypted_value: Mapped[str] = mapped_column(Text, nullable=False) + + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, default=lambda: datetime.now(UTC) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + __table_args__ = ( + UniqueConstraint("user_id", "zone_id", "name", name="uq_user_secret"), + Index("idx_user_secrets_user", "user_id"), + Index("idx_user_secrets_zone", "zone_id"), + Index("idx_user_secrets_user_zone", "user_id", "zone_id"), + ) + + def __repr__(self) -> str: + return f"" + + class ExternalUserServiceModel(Base): """Configuration for external user management services.""" diff --git a/tests/unit/bricks/secrets/__init__.py b/tests/unit/bricks/secrets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/bricks/secrets/test_user_secrets.py b/tests/unit/bricks/secrets/test_user_secrets.py new file mode 100644 index 0000000000..60c261578a --- /dev/null +++ b/tests/unit/bricks/secrets/test_user_secrets.py @@ -0,0 +1,211 @@ +"""Integration tests for user secrets lifecycle. + +Verifies: set -> encrypt -> retrieve/decrypt -> resolver -> audit log. +""" + +import pytest + +from nexus.bricks.auth.secrets.crypto import SecretsCrypto +from nexus.bricks.auth.secrets.resolver import SecretResolver +from nexus.bricks.auth.secrets.service import UserSecretsService +from nexus.contracts.constants import ROOT_ZONE_ID +from nexus.storage.models.auth import UserSecretModel +from nexus.storage.secrets_audit_logger import SecretsAuditLogger + + +@pytest.fixture() +def record_store(): + from tests.helpers.in_memory_record_store import InMemoryRecordStore + + store = InMemoryRecordStore() + # Ensure user_secrets table exists + UserSecretModel.__table__.create(store.engine, checkfirst=True) + # Ensure secrets_audit_log table exists + from nexus.storage.models.secrets_audit_log import SecretsAuditLogModel + + SecretsAuditLogModel.__table__.create(store.engine, checkfirst=True) + yield store + store.close() + + +@pytest.fixture() +def crypto(): + return SecretsCrypto(encryption_key=SecretsCrypto.generate_key()) + + +@pytest.fixture() +def audit_logger(record_store): + return SecretsAuditLogger(record_store=record_store) + + +@pytest.fixture() +def service(record_store, crypto, audit_logger): + return UserSecretsService( + record_store=record_store, + crypto=crypto, + audit_logger=audit_logger, + ) + + +USER_ID = "test-user-1" +ZONE_ID = ROOT_ZONE_ID + + +class TestSetAndGet: + def test_set_creates_secret(self, service): + secret_id = service.set_secret(user_id=USER_ID, name="API_KEY", value="sk-test-123") + assert secret_id is not None + + def test_get_returns_decrypted_value(self, service): + service.set_secret(user_id=USER_ID, name="API_KEY", value="sk-test-123") + value = service.get_secret_value(user_id=USER_ID, name="API_KEY") + assert value == "sk-test-123" + + def test_get_nonexistent_returns_none(self, service): + value = service.get_secret_value(user_id=USER_ID, name="MISSING") + assert value is None + + def test_set_updates_existing(self, service): + id1 = service.set_secret(user_id=USER_ID, name="KEY", value="v1") + id2 = service.set_secret(user_id=USER_ID, name="KEY", value="v2") + # Same record updated + assert id1 == id2 + assert service.get_secret_value(user_id=USER_ID, name="KEY") == "v2" + + def test_value_is_encrypted_at_rest(self, service, record_store): + service.set_secret(user_id=USER_ID, name="SECRET", value="plaintext-value") + + from sqlalchemy import select + + with record_store.session_factory() as session: + row = session.execute( + select(UserSecretModel).where(UserSecretModel.name == "SECRET") + ).scalar_one() + # Encrypted value should NOT equal plaintext + assert row.encrypted_value != "plaintext-value" + assert len(row.encrypted_value) > 0 + + +class TestListAndDelete: + def test_list_returns_metadata_only(self, service): + service.set_secret(user_id=USER_ID, name="A", value="va") + service.set_secret(user_id=USER_ID, name="B", value="vb") + + secrets = service.list_secrets(user_id=USER_ID) + assert len(secrets) == 2 + names = {s["name"] for s in secrets} + assert names == {"A", "B"} + # No values in list output + for s in secrets: + assert "value" not in s + assert "encrypted_value" not in s + + def test_list_empty(self, service): + secrets = service.list_secrets(user_id="nobody") + assert secrets == [] + + def test_delete_existing(self, service): + service.set_secret(user_id=USER_ID, name="TO_DELETE", value="val") + assert service.delete_secret(user_id=USER_ID, name="TO_DELETE") is True + assert service.get_secret_value(user_id=USER_ID, name="TO_DELETE") is None + + def test_delete_nonexistent(self, service): + assert service.delete_secret(user_id=USER_ID, name="NOPE") is False + + +class TestZoneIsolation: + def test_secrets_isolated_by_zone(self, service): + service.set_secret(user_id=USER_ID, name="KEY", value="zone1-val", zone_id="zone-1") + service.set_secret(user_id=USER_ID, name="KEY", value="zone2-val", zone_id="zone-2") + + assert ( + service.get_secret_value(user_id=USER_ID, name="KEY", zone_id="zone-1") == "zone1-val" + ) + assert ( + service.get_secret_value(user_id=USER_ID, name="KEY", zone_id="zone-2") == "zone2-val" + ) + + def test_list_scoped_to_zone(self, service): + service.set_secret(user_id=USER_ID, name="A", value="v", zone_id="z1") + service.set_secret(user_id=USER_ID, name="B", value="v", zone_id="z2") + + z1_secrets = service.list_secrets(user_id=USER_ID, zone_id="z1") + assert len(z1_secrets) == 1 + assert z1_secrets[0]["name"] == "A" + + +class TestAuditLogging: + def test_get_secret_emits_audit_event(self, service, audit_logger): + service.set_secret(user_id=USER_ID, name="AUDITED", value="secret") + service.get_secret_value(user_id=USER_ID, name="AUDITED") + + events = audit_logger.iter_events( + filters={"actor_id": USER_ID, "event_type": "key_accessed"}, + ) + assert len(events) >= 1 + event = events[0] + assert event.actor_id == USER_ID + assert event.event_type == "key_accessed" + + def test_audit_event_contains_secret_name(self, service, audit_logger): + service.set_secret(user_id=USER_ID, name="MY_KEY", value="val") + service.get_secret_value(user_id=USER_ID, name="MY_KEY") + + events = audit_logger.iter_events(filters={"actor_id": USER_ID}) + assert len(events) >= 1 + import json + + details = json.loads(events[0].details) + assert details["secret_name"] == "MY_KEY" + + +class TestSecretResolver: + def test_resolve_simple_string(self, service): + service.set_secret(user_id=USER_ID, name="TOKEN", value="abc123") + + resolver = SecretResolver(secrets_service=service, user_id=USER_ID) + result = resolver.resolve_string("Bearer nexus-secret:TOKEN") + assert result == "Bearer abc123" + + def test_resolve_config_dict(self, service): + service.set_secret(user_id=USER_ID, name="DB_PASS", value="s3cret") + + resolver = SecretResolver(secrets_service=service, user_id=USER_ID) + config = { + "database": { + "host": "localhost", + "password": "nexus-secret:DB_PASS", + }, + "api_key": "nexus-secret:DB_PASS", + } + resolved = resolver.resolve_config(config) + assert resolved["database"]["password"] == "s3cret" + assert resolved["api_key"] == "s3cret" + assert resolved["database"]["host"] == "localhost" # unchanged + + def test_resolve_missing_secret_leaves_pattern(self, service): + resolver = SecretResolver(secrets_service=service, user_id=USER_ID) + result = resolver.resolve_string("nexus-secret:MISSING_KEY") + assert result == "nexus-secret:MISSING_KEY" + + def test_resolve_list_values(self, service): + service.set_secret(user_id=USER_ID, name="KEY1", value="val1") + + resolver = SecretResolver(secrets_service=service, user_id=USER_ID) + config = ["nexus-secret:KEY1", "static"] + resolved = resolver.resolve_config(config) + assert resolved == ["val1", "static"] + + def test_has_secrets(self, service): + resolver = SecretResolver(secrets_service=service, user_id=USER_ID) + assert resolver.has_secrets({"key": "nexus-secret:FOO"}) is True + assert resolver.has_secrets({"key": "plain-value"}) is False + assert resolver.has_secrets("nexus-secret:BAR") is True + assert resolver.has_secrets(42) is False + + def test_resolve_with_zone(self, service): + service.set_secret(user_id=USER_ID, name="ZONED", value="zone-val", zone_id="z1") + + resolver = SecretResolver(secrets_service=service, user_id=USER_ID, zone_id="z1") + result = resolver.resolve_string("nexus-secret:ZONED") + assert result == "zone-val" From 89e7b89ba20ab28769fd5d39c3113b2e88551df6 Mon Sep 17 00:00:00 2001 From: jinjing Date: Sun, 8 Mar 2026 15:49:30 +0800 Subject: [PATCH 2/3] fix: wire audit logger into CLI secrets service The CLI _get_service() was not passing an audit_logger to UserSecretsService, so 'nexus secrets get' calls were not being recorded in the secrets_audit_log table. Co-Authored-By: Claude Opus 4.6 --- src/nexus/cli/commands/secrets.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/nexus/cli/commands/secrets.py b/src/nexus/cli/commands/secrets.py index f90fcb4cfa..aef6ed1e28 100644 --- a/src/nexus/cli/commands/secrets.py +++ b/src/nexus/cli/commands/secrets.py @@ -62,8 +62,11 @@ def _get_service(db_path: str | None = None) -> "UserSecretsService": record_store = SQLAlchemyRecordStore(db_url=db_url) Base.metadata.create_all(record_store.engine, checkfirst=True) + from nexus.storage.secrets_audit_logger import SecretsAuditLogger + crypto = SecretsCrypto(record_store=record_store) - return UserSecretsService(record_store=record_store, crypto=crypto) + audit_logger = SecretsAuditLogger(record_store=record_store) + return UserSecretsService(record_store=record_store, crypto=crypto, audit_logger=audit_logger) def _get_user_id() -> str: From 834584b42160406117bbb5ec8ac528523597bf63 Mon Sep 17 00:00:00 2001 From: jinjing Date: Sun, 8 Mar 2026 16:09:53 +0800 Subject: [PATCH 3/3] docs: add UserSecretModel to data-storage matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register UserSecretModel in the architecture doc: - Part 6 table: properties, storage affinity, rationale - Analysis: explain separation from OAuthCredentialModel - Master summary: Users & Auth category (4 → 5 types) - RecordStore count: 49 → 50 types Co-Authored-By: Claude Opus 4.6 --- docs/architecture/data-storage-matrix.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/architecture/data-storage-matrix.md b/docs/architecture/data-storage-matrix.md index 6beaccaef6..78cd713815 100644 --- a/docs/architecture/data-storage-matrix.md +++ b/docs/architecture/data-storage-matrix.md @@ -177,12 +177,14 @@ Map **data requiring properties** ↔ **storage providing properties**. | **UserModel** | Med | Low | SC | Relational (JOIN on zone_id, email lookup) | Small | Med | Persistent | System | Core user accounts with soft delete | SQLAlchemy with soft delete | **Keep SQLAlchemy** (relational queries) | ✅ KEEP | | **UserOAuthAccountModel** | Med | Low | SC | Relational (FK to user_id, unique constraint on provider+provider_user_id) | Small | Med | Persistent | System | OAuth provider accounts for SSO login | SQLAlchemy | **Keep SQLAlchemy** (FK, unique constraints) | ✅ KEEP | | **OAuthCredentialModel** | Med | Low | SC | Relational (FK to user_id, zone_id, encrypted tokens) | Small | Med | Persistent | Zone | OAuth tokens for backend integrations (Google Drive, OneDrive) | SQLAlchemy with encryption | **Keep SQLAlchemy** (FK, encryption) | ✅ KEEP | +| **UserSecretModel** | Med | Low | SC | Relational (FK to user_id, unique constraint on user_id+zone_id+name, Fernet-encrypted value) | Small | Med | Persistent | Zone | User-managed encrypted secrets (API keys, tokens) referenced via `nexus-secret:NAME` in plugin/agent configs | SQLAlchemy with encryption | **Keep SQLAlchemy** (FK, encryption, unique constraint) | ✅ KEEP | | **UserSessionModel** | High | Med | EC | KV (by session_id) | Tiny | High | Session | System | Active user sessions | SQLAlchemy | **CacheStore** (Dragonfly / In-Memory) | ✅ DECIDED: CacheStore | **Analysis (Step 1+3 DECIDED):** - **No merges or abstractions needed** — well-designed, minimal redundancy: - **UserOAuthAccountModel** vs **OAuthCredentialModel**: Intentionally separate — *login auth* (ID token only) vs *backend integration* (access/refresh tokens). Different security flows. - - User/OAuth models: ✅ KEEP RecordStore — relational queries, FK, encryption + - **UserSecretModel**: User-managed encrypted key-value secrets. Distinct from OAuthCredentialModel (system-managed OAuth tokens) — different lifecycle, different crypto (standalone Fernet key vs OAuth encryption key). Scoped by (user_id, zone_id, name). Accessed via `SecretResolver` for `nexus-secret:NAME` pattern injection in plugin/agent configs. Audit trail via SecretsAuditLogModel (Part 20). + - User/OAuth/Secrets models: ✅ KEEP RecordStore — relational queries, FK, encryption - **UserSessionModel affinity (Step 3)**: - Required: KV by session_id, TTL expiry, high read freq, EC sufficient - Relational ACID (RecordStore): ✅ works, but ❌ no native TTL, ❌ overkill (no JOINs/FK needed) @@ -351,7 +353,7 @@ individual rationale, and the Quartet section below for complete data type → p ### ✅ Confirmed NO-MERGE (architecture is correct): 5. **ReBAC 4 types** — Zanzibar-correct: SSOT (Tuple, Namespace), Derived (GroupClosure), Audit (Changelog) -6. **User/Auth 4 types** — Clean separation: identity (User), login auth (OAuthAccount), backend integration (OAuthCredential), sessions (UserSession) +6. **User/Auth 5 types** — Clean separation: identity (User), login auth (OAuthAccount), backend integration (OAuthCredential), user-managed secrets (UserSecret), sessions (UserSession) 7. **Events 3 types** — Different lifecycles: ephemeral (FileEvent), persistent config (Subscription), audit (Delivery) 8. **CompactFileMetadata** — Cache-tier projection of FileMetadata (auto-generated from proto) 9. **FileMetadataModel (custom KV)** — Arbitrary user-defined pairs, fundamentally different from fixed-schema FileMetadata @@ -511,10 +513,10 @@ Data classes (`FileMetadata`, `PaginatedResult`) live in `contracts/metadata.py` | ReBACNamespaceModel | SQLAlchemy | Part 5 | Permission config, KV by namespace_id | | SystemSettingsModel | SQLAlchemy | Part 13 | System config, KV by key | -**RecordStore** (Relational — PostgreSQL/SQLite) — 49 types: +**RecordStore** (Relational — PostgreSQL/SQLite) — 50 types: | Category | Data Types | From Part | Rationale | |----------|-----------|-----------|-----------| -| **Users & Auth** | UserModel, UserOAuthAccountModel, OAuthCredentialModel | Part 6 | FK, unique constraints, encryption | +| **Users & Auth** | UserModel, UserOAuthAccountModel, OAuthCredentialModel, UserSecretModel | Part 6 | FK, unique constraints, encryption | | **ReBAC** | ReBACTupleModel, ReBACGroupClosureModel, ReBACChangelogModel | Part 5 | Composite indexes (SSOT), materialized view, append-only BRIN | | **Memory System** | MemoryModel, **MemoryConfig**, TrajectoryModel, TrajectoryFeedbackModel, PlaybookModel | Part 4 | Vector search (pgvector), relational FK; MemoryConfig co-exists with MemoryModel | | **Versioning** | VersionHistoryModel, WorkspaceSnapshotModel | Part 3 | Parent FK, BRIN time-series |