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.
+