diff --git a/api/admin_ui/src/api/client.ts b/api/admin_ui/src/api/client.ts index 8f5381c4..daa73b4a 100644 --- a/api/admin_ui/src/api/client.ts +++ b/api/admin_ui/src/api/client.ts @@ -138,6 +138,29 @@ export class AdminAPIClient { return res.json(); } + async v4SetConfig(payload: { + key: string; + value: any; + level: 'global' | 'tenant' | 'department' | 'group' | 'user'; + level_id?: string; + reason?: string; + }): Promise<{ + key: string; + value: any; + source: string; + level: string; + level_id?: string; + encrypted: boolean; + updated_at: string; + }>{ + const res = await this.fetch('/admin/config/v4/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + 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/admin_ui/src/components/ConfigurationManager.tsx b/api/admin_ui/src/components/ConfigurationManager.tsx index fb0c4312..540f6ab2 100644 --- a/api/admin_ui/src/components/ConfigurationManager.tsx +++ b/api/admin_ui/src/components/ConfigurationManager.tsx @@ -27,7 +27,9 @@ import { Visibility as VisibilityIcon, VisibilityOff as VisibilityOffIcon, Info as InfoIcon, - FlashOn as FlashOnIcon + FlashOn as FlashOnIcon, + Save as SaveIcon, + Cancel as CancelIcon } from '@mui/icons-material'; import AdminAPIClient from '../api/client'; @@ -116,6 +118,14 @@ function ConfigurationManager({ client, docsBase }: ConfigurationManagerProps) { const [searchTerm, setSearchTerm] = useState(''); const [cacheStats] = useState({ backend: 'memory', memory_items: 0 }); + // Editing state + const [editingKey, setEditingKey] = useState(null); + const [editValue, setEditValue] = useState(null); + const [editLevel, setEditLevel] = useState<'global' | 'tenant' | 'department' | 'group' | 'user'>('global'); + const [editLevelId, setEditLevelId] = useState(''); + const [editReason, setEditReason] = useState(''); + const [saving, setSaving] = useState(false); + // Filter keys based on search and category const filteredKeys = ALL_KEYS.filter(key => { const matchesSearch = searchTerm === '' || key.toLowerCase().includes(searchTerm.toLowerCase()); @@ -236,6 +246,58 @@ function ConfigurationManager({ client, docsBase }: ConfigurationManagerProps) { } }; + const saveConfigValue = async () => { + if (!editingKey) return; + + try { + setSaving(true); + setError(null); + + await client.v4SetConfig({ + key: editingKey, + value: editValue, + level: editLevel, + level_id: editLevel === 'global' ? undefined : editLevelId, + reason: editReason || 'Configuration update via Admin Console' + }); + + setSuccess(`Configuration '${editingKey}' updated successfully`); + + // Reset editing state + setEditingKey(null); + setEditValue(null); + setEditLevel('global'); + setEditLevelId(''); + setEditReason(''); + + // Refresh configuration + await fetchEffectiveConfig(); + + setTimeout(() => setSuccess(null), 3000); + } catch (err: any) { + console.error('Failed to save config:', err); + setError(err.message || 'Failed to save configuration'); + } finally { + setSaving(false); + } + }; + + const startEditing = (key: string, currentValue: any) => { + setEditingKey(key); + setEditValue(currentValue); + setEditLevel('global'); // Default to global level + setEditLevelId(''); + setEditReason(''); + }; + + const cancelEditing = () => { + setEditingKey(null); + setEditValue(null); + setEditLevel('global'); + setEditLevelId(''); + setEditReason(''); + }; + const toggleShowValue = (key: string) => { setShowValues(prev => ({ ...prev, @@ -445,19 +507,35 @@ function ConfigurationManager({ client, docsBase }: ConfigurationManagerProps) { config.value || 'Not set' ) : 'Loading...'} - {isSecret && config && ( - - { - e.stopPropagation(); - toggleShowValue(key); - }} - > - {showValue ? : } - - - )} + + {isSecret && config && ( + + { + e.stopPropagation(); + toggleShowValue(key); + }} + > + {showValue ? : } + + + )} + {config && key in safeKeys && ( + + { + e.stopPropagation(); + startEditing(key, config.value); + }} + > + + + + )} + ); @@ -559,6 +637,102 @@ function ConfigurationManager({ client, docsBase }: ConfigurationManagerProps) { )} + {/* Configuration Edit Dialog */} + {editingKey && ( + + + + Edit Configuration: {editingKey} + + + + {/* Level Selector */} + + setEditLevel(e.target.value as any)} + sx={{ minWidth: 200 }} + size="small" + > + Global (System-wide) + Tenant + Department + Group + User + + + {editLevel !== 'global' && ( + setEditLevelId(e.target.value)} + placeholder={ + editLevel === 'department' ? 'tenant_id:department_name' : + editLevel === 'tenant' ? 'tenant_id' : + editLevel === 'group' ? 'group_id' : + 'user_id' + } + sx={{ flex: 1 }} + size="small" + required + /> + )} + + + {/* Value Editor */} + { + const val = e.target.value; + // Try to parse as appropriate type + if (val === 'true') setEditValue(true); + else if (val === 'false') setEditValue(false); + else if (/^\d+$/.test(val)) setEditValue(parseInt(val, 10)); + else setEditValue(val); + }} + multiline + rows={2} + fullWidth + size="small" + helperText={`Constraints: ${JSON.stringify(safeKeys[editingKey] || {})}`} + /> + + {/* Reason */} + setEditReason(e.target.value)} + fullWidth + size="small" + placeholder="Configuration update via Admin Console" + /> + + {/* Actions */} + + + + + + + )} + {/* Info Panel with Stats */} diff --git a/api/app/config/hierarchical_provider.py b/api/app/config/hierarchical_provider.py index 886f9aec..e9c3d411 100644 --- a/api/app/config/hierarchical_provider.py +++ b/api/app/config/hierarchical_provider.py @@ -476,3 +476,327 @@ async def validate_config_value(self, key: str, value: Any) -> bool: return True + async def set( + self, + key: str, + value: Any, + level: ConfigLevel, + level_id: Optional[str] = None, + changed_by: str = "system", + reason: Optional[str] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None + ) -> Dict[str, Any]: + """Set configuration value at specified level with audit trail.""" + + if not DB_AVAILABLE: + raise ValueError("Database not available for configuration updates") + + # Validate key is safe to edit + if key not in self.SAFE_EDIT_KEYS: + raise ValueError(f"Key '{key}' is not safe to edit via API") + + # Validate value + if not await self.validate_config_value(key, value): + constraints = self.SAFE_EDIT_KEYS[key] + raise ValueError(f"Value does not meet constraints: {constraints}") + + # Determine if this key should be encrypted + should_encrypt = self._should_encrypt(key) + + async with AsyncSessionLocal() as db: + try: + # Get old value for audit + old_value = None + try: + old_config = await self._get_raw_config(key, level, level_id, db) + if old_config: + old_value = old_config.value_encrypted + except Exception: + pass # No old value is fine + + # Encrypt new value + encrypted_value = self.encryption.encrypt_value(value, should_encrypt) + + # Store configuration based on level + if level == 'global': + config_record = ConfigGlobal( + key=key, + value_encrypted=encrypted_value, + value_type=type(value).__name__, + encrypted=should_encrypt, + updated_at=datetime.utcnow() + ) + # Use merge for upsert behavior + existing = await db.execute(select(ConfigGlobal).where(ConfigGlobal.key == key)) + if existing.scalar_one_or_none(): + await db.execute( + ConfigGlobal.__table__.update().where( + ConfigGlobal.key == key + ).values( + value_encrypted=encrypted_value, + value_type=type(value).__name__, + encrypted=should_encrypt, + updated_at=datetime.utcnow() + ) + ) + else: + db.add(config_record) + + elif level == 'tenant': + if not level_id: + raise ValueError("tenant_id required for tenant-level configuration") + + existing = await db.execute( + select(ConfigTenant).where( + ConfigTenant.tenant_id == level_id, + ConfigTenant.key == key + ) + ) + if existing.scalar_one_or_none(): + await db.execute( + ConfigTenant.__table__.update().where( + ConfigTenant.tenant_id == level_id, + ConfigTenant.key == key + ).values( + value_encrypted=encrypted_value, + value_type=type(value).__name__, + encrypted=should_encrypt, + updated_at=datetime.utcnow() + ) + ) + else: + config_record = ConfigTenant( + tenant_id=level_id, + key=key, + value_encrypted=encrypted_value, + value_type=type(value).__name__, + encrypted=should_encrypt, + updated_at=datetime.utcnow() + ) + db.add(config_record) + + elif level == 'department': + if not level_id or ':' not in level_id: + raise ValueError("level_id must be 'tenant_id:department' for department-level configuration") + + tenant_id, department = level_id.split(':', 1) + existing = await db.execute( + select(ConfigDepartment).where( + ConfigDepartment.tenant_id == tenant_id, + ConfigDepartment.department == department, + ConfigDepartment.key == key + ) + ) + if existing.scalar_one_or_none(): + await db.execute( + ConfigDepartment.__table__.update().where( + ConfigDepartment.tenant_id == tenant_id, + ConfigDepartment.department == department, + ConfigDepartment.key == key + ).values( + value_encrypted=encrypted_value, + value_type=type(value).__name__, + encrypted=should_encrypt, + updated_at=datetime.utcnow() + ) + ) + else: + config_record = ConfigDepartment( + tenant_id=tenant_id, + department=department, + key=key, + value_encrypted=encrypted_value, + value_type=type(value).__name__, + encrypted=should_encrypt, + updated_at=datetime.utcnow() + ) + db.add(config_record) + + elif level == 'group': + if not level_id: + raise ValueError("group_id required for group-level configuration") + + existing = await db.execute( + select(ConfigGroup).where( + ConfigGroup.group_id == level_id, + ConfigGroup.key == key + ) + ) + if existing.scalar_one_or_none(): + await db.execute( + ConfigGroup.__table__.update().where( + ConfigGroup.group_id == level_id, + ConfigGroup.key == key + ).values( + value_encrypted=encrypted_value, + value_type=type(value).__name__, + encrypted=should_encrypt, + updated_at=datetime.utcnow() + ) + ) + else: + config_record = ConfigGroup( + group_id=level_id, + key=key, + value_encrypted=encrypted_value, + value_type=type(value).__name__, + encrypted=should_encrypt, + updated_at=datetime.utcnow() + ) + db.add(config_record) + + elif level == 'user': + if not level_id: + raise ValueError("user_id required for user-level configuration") + + existing = await db.execute( + select(ConfigUser).where( + ConfigUser.user_id == level_id, + ConfigUser.key == key + ) + ) + if existing.scalar_one_or_none(): + await db.execute( + ConfigUser.__table__.update().where( + ConfigUser.user_id == level_id, + ConfigUser.key == key + ).values( + value_encrypted=encrypted_value, + value_type=type(value).__name__, + encrypted=should_encrypt, + updated_at=datetime.utcnow() + ) + ) + else: + config_record = ConfigUser( + user_id=level_id, + key=key, + value_encrypted=encrypted_value, + value_type=type(value).__name__, + encrypted=should_encrypt, + updated_at=datetime.utcnow() + ) + db.add(config_record) + + else: + raise ValueError(f"Invalid level: {level}") + + # Create audit record + audit_record = ConfigAudit( + id=uuid.uuid4().hex, + level=level, + level_id=level_id, + key=key, + old_value_masked=self._mask_value(old_value, key) if old_value else None, + new_value_masked=self._mask_value(value, key), + value_hmac=self._compute_value_hmac(value), + value_type=type(value).__name__, + changed_by=changed_by, + reason=reason, + ip_address=ip_address, + user_agent=user_agent + ) + db.add(audit_record) + + await db.commit() + + # Invalidate relevant cache entries + await self._invalidate_cache_for_key(key, level, level_id) + + # Return success response + return { + "key": key, + "value": value, + "source": "db", + "level": level, + "level_id": level_id, + "encrypted": should_encrypt, + "updated_at": datetime.utcnow().isoformat() + } + + except Exception as e: + await db.rollback() + raise + + async def _get_raw_config(self, key: str, level: ConfigLevel, level_id: Optional[str], db) -> Optional[Any]: + """Get raw configuration record from database.""" + if level == 'global': + result = await db.execute(select(ConfigGlobal).where(ConfigGlobal.key == key)) + return result.scalar_one_or_none() + elif level == 'tenant' and level_id: + result = await db.execute( + select(ConfigTenant).where( + ConfigTenant.tenant_id == level_id, + ConfigTenant.key == key + ) + ) + return result.scalar_one_or_none() + elif level == 'department' and level_id and ':' in level_id: + tenant_id, department = level_id.split(':', 1) + result = await db.execute( + select(ConfigDepartment).where( + ConfigDepartment.tenant_id == tenant_id, + ConfigDepartment.department == department, + ConfigDepartment.key == key + ) + ) + return result.scalar_one_or_none() + elif level == 'group' and level_id: + result = await db.execute( + select(ConfigGroup).where( + ConfigGroup.group_id == level_id, + ConfigGroup.key == key + ) + ) + return result.scalar_one_or_none() + elif level == 'user' and level_id: + result = await db.execute( + select(ConfigUser).where( + ConfigUser.user_id == level_id, + ConfigUser.key == key + ) + ) + return result.scalar_one_or_none() + return None + + def _compute_value_hmac(self, value: Any) -> str: + """Compute HMAC for audit integrity.""" + # Use a server-side pepper for audit integrity + audit_pepper = os.getenv('AUDIT_PEPPER', 'default-audit-pepper-change-in-production') + + value_str = json.dumps(value) if not isinstance(value, str) else value + return hmac.new( + audit_pepper.encode(), + value_str.encode(), + hashlib.sha256 + ).hexdigest() + + async def _invalidate_cache_for_key(self, key: str, level: ConfigLevel, level_id: Optional[str]): + """Invalidate cache entries affected by configuration change.""" + if not self.cache_manager: + return + + patterns_to_invalidate = [] + + if level == 'global': + # Global changes affect all users + patterns_to_invalidate.append(f"cfg:effective:*:{key}") + elif level == 'tenant' and level_id: + # Tenant changes affect users in that tenant + patterns_to_invalidate.append(f"cfg:effective:{level_id}:*:{key}") + elif level == 'department' and level_id: + # Department changes affect users in that department + tenant_id, department = level_id.split(':', 1) + patterns_to_invalidate.append(f"cfg:effective:{tenant_id}:{department}:*:{key}") + elif level == 'group' and level_id: + # Group changes affect users in that group - broader invalidation needed + patterns_to_invalidate.append(f"cfg:effective:*:{key}") # Conservative approach + elif level == 'user' and level_id: + # User changes only affect that specific user + patterns_to_invalidate.append(f"cfg:effective:*:{level_id}:{key}") + + # Invalidate all relevant patterns + for pattern in patterns_to_invalidate: + await self.cache_manager.delete_pattern(pattern) + diff --git a/api/app/main.py b/api/app/main.py index 1868224e..7397d7b3 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -1070,6 +1070,55 @@ async def admin_config_v4_flush_cache(scope: Optional[str] = Query(default="*")) raise HTTPException(500, detail=str(ex)) +# PR17: Configuration Write Models +class V4ConfigSetIn(BaseModel): + key: str + value: Any + level: str # 'global', 'tenant', 'department', 'group', 'user' + level_id: Optional[str] = None # Required for non-global levels + reason: Optional[str] = None + +class V4ConfigSetOut(BaseModel): + key: str + value: Any + source: str + level: str + level_id: Optional[str] + encrypted: bool + updated_at: str + + +@app.post("/admin/config/v4/set", dependencies=[Depends(require_admin)]) +async def admin_config_v4_set(payload: V4ConfigSetIn, request: Request): + """Set configuration value at specified level (safe keys only).""" + hc: HierarchicalConfigProvider = getattr(app.state, "hierarchical_config", None) # type: ignore[assignment] + if not hc: + raise HTTPException(500, detail="hierarchical config not initialized") + + # Get client info for audit trail + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('user-agent', '') + + try: + result = await hc.set( + key=payload.key, + value=payload.value, + level=payload.level, # type: ignore + level_id=payload.level_id, + changed_by="admin_user", # TODO: Get from auth context + reason=payload.reason, + ip_address=client_ip, + user_agent=user_agent + ) + + return V4ConfigSetOut(**result) + + except ValueError as e: + raise HTTPException(400, detail=str(e)) + except Exception as e: + raise HTTPException(500, detail=f"Failed to set configuration: {str(e)}") + + class ProviderTestOut(BaseModel): success: bool message: str diff --git a/api/requirements.txt b/api/requirements.txt index 0905a1b5..4dab3e3e 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -21,4 +21,4 @@ qrcode==7.4.2 Pillow==10.4.0 cryptography==43.0.3 redis==5.2.1 -sse-starlette==2.2.1 +sse-starlette==1.8.2