diff --git a/api/admin_ui/src/App.tsx b/api/admin_ui/src/App.tsx index b1715d07..785b15a5 100644 --- a/api/admin_ui/src/App.tsx +++ b/api/admin_ui/src/App.tsx @@ -33,6 +33,7 @@ import ListAltIcon from '@mui/icons-material/ListAlt'; import InboxIcon from '@mui/icons-material/Inbox'; import VpnKeyIcon from '@mui/icons-material/VpnKey'; import CodeIcon from '@mui/icons-material/Code'; +import TuneIcon from '@mui/icons-material/Tune'; import TerminalIcon from '@mui/icons-material/Terminal'; import AssessmentIcon from '@mui/icons-material/Assessment'; import DescriptionIcon from '@mui/icons-material/Description'; @@ -60,6 +61,7 @@ import TunnelSettings from './components/TunnelSettings'; import ProviderSetupWizard from './components/ProviderSetupWizard'; import InboundWebhookTester from './components/InboundWebhookTester'; import OutboundSmokeTests from './components/OutboundSmokeTests'; +import ConfigurationManager from './components/ConfigurationManager'; import { ThemeProvider } from './theme/ThemeContext'; import { ThemeToggle } from './components/ThemeToggle'; @@ -293,6 +295,7 @@ function AppContent() { const settingsItems = [ { label: 'Setup', icon: }, { label: 'Settings', icon: }, + { label: 'Configuration', icon: }, { label: 'Keys', icon: }, { label: 'MCP', icon: }, ]; @@ -776,8 +779,9 @@ function AppContent() { )} - {settingsTab === 2 && } - {settingsTab === 3 && } + {settingsTab === 2 && } + {settingsTab === 3 && } + {settingsTab === 4 && } diff --git a/api/admin_ui/src/api/client.ts b/api/admin_ui/src/api/client.ts index 59eb559c..322e2e60 100644 --- a/api/admin_ui/src/api/client.ts +++ b/api/admin_ui/src/api/client.ts @@ -160,6 +160,35 @@ export class AdminAPIClient { return res.json(); } + // v4 Config (read-only baseline) + async v4GetEffective(payload: { keys?: string[]; user_id?: string; tenant_id?: string; department?: string; groups?: string[] } = {}): Promise<{ schema_version: number; items: Record }>{ + const res = await this.fetch('/admin/config/v4/effective', { + method: 'POST', + body: JSON.stringify(payload || {}), + }); + return res.json(); + } + + async v4GetHierarchy(payload: { key: string; user_id?: string; tenant_id?: string; department?: string; groups?: string[] }): Promise{ + const res = await this.fetch('/admin/config/v4/hierarchy', { + method: 'POST', + body: JSON.stringify(payload || {}), + }); + return res.json(); + } + + async v4GetSafeKeys(): Promise>{ + const res = await this.fetch('/admin/config/v4/safe-keys'); + return res.json(); + } + + async v4FlushCache(scope: string = '*'): Promise<{ ok: boolean; deleted?: number | null; scope: string; backend?: string }>{ + const res = await this.fetch(`/admin/config/v4/flush-cache?scope=${encodeURIComponent(scope)}`, { + method: 'POST', + }); + return res.json(); + } + // User traits (admin-only) async getUserTraits(): Promise<{ schema_version: number; user: { id: string }; traits: string[] }>{ const res = await this.fetch('/admin/user/traits'); diff --git a/api/app/config/hierarchical_provider.py b/api/app/config/hierarchical_provider.py index 7aeecd79..83939f29 100644 --- a/api/app/config/hierarchical_provider.py +++ b/api/app/config/hierarchical_provider.py @@ -36,6 +36,442 @@ def __post_init__(self) -> None: self.groups = [] +@dataclass +class ConfigEncryption: + """Handles configuration value encryption/decryption""" + + def __init__(self, master_key: Optional[str] = None): + # Use provided key or get from environment + key = master_key or os.getenv('CONFIG_MASTER_KEY') + + # P0: require 44-char base64 Fernet key; fail fast if missing/invalid + if not key: + # For development, generate a key if not provided + if os.getenv('FAXBOT_ENV') == 'development': + key = Fernet.generate_key().decode() + print(f"[WARN] Generated dev CONFIG_MASTER_KEY: {key}") + else: + raise ValueError("CONFIG_MASTER_KEY must be set") + + if len(key) != 44: + raise ValueError("CONFIG_MASTER_KEY must be a 44-char base64 Fernet key") + + self.fernet = Fernet(key.encode() if isinstance(key, str) else key) + + def encrypt_value(self, value: Any, should_encrypt: bool = True) -> str: + """Encrypt configuration value""" + json_value = json.dumps(value) if not isinstance(value, str) else value + if should_encrypt: + return self.fernet.encrypt(json_value.encode()).decode() + return json_value + + def decrypt_value(self, encrypted_value: str, is_encrypted: bool = True) -> Any: + """Decrypt configuration value""" + try: + if is_encrypted: + decrypted = self.fernet.decrypt(encrypted_value.encode()).decode() + try: + return json.loads(decrypted) + except json.JSONDecodeError: + return decrypted + else: + try: + return json.loads(encrypted_value) + except json.JSONDecodeError: + return encrypted_value + except Exception: + # Fallback for non-JSON values + return encrypted_value + + +class HierarchicalConfigProvider: + """Phase 3 hierarchical configuration provider with encryption. + + Provides database-first configuration with hierarchical resolution: + User → Group → Department → Tenant → Global → Environment → Default + """ + + # Built-in defaults for essential configurations + BUILT_IN_DEFAULTS = { + 'system.public_api_url': 'http://localhost:8080', + 'api.rate_limit_rpm': 60, + 'api.session_timeout_hours': 8, + 'security.enforce_public_https': False, + 'security.require_mfa': False, + 'security.password_min_length': 12, + 'storage.s3.bucket': None, + 'storage.s3.region': 'us-east-1', + 'storage.s3.endpoint_url': None, + 'fax.timeout_seconds': 30, + 'fax.max_pages': 100, + 'fax.retry_attempts': 3, + 'webhook.verify_signatures': True, + 'provider.health_check_interval': 300, + 'provider.circuit_breaker_threshold': 5, + 'provider.circuit_breaker_timeout': 60, + 'audit.retention_days': 365, + 'hipaa.enforce_compliance': False, + } + + # Configuration keys that should always be encrypted + ALWAYS_ENCRYPT_KEYS = { + 'api_key', 'secret', 'password', 'token', + 'encryption.master_key', 'session.pepper' + } + + # Safe keys that can be edited in Admin Console (Phase 3 scope) + SAFE_EDIT_KEYS = { + 'fax.timeout_seconds': {'type': 'integer', 'min': 10, 'max': 300}, + 'fax.max_pages': {'type': 'integer', 'min': 1, 'max': 1000}, + 'fax.retry_attempts': {'type': 'integer', 'min': 0, 'max': 10}, + 'api.rate_limit_rpm': {'type': 'integer', 'min': 1, 'max': 10000}, + 'api.session_timeout_hours': {'type': 'integer', 'min': 1, 'max': 168}, + 'provider.health_check_interval': {'type': 'integer', 'min': 30, 'max': 3600}, + 'provider.circuit_breaker_threshold': {'type': 'integer', 'min': 1, 'max': 100}, + 'provider.circuit_breaker_timeout': {'type': 'integer', 'min': 10, 'max': 600}, + 'webhook.verify_signatures': {'type': 'boolean'}, + 'security.require_mfa': {'type': 'boolean'}, + 'hipaa.enforce_compliance': {'type': 'boolean'}, + } + + def __init__(self, cache: Optional[CacheManager] = None) -> None: + self.cache_manager = cache or CacheManager() + self.encryption = ConfigEncryption() + self._env_mapping = self._build_env_mapping() + + def _build_env_mapping(self) -> Dict[str, str]: + """Build mapping of config keys to environment variables""" + return { + "system.public_api_url": "PUBLIC_API_URL", + "api.rate_limit_rpm": "API_RATE_LIMIT_RPM", + "api.session_timeout_hours": "API_SESSION_TIMEOUT_HOURS", + "security.enforce_public_https": "ENFORCE_PUBLIC_HTTPS", + "security.require_mfa": "REQUIRE_MFA", + "storage.s3.bucket": "S3_BUCKET", + "storage.s3.region": "S3_REGION", + "storage.s3.endpoint_url": "S3_ENDPOINT_URL", + "fax.timeout_seconds": "FAX_TIMEOUT_SECONDS", + "fax.max_pages": "FAX_MAX_PAGES", + "fax.retry_attempts": "FAX_RETRY_ATTEMPTS", + "webhook.verify_signatures": "WEBHOOK_VERIFY_SIGNATURES", + "hipaa.enforce_compliance": "HIPAA_ENFORCE_COMPLIANCE", + } + + def _should_encrypt(self, key: str) -> bool: + """Check if a configuration key should be encrypted""" + return any(sensitive in key.lower() for sensitive in self.ALWAYS_ENCRYPT_KEYS) + + def _mask_value(self, value: Any, key: str) -> str: + """Mask sensitive values for audit/display""" + if value is None: + return "Not set" + + str_value = str(value) + if self._should_encrypt(key): + if len(str_value) <= 4: + return "*" * len(str_value) + return str_value[:4] + "*" * (len(str_value) - 4) + + return str_value + + async def get_effective(self, key: str, ctx: Optional[UserContext] = None) -> Dict[str, Any]: + """Get effective configuration value with hierarchical resolution. + + Resolution order: + 1. User level (if ctx.user_id) + 2. Group level (if ctx.groups) + 3. Department level (if ctx.department) + 4. Tenant level (if ctx.tenant_id) + 5. Global level (database) + 6. Environment variable + 7. Built-in default + """ + + # Try cache first + if self.cache_manager and ctx: + cache_key = self._build_cache_key('effective', ctx, key) + cached = await self.cache_manager.get(cache_key) + if cached: + return cached + + # Check database levels if available + if DB_AVAILABLE and ctx: + # Try each level in order + async with AsyncSessionLocal() as db: + # User level + if ctx.user_id: + result = await db.execute( + select(ConfigUser).where( + ConfigUser.user_id == ctx.user_id, + ConfigUser.key == key + ) + ) + config = result.scalar_one_or_none() + if config: + value = self.encryption.decrypt_value( + config.value_encrypted, config.encrypted + ) + result = {"key": key, "value": value, "source": "db", "level": "user"} + if self.cache_manager: + await self.cache_manager.set(cache_key, result, ttl=300) + return result + + # Group level + if ctx.groups: + for group_id in ctx.groups: + result = await db.execute( + select(ConfigGroup).where( + ConfigGroup.group_id == group_id, + ConfigGroup.key == key + ).order_by(ConfigGroup.priority.desc()) + ) + config = result.scalar_one_or_none() + if config: + value = self.encryption.decrypt_value( + config.value_encrypted, config.encrypted + ) + result = {"key": key, "value": value, "source": "db", "level": "group"} + if self.cache_manager: + await self.cache_manager.set(cache_key, result, ttl=300) + return result + + # Department level + if ctx.tenant_id and ctx.department: + result = await db.execute( + select(ConfigDepartment).where( + ConfigDepartment.tenant_id == ctx.tenant_id, + ConfigDepartment.department == ctx.department, + ConfigDepartment.key == key + ) + ) + config = result.scalar_one_or_none() + if config: + value = self.encryption.decrypt_value( + config.value_encrypted, config.encrypted + ) + result = {"key": key, "value": value, "source": "db", "level": "department"} + if self.cache_manager: + await self.cache_manager.set(cache_key, result, ttl=300) + return result + + # Tenant level + if ctx.tenant_id: + result = await db.execute( + select(ConfigTenant).where( + ConfigTenant.tenant_id == ctx.tenant_id, + ConfigTenant.key == key + ) + ) + config = result.scalar_one_or_none() + if config: + value = self.encryption.decrypt_value( + config.value_encrypted, config.encrypted + ) + result = {"key": key, "value": value, "source": "db", "level": "tenant"} + if self.cache_manager: + await self.cache_manager.set(cache_key, result, ttl=300) + return result + + # Global level + result = await db.execute( + select(ConfigGlobal).where(ConfigGlobal.key == key) + ) + config = result.scalar_one_or_none() + if config: + value = self.encryption.decrypt_value( + config.value_encrypted, config.encrypted + ) + result = {"key": key, "value": value, "source": "db", "level": "global"} + if self.cache_manager: + await self.cache_manager.set(cache_key, result, ttl=300) + return result + + # Check environment variable + env_key = self._env_mapping.get(key, key.upper().replace(".", "_")) + env_value = os.getenv(env_key) + if env_value is not None: + # Parse boolean strings + if env_value.lower() in ('true', 'false'): + value = env_value.lower() == 'true' + # Parse numeric strings + elif env_value.isdigit(): + value = int(env_value) + else: + value = env_value + + return {"key": key, "value": value, "source": "env"} + + # Check built-in defaults + if key in self.BUILT_IN_DEFAULTS: + return {"key": key, "value": self.BUILT_IN_DEFAULTS[key], "source": "default"} + + # Unknown key + return {"key": key, "value": None, "source": None} + + async def get_hierarchy(self, key: str, ctx: Optional[UserContext] = None) -> Dict[str, Any]: + """Get configuration hierarchy for a key showing values at each level.""" + + hierarchy = { + "key": key, + "levels": { + "user": None, + "group": None, + "department": None, + "tenant": None, + "global": None, + "env": None, + "default": None, + }, + "effective": await self.get_effective(key, ctx), + } + + # Check database levels if available + if DB_AVAILABLE and ctx: + async with AsyncSessionLocal() as db: + # User level + if ctx.user_id: + result = await db.execute( + select(ConfigUser).where( + ConfigUser.user_id == ctx.user_id, + ConfigUser.key == key + ) + ) + config = result.scalar_one_or_none() + if config: + hierarchy["levels"]["user"] = self.encryption.decrypt_value( + config.value_encrypted, config.encrypted + ) + + # Group level + if ctx.groups: + for group_id in ctx.groups: + result = await db.execute( + select(ConfigGroup).where( + ConfigGroup.group_id == group_id, + ConfigGroup.key == key + ).order_by(ConfigGroup.priority.desc()) + ) + config = result.scalar_one_or_none() + if config: + hierarchy["levels"]["group"] = self.encryption.decrypt_value( + config.value_encrypted, config.encrypted + ) + break + + # Department level + if ctx.tenant_id and ctx.department: + result = await db.execute( + select(ConfigDepartment).where( + ConfigDepartment.tenant_id == ctx.tenant_id, + ConfigDepartment.department == ctx.department, + ConfigDepartment.key == key + ) + ) + config = result.scalar_one_or_none() + if config: + hierarchy["levels"]["department"] = self.encryption.decrypt_value( + config.value_encrypted, config.encrypted + ) + + # Tenant level + if ctx.tenant_id: + result = await db.execute( + select(ConfigTenant).where( + ConfigTenant.tenant_id == ctx.tenant_id, + ConfigTenant.key == key + ) + ) + config = result.scalar_one_or_none() + if config: + hierarchy["levels"]["tenant"] = self.encryption.decrypt_value( + config.value_encrypted, config.encrypted + ) + + # Global level + result = await db.execute( + select(ConfigGlobal).where(ConfigGlobal.key == key) + ) + config = result.scalar_one_or_none() + if config: + hierarchy["levels"]["global"] = self.encryption.decrypt_value( + config.value_encrypted, config.encrypted + ) + + # Check environment + env_key = self._env_mapping.get(key, key.upper().replace(".", "_")) + env_value = os.getenv(env_key) + if env_value: + hierarchy["levels"]["env"] = env_value + + # Check default + if key in self.BUILT_IN_DEFAULTS: + hierarchy["levels"]["default"] = self.BUILT_IN_DEFAULTS[key] + + return hierarchy + + async def get_safe_edit_keys(self) -> Dict[str, Dict[str, Any]]: + """Get configuration keys that are safe to edit via Admin Console.""" + return self.SAFE_EDIT_KEYS.copy() + + async def flush_cache(self, scope: Optional[str] = None) -> Dict[str, Any]: + if not self.cache_manager: + return {"ok": True, "deleted": 0, "scope": scope or "all", "backend": "none"} + if scope and scope != "*": + deleted = await self.cache_manager.delete_pattern(scope) + return {"ok": True, "deleted": deleted, "scope": scope, "backend": "memory"} + await self.cache_manager.flush_all() + return {"ok": True, "deleted": None, "scope": "all", "backend": "memory"} + + def _build_cache_key(self, prefix: str, ctx: UserContext, key: str) -> str: + """Build cache key for hierarchical config""" + context_parts = [ + ctx.tenant_id or 'null', + ctx.department or 'null', + ctx.user_id or 'null', + ','.join(sorted(ctx.groups)) if ctx.groups else 'null' + ] + return f"cfg:{prefix}:{':'.join(context_parts)}:{key}" + + async def validate_config_value(self, key: str, value: Any) -> bool: + """Validate a configuration value against its constraints.""" + if key not in self.SAFE_EDIT_KEYS: + return False + + constraints = self.SAFE_EDIT_KEYS[key] + value_type = constraints.get('type') + + if value_type == 'integer': + if not isinstance(value, int): + try: + value = int(value) + except (ValueError, TypeError): + return False + + if 'min' in constraints and value < constraints['min']: + return False + if 'max' in constraints and value > constraints['max']: + return False + + elif value_type == 'boolean': + if not isinstance(value, bool): + if isinstance(value, str): + if value.lower() not in ('true', 'false'): + return False + else: + return False + + elif value_type == 'string': + if not isinstance(value, str): + return False + + if 'min_length' in constraints and len(value) < constraints['min_length']: + return False + if 'max_length' in constraints and len(value) > constraints['max_length']: + return False + + return True + +======= @dataclass class ConfigValue: value: Any diff --git a/api/app/db/migrations/003_hierarchical_config.py b/api/app/db/migrations/003_hierarchical_config.py new file mode 100644 index 00000000..5786d4b0 --- /dev/null +++ b/api/app/db/migrations/003_hierarchical_config.py @@ -0,0 +1,111 @@ +""" +Add hierarchical configuration tables + +Revision ID: 003_hierarchical_config +Create Date: 2025-09-26 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +def upgrade(): + # Global configuration (system-wide defaults) + op.create_table('config_global', + sa.Column('key', sa.String(200), primary_key=True, nullable=False), + sa.Column('value_encrypted', sa.Text(), nullable=False), + sa.Column('value_type', sa.String(20), nullable=False, server_default='string'), + sa.Column('encrypted', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('category', sa.String(50), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()) + ) + op.create_index('idx_global_key', 'config_global', ['key']) + op.create_index('idx_global_category', 'config_global', ['category']) + + # Tenant-level configuration + op.create_table('config_tenant', + sa.Column('tenant_id', sa.String(100), nullable=False), + sa.Column('key', sa.String(200), nullable=False), + sa.Column('value_encrypted', sa.Text(), nullable=False), + sa.Column('value_type', sa.String(20), nullable=False, server_default='string'), + sa.Column('encrypted', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.PrimaryKeyConstraint('tenant_id', 'key') + ) + op.create_index('idx_tenant_key', 'config_tenant', ['tenant_id', 'key']) + + # Department-level configuration + op.create_table('config_department', + sa.Column('tenant_id', sa.String(100), nullable=False), + sa.Column('department', sa.String(100), nullable=False), + sa.Column('key', sa.String(200), nullable=False), + sa.Column('value_encrypted', sa.Text(), nullable=False), + sa.Column('value_type', sa.String(20), nullable=False, server_default='string'), + sa.Column('encrypted', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.PrimaryKeyConstraint('tenant_id', 'department', 'key') + ) + op.create_index('idx_dept_key', 'config_department', ['tenant_id', 'department', 'key']) + + # Group-level configuration + op.create_table('config_group', + sa.Column('group_id', sa.String(100), nullable=False), + sa.Column('key', sa.String(200), nullable=False), + sa.Column('value_encrypted', sa.Text(), nullable=False), + sa.Column('value_type', sa.String(20), nullable=False, server_default='string'), + sa.Column('encrypted', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('priority', sa.Integer(), nullable=False, server_default='0'), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.PrimaryKeyConstraint('group_id', 'key') + ) + op.create_index('idx_group_key', 'config_group', ['group_id', 'key']) + op.create_index('idx_group_priority', 'config_group', ['group_id', 'priority']) + + # User-level configuration + op.create_table('config_user', + sa.Column('user_id', sa.String(100), nullable=False), + sa.Column('key', sa.String(200), nullable=False), + sa.Column('value_encrypted', sa.Text(), nullable=False), + sa.Column('value_type', sa.String(20), nullable=False, server_default='string'), + sa.Column('encrypted', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.PrimaryKeyConstraint('user_id', 'key') + ) + op.create_index('idx_user_key', 'config_user', ['user_id', 'key']) + + # Configuration audit trail + op.create_table('config_audit', + sa.Column('id', sa.String(40), primary_key=True, nullable=False), + sa.Column('level', sa.String(20), nullable=False), + sa.Column('level_id', sa.String(200), nullable=True), + sa.Column('key', sa.String(200), nullable=False), + sa.Column('old_value_masked', sa.Text(), nullable=True), + sa.Column('new_value_masked', sa.Text(), nullable=False), + sa.Column('value_hmac', sa.String(64), nullable=False), + sa.Column('value_type', sa.String(20), nullable=False), + sa.Column('changed_by', sa.String(100), nullable=False), + sa.Column('changed_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column('reason', sa.Text(), nullable=True), + sa.Column('ip_address', sa.String(45), nullable=True), + sa.Column('user_agent', sa.Text(), nullable=True) + ) + op.create_index('idx_audit_level', 'config_audit', ['level', 'level_id']) + op.create_index('idx_audit_key', 'config_audit', ['key']) + op.create_index('idx_audit_time', 'config_audit', ['changed_at']) + op.create_index('idx_audit_user', 'config_audit', ['changed_by']) + + +def downgrade(): + op.drop_table('config_audit') + op.drop_table('config_user') + op.drop_table('config_group') + op.drop_table('config_department') + op.drop_table('config_tenant') + op.drop_table('config_global') \ No newline at end of file diff --git a/api/app/main.py b/api/app/main.py index 3506f638..effb031c 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -59,6 +59,8 @@ from .middleware.traits import requires_traits from .security.permissions import require_permissions from .security.user_traits import pack_user_traits +from .config.hierarchical_provider import HierarchicalConfigProvider, UserContext +from .services.cache_manager import CacheManager from fastapi import APIRouter @@ -1255,6 +1257,90 @@ def src(env_key: str, default: Any) -> dict[str, Any]: # type: ignore return EffectiveConfigOut(schema_version=1, values=values) +# === Admin Config (v4) endpoints — read-only baseline === +class V4EffectiveIn(BaseModel): + keys: Optional[List[str]] = None + user_id: Optional[str] = None + tenant_id: Optional[str] = None + department: Optional[str] = None + groups: Optional[List[str]] = None + + +class V4EffectiveOut(BaseModel): + schema_version: int + items: Dict[str, Dict[str, Any]] + + +@app.post("/admin/config/v4/effective", dependencies=[Depends(require_admin)]) +async def admin_config_v4_effective(payload: V4EffectiveIn) -> V4EffectiveOut: + hc: HierarchicalConfigProvider = getattr(app.state, "hierarchical_config", None) # type: ignore[assignment] + if not hc: + raise HTTPException(500, detail="hierarchical config not initialized") + ctx = UserContext( + user_id=payload.user_id, + tenant_id=payload.tenant_id, + department=payload.department, + groups=payload.groups or [], + ) + keys = payload.keys or [ + "system.public_api_url", + "api.rate_limit_rpm", + "security.enforce_public_https", + "storage.s3.bucket", + "storage.s3.region", + "storage.s3.endpoint_url", + ] + out: Dict[str, Dict[str, Any]] = {} + for k in keys: + try: + out[k] = await hc.get_effective(k, ctx) + except Exception as ex: # pragma: no cover + out[k] = {"key": k, "error": str(ex), "source": None, "value": None} + return V4EffectiveOut(schema_version=1, items=out) + + +class V4HierarchyIn(BaseModel): + key: str + user_id: Optional[str] = None + tenant_id: Optional[str] = None + department: Optional[str] = None + groups: Optional[List[str]] = None + + +@app.post("/admin/config/v4/hierarchy", dependencies=[Depends(require_admin)]) +async def admin_config_v4_hierarchy(payload: V4HierarchyIn): + hc: HierarchicalConfigProvider = getattr(app.state, "hierarchical_config", None) # type: ignore[assignment] + if not hc: + raise HTTPException(500, detail="hierarchical config not initialized") + ctx = UserContext( + user_id=payload.user_id, + tenant_id=payload.tenant_id, + department=payload.department, + groups=payload.groups or [], + ) + return await hc.get_hierarchy(payload.key, ctx) + + +@app.get("/admin/config/v4/safe-keys", dependencies=[Depends(require_admin)]) +async def admin_config_v4_safe_keys(): + hc: HierarchicalConfigProvider = getattr(app.state, "hierarchical_config", None) # type: ignore[assignment] + if not hc: + raise HTTPException(500, detail="hierarchical config not initialized") + return await hc.get_safe_edit_keys() + + +@app.post("/admin/config/v4/flush-cache", dependencies=[Depends(require_admin)]) +async def admin_config_v4_flush_cache(scope: Optional[str] = Query(default="*")): + hc: HierarchicalConfigProvider = getattr(app.state, "hierarchical_config", None) # type: ignore[assignment] + if not hc: + raise HTTPException(500, detail="hierarchical config not initialized") + # flush and return a minimal report + try: + return await hc.flush_cache(scope) + except Exception as ex: # pragma: no cover + raise HTTPException(500, detail=str(ex)) + + class ProviderTestOut(BaseModel): success: bool message: str diff --git a/api/app/models/config.py b/api/app/models/config.py index 43225293..8f732d77 100644 --- a/api/app/models/config.py +++ b/api/app/models/config.py @@ -96,4 +96,4 @@ class ConfigAudit(Base): # type: ignore encrypted = Column(Boolean, nullable=False, default=False) reason = Column(String(500), nullable=True) changed_by = Column(String(100), nullable=True) - changed_at = Column(DateTime, nullable=False, server_default=func.now()) \ No newline at end of file + changed_at = Column(DateTime, nullable=False, server_default=func.now()) diff --git a/api/app/services/cache_manager.py b/api/app/services/cache_manager.py index ddeb3d36..075dbbff 100644 --- a/api/app/services/cache_manager.py +++ b/api/app/services/cache_manager.py @@ -210,4 +210,4 @@ async def health_check(self) -> Dict[str, Any]: result["error"] = str(e) self.stats["redis_available"] = False - return result \ No newline at end of file + return result diff --git a/api/requirements.txt b/api/requirements.txt index e35cfcbe..0905a1b5 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -9,7 +9,7 @@ httpx==0.27.2 tenacity==8.5.0 pytest==8.3.2 pytest-asyncio==0.23.8 -anyio==4.4.0 +anyio==4.11.0 reportlab==4.2.2 aiohttp==3.9.1 boto3==1.34.162 @@ -19,3 +19,6 @@ pexpect==4.9.0 segno==1.6.1 qrcode==7.4.2 Pillow==10.4.0 +cryptography==43.0.3 +redis==5.2.1 +sse-starlette==2.2.1 diff --git a/v4_plans/phase_3_runbook.md b/v4_plans/phase_3_runbook.md new file mode 100644 index 00000000..7574bb2d --- /dev/null +++ b/v4_plans/phase_3_runbook.md @@ -0,0 +1,115 @@ +# Faxbot v4 — Phase 3 Runbook (Auto‑Tunnel) + +Scope: Hierarchical configuration, diagnostics, and enterprise reliability. This runbook drives PR16–PR21 using the v4 PR loop, with Admin Console coverage and strict adherence to AGENTS.md (traits‑first, plugin‑first, HIPAA). + +Branch policy +- Base branch: `auto-tunnel` +- Per‑feature branches: `feat/prXX-` → PR to `auto-tunnel` +- Require green CI; never commit to `main`. After milestones, merge `auto-tunnel` → `development` via PR only. +- PR loop: see `v4_plans/implement/pr-loop-per-phase.md:1` (use exactly that 8‑step loop). + +Pre‑checks (one‑time per workstation) +- Secrets: ensure `CONFIG_MASTER_KEY` (44‑char base64) and `FAXBOT_SESSION_PEPPER` are set for secure modes; app should fail fast if sessions are enabled and these are missing. +- Optional: `REDIS_URL=redis://redis:6379/0` for caching and rate limiting. +- Dev run: `docker compose build && docker compose up -d` then `curl -fsS http://localhost:8080/health || true`. +- Quick greps: `bash scripts/ci/greps.sh || true`. + +Current repository state (verified locally) +- Admin diagnostics: `/admin/diagnostics/run` present and working. +- Admin UI config: `/admin/ui-config` present. +- v3 Admin effective config: `/admin/config/effective` present. +- NOT FOUND in workspace (expected from PR14/PR15 notes): + - `api/app/config/hierarchical_provider.py` + - `api/app/services/cache_manager.py` + - v4 Admin config endpoints under `/admin/config/v4/*` (effective, hierarchy, safe-keys, flush-cache) + - Admin SSE diagnostics router (`api/app/routers/admin_diagnostics.py`) and `services/events.py` + +Implication: Before PR16 (UI for hierarchical config) we must land PR14/PR15 content or re‑implement them here. If you have those changes in an open PR elsewhere, cherry‑pick or recreate them as small, testable diffs. + +Deliverables by PR + +PR15 — Admin config v4 endpoints (read‑only) + hierarchical provider bootstrap +- Backend + - Add `api/app/config/hierarchical_provider.py` (async provider with DB‑first resolution, `.env` fallback on DB outage only; Fernet encryption with `CONFIG_MASTER_KEY`; masked outputs; precise cache keys). + - Add `api/app/services/cache_manager.py` (Redis + local fallback; TTL; targeted invalidation by scope and key). + - Wire lazy bootstrap in `api/app/main.py` (search anchor: “Phase 3: optional hierarchical config bootstrap (lazy)”). + - Add v4 endpoints (read‑only) alongside legacy (do not rename existing): + - `GET /admin/config/v4/effective` + - `GET /admin/config/v4/hierarchy` + - `GET /admin/config/v4/safe-keys` + - `POST /admin/config/v4/flush-cache?scope=...` + - Emit no PHI; secrets masked; return 202 where applicable (not required for these reads). +- Dependencies + - Confirm `api/requirements.txt` has `cryptography`, `redis`, `sse-starlette`, and pinned `anyio` (bumped to 4.11.0). Run tests to ensure no asyncio regressions. +- Acceptance + - Greps pass; OpenAPI diff stable; endpoints functional with DB present and with DB deliberately unavailable (env fallback used only in outage). + +PR16 — Admin Console: ConfigurationManager (read‑only) +- UI + - Create `api/admin_ui/src/components/ConfigurationManager.tsx`. + - Fetch from `GET /admin/config/v4/effective`, `/hierarchy`, `/safe-keys`; call `/flush-cache` on demand. + - Display source badges (db/env/default/cache); hierarchy stack; secrets masked; helper text + “Learn more” links from `docsBase`. + - Trait‑gate visibility using existing user traits hooks; no provider name checks. + - Wire into App shell (new Settings sub‑tab “Configuration” or under Tools if specified by plan) and add client methods in `api/admin_ui/src/api/client.ts`. +- Acceptance + - Admin UI builds in Dockerfile stage; component renders real data; mobile responsive; no traits violations. + +PR17 — Safe write path + cache invalidation +- Backend + - Add `POST /admin/config/v4/set` to update only SAFE_EDIT_KEYS. + - Enforce masking + `config_audit` with masked diffs and `value_hmac` (HMAC‑SHA256 with server‑side pepper). + - Invalidate cache precisely by scope and key. +- UI + - “Save” actions enabled only for safe keys; show success and guidance; invoke flush‑cache as needed. +- Acceptance + - Values persist in DB; effective view updates without logging secrets. + +PR19 — Provider health monitor + circuit breaker +- Backend + - Implement exactly one `ProviderHealthMonitor` class in `api/app/monitoring/health.py` using `plugin_manager` and hierarchical thresholds/timeouts (`get_effective(...)`). + - Emit canonical events on transitions; surface snapshots in diagnostics. +- Acceptance + - Grep requires one provider health monitor; transitions observed under simulated failures. + +PR20 — Webhook hardening + DLQ + idempotent 202 +- Backend + - Verify signatures via trait (`webhook.verification`); dedupe on `provider_id + external_id`. + - Persist DLQ on validation failures (headers allowlist only — no secrets). + - All inbound handlers return 202 and are idempotent. +- Acceptance + - CI grep confirms 202; DLQ populated on invalid payloads; duplicates deduped. + +PR21 — Rate limiting (hierarchical) +- Backend + - Middleware enforces per‑user/per‑endpoint RPM; Redis preferred; headers: `Retry-After`, `X-RateLimit-*`. + - RPM value from `api.rate_limit_rpm` via hierarchical config. +- Acceptance + - 429s with headers observed under load; config overrides respected. + +Coding guardrails (Phase 3) +- Traits over names; `plugin_manager.get_active_by_type()` and trait checks only. +- DB‑first config; `.env` fallback only on DB outage. +- Async everything; avoid blocking I/O in async paths. +- Admin Console coverage required; mobile‑first; inline help + docs links. +- No PHI or secrets in logs/events/SSE; audit uses masked values + HMAC. +- Do not rename legacy routes; add new routes in parallel only. +- Exactly one `/metrics` endpoint; exactly one `ProviderHealthMonitor` class. + +Local validation steps (repeat per PR) +1) Greps and quick health + - `bash scripts/ci/greps.sh || true` + - `docker compose build && docker compose up -d` + - `curl -fsS http://localhost:8080/health || true` +2) UI build (Dockerfile first stage builds Admin UI) +3) OpenAPI diff and tests (CI) + +Rollback (if a PR breaks CI) +1) `git checkout auto-tunnel && git pull` +2) `git log --oneline` and copy merge commit SHA +3) `git revert -m 1 ` then `git push` + +Next actions from this runbook +1) If missing, implement PR15 (hierarchical provider + cache + v4 read‑only endpoints) as tight diffs. +2) Implement PR16: add `ConfigurationManager.tsx` and AdminAPIClient methods; wire into App. +3) Proceed with PR17–PR21 as above. +