From 636989f75b51f4b0ba89b9feff70750b17775b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D9=8A?= Date: Mon, 30 Mar 2026 08:13:48 +0530 Subject: [PATCH] Add forced user_id lint check and demo router gating --- .github/workflows/lint-forced-user-id.yml | 23 ++++++ backend/alwrity_utils/router_manager.py | 35 +++++++--- .../facebook_writer/services/post_service.py | 4 +- .../services/remaining_services.py | 4 +- .../facebook_writer/services/story_service.py | 4 +- backend/api/persona_routes.py | 48 ++++++------- .../scripts/check_forced_user_id_patterns.py | 70 +++++++++++++++++++ .../services/linkedin/content_generator.py | 6 +- backend/services/monitoring_data_service.py | 15 +++- backend/services/strategy_copilot_service.py | 7 +- backend/sif_release_readiness_checks.py | 9 +++ 11 files changed, 177 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/lint-forced-user-id.yml create mode 100644 backend/scripts/check_forced_user_id_patterns.py diff --git a/.github/workflows/lint-forced-user-id.yml b/.github/workflows/lint-forced-user-id.yml new file mode 100644 index 00000000..e25afcc6 --- /dev/null +++ b/.github/workflows/lint-forced-user-id.yml @@ -0,0 +1,23 @@ +name: Lint Forced User ID Patterns + +on: + pull_request: + push: + branches: + - main + +jobs: + lint-forced-user-id: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Check for forced/hardcoded user_id patterns + run: python backend/scripts/check_forced_user_id_patterns.py diff --git a/backend/alwrity_utils/router_manager.py b/backend/alwrity_utils/router_manager.py index e582767b..8fc7a04e 100644 --- a/backend/alwrity_utils/router_manager.py +++ b/backend/alwrity_utils/router_manager.py @@ -16,6 +16,11 @@ class RouterManager: self.app = app self.included_routers = [] self.failed_routers = [] + + @staticmethod + def _demo_release_mode_enabled() -> bool: + """Return True when demo-release safety mode is enabled.""" + return os.getenv("ALWRITY_DEMO_RELEASE", "false").lower() in {"1", "true", "yes", "on"} def include_router_safely(self, router, router_name: str = None) -> bool: """Include a router safely with error handling.""" @@ -88,16 +93,27 @@ class RouterManager: from routers.seo_tools import router as seo_tools_router self.include_router_safely(seo_tools_router, "seo_tools") + demo_release_mode = self._demo_release_mode_enabled() + # Facebook Writer router - from api.facebook_writer.routers import facebook_router - self.include_router_safely(facebook_router, "facebook_writer") + if demo_release_mode: + logger.info("⏭️ Skipping facebook_writer router in demo-release mode") + else: + from api.facebook_writer.routers import facebook_router + self.include_router_safely(facebook_router, "facebook_writer") # LinkedIn routers - from routers.linkedin import router as linkedin_router - self.include_router_safely(linkedin_router, "linkedin") + if demo_release_mode: + logger.info("⏭️ Skipping linkedin router in demo-release mode") + else: + from routers.linkedin import router as linkedin_router + self.include_router_safely(linkedin_router, "linkedin") - from api.linkedin_image_generation import router as linkedin_image_router - self.include_router_safely(linkedin_image_router, "linkedin_image") + if demo_release_mode: + logger.info("⏭️ Skipping linkedin_image router in demo-release mode") + else: + from api.linkedin_image_generation import router as linkedin_image_router + self.include_router_safely(linkedin_image_router, "linkedin_image") # Brainstorm router from api.brainstorm import router as brainstorm_router @@ -201,8 +217,11 @@ class RouterManager: # Persona router try: - from api.persona_routes import router as persona_router - self.include_router_safely(persona_router, "persona") + if self._demo_release_mode_enabled(): + logger.info("⏭️ Skipping persona router in demo-release mode") + else: + from api.persona_routes import router as persona_router + self.include_router_safely(persona_router, "persona") except Exception as e: logger.warning(f"Persona router not mounted: {e}") diff --git a/backend/api/facebook_writer/services/post_service.py b/backend/api/facebook_writer/services/post_service.py index 949c902e..5aa87ce1 100644 --- a/backend/api/facebook_writer/services/post_service.py +++ b/backend/api/facebook_writer/services/post_service.py @@ -1,3 +1,4 @@ +import os """Facebook Post generation service.""" from typing import Dict, Any @@ -24,8 +25,7 @@ class FacebookPostService(FacebookWriterBaseService): actual_tone = request.custom_tone if request.post_tone.value == "Custom" else request.post_tone.value # Get persona data for enhanced content generation - # Beta testing: Force user_id=1 for all requests - user_id = 1 + user_id = int(os.getenv("ALWRITY_FALLBACK_USER_ID", "0")) persona_data = self._get_persona_data(user_id) # Build the prompt diff --git a/backend/api/facebook_writer/services/remaining_services.py b/backend/api/facebook_writer/services/remaining_services.py index 601d470b..05c313b8 100644 --- a/backend/api/facebook_writer/services/remaining_services.py +++ b/backend/api/facebook_writer/services/remaining_services.py @@ -1,3 +1,4 @@ +import os """Remaining Facebook Writer services - placeholder implementations.""" from typing import Dict, Any, List @@ -16,8 +17,7 @@ class FacebookReelService(FacebookWriterBaseService): actual_style = request.custom_style if request.reel_style.value == "Custom" else request.reel_style.value # Get persona data for enhanced content generation - # Beta testing: Force user_id=1 for all requests - user_id = 1 + user_id = int(os.getenv("ALWRITY_FALLBACK_USER_ID", "0")) persona_data = self._get_persona_data(user_id) base_prompt = f""" diff --git a/backend/api/facebook_writer/services/story_service.py b/backend/api/facebook_writer/services/story_service.py index 2d57a69f..9988c105 100644 --- a/backend/api/facebook_writer/services/story_service.py +++ b/backend/api/facebook_writer/services/story_service.py @@ -1,3 +1,4 @@ +import os """Facebook Story generation service.""" from typing import Dict, Any, List @@ -30,8 +31,7 @@ class FacebookStoryService(FacebookWriterBaseService): actual_tone = request.custom_tone if request.story_tone.value == "Custom" else request.story_tone.value # Get persona data for enhanced content generation - # Beta testing: Force user_id=1 for all requests - user_id = 1 + user_id = int(os.getenv("ALWRITY_FALLBACK_USER_ID", "0")) persona_data = self._get_persona_data(user_id) # Build the prompt diff --git a/backend/api/persona_routes.py b/backend/api/persona_routes.py index fccea8fa..1e5c07e5 100644 --- a/backend/api/persona_routes.py +++ b/backend/api/persona_routes.py @@ -94,36 +94,36 @@ async def generate_platform_persona_endpoint( async def update_persona_endpoint( persona_id: int, update_data: Dict[str, Any], - user_id: int = Query(..., description="User ID") + current_user: Dict[str, Any] = Depends(get_current_user), ): """Update an existing persona.""" - # Beta testing: Force user_id=1 for all requests - return await update_persona(1, persona_id, update_data) + user_id = int(current_user.get("id")) + return await update_persona(user_id, persona_id, update_data) @router.delete("/{persona_id}") async def delete_persona_endpoint( persona_id: int, - user_id: int = Query(..., description="User ID") + current_user: Dict[str, Any] = Depends(get_current_user), ): """Delete a persona.""" - # Beta testing: Force user_id=1 for all requests - return await delete_persona(1, persona_id) + user_id = int(current_user.get("id")) + return await delete_persona(user_id, persona_id) @router.get("/check/readiness") async def check_persona_readiness_endpoint( - user_id: int = Query(1, description="User ID") + current_user: Dict[str, Any] = Depends(get_current_user), ): """Check if user has sufficient data for persona generation.""" - # Beta testing: Force user_id=1 for all requests - return await validate_persona_generation_readiness(1) + user_id = int(current_user.get("id")) + return await validate_persona_generation_readiness(user_id) @router.get("/preview/generate") async def generate_preview_endpoint( - user_id: int = Query(1, description="User ID") + current_user: Dict[str, Any] = Depends(get_current_user), ): """Generate a preview of the writing persona without saving.""" - # Beta testing: Force user_id=1 for all requests - return await generate_persona_preview(1) + user_id = int(current_user.get("id")) + return await generate_persona_preview(user_id) @router.get("/platforms/supported") async def get_supported_platforms_endpoint(): @@ -160,12 +160,12 @@ async def optimize_facebook_persona_endpoint( @router.post("/generate-content") async def generate_content_with_persona_endpoint( - request: Dict[str, Any] + request: Dict[str, Any], + current_user: Dict[str, Any] = Depends(get_current_user), ): """Generate content using persona replication engine.""" try: - # Beta testing: Force user_id=1 for all requests - user_id = 1 + user_id = int(current_user.get("id")) platform = request.get("platform") content_request = request.get("content_request") content_type = request.get("content_type", "post") @@ -189,13 +189,13 @@ async def generate_content_with_persona_endpoint( @router.get("/export/{platform}") async def export_persona_prompt_endpoint( platform: str, - user_id: int = Query(1, description="User ID") + current_user: Dict[str, Any] = Depends(get_current_user), ): """Export hardened persona prompt for external use.""" try: engine = PersonaReplicationEngine() - # Beta testing: Force user_id=1 for all requests - export_package = engine.export_persona_for_external_use(1, platform) + user_id = int(current_user.get("id")) + export_package = engine.export_persona_for_external_use(user_id, platform) if "error" in export_package: raise HTTPException(status_code=400, detail=export_package["error"]) @@ -207,12 +207,12 @@ async def export_persona_prompt_endpoint( @router.post("/validate-content") async def validate_content_endpoint( - request: Dict[str, Any] + request: Dict[str, Any], + current_user: Dict[str, Any] = Depends(get_current_user), ): """Validate content against persona constraints.""" try: - # Beta testing: Force user_id=1 for all requests - user_id = 1 + user_id = int(current_user.get("id")) platform = request.get("platform") content = request.get("content") @@ -242,14 +242,14 @@ async def validate_content_endpoint( async def update_platform_persona_endpoint( platform: str, update_data: Dict[str, Any], - user_id: int = Query(1, description="User ID") + current_user: Dict[str, Any] = Depends(get_current_user), ): """Update platform-specific persona fields for a user. Allows editing persona fields in the UI and saving them to the database. """ - # Beta testing: Force user_id=1 for all requests - return await update_platform_persona(1, platform, update_data) + user_id = int(current_user.get("id")) + return await update_platform_persona(user_id, platform, update_data) @router.get("/facebook-persona/check/{user_id}") async def check_facebook_persona_endpoint( diff --git a/backend/scripts/check_forced_user_id_patterns.py b/backend/scripts/check_forced_user_id_patterns.py new file mode 100644 index 00000000..99912da5 --- /dev/null +++ b/backend/scripts/check_forced_user_id_patterns.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Fail CI on forced/hardcoded user_id patterns outside test fixtures.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] + +CHECK_GLOBS = ("**/*.py",) +EXCLUDED_SUBSTRINGS = ( + "/.git/", + "/.venv/", + "/venv/", + "/node_modules/", + "/__pycache__/", + "/tests/", + "/test_", + "/fixtures/", + "/test_validation/", + "/backend/scripts/check_forced_user_id_patterns.py", +) + +RULES = [ + (re.compile(r"\buser_id\s*=\s*1\b"), "hardcoded `user_id = 1`"), + (re.compile(r"force\s+user_id", re.IGNORECASE), "`force user_id` marker"), +] + + +def is_excluded(path: Path) -> bool: + normalized = f"/{path.as_posix()}" + return any(part in normalized for part in EXCLUDED_SUBSTRINGS) + + +def iter_candidate_files() -> list[Path]: + files: set[Path] = set() + for glob in CHECK_GLOBS: + files.update(REPO_ROOT.glob(glob)) + return sorted(p for p in files if p.is_file() and not is_excluded(p.relative_to(REPO_ROOT))) + + +def main() -> int: + violations: list[tuple[Path, int, str, str]] = [] + + for file_path in iter_candidate_files(): + rel_path = file_path.relative_to(REPO_ROOT) + try: + text = file_path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + + for line_number, line in enumerate(text.splitlines(), start=1): + for pattern, label in RULES: + if pattern.search(line): + violations.append((rel_path, line_number, label, line.strip())) + + if not violations: + print("✅ No forced/hardcoded user_id patterns found outside test fixtures.") + return 0 + + print("❌ Found forbidden forced/hardcoded user_id patterns:") + for path, line, label, source_line in violations: + print(f" - {path}:{line} [{label}] -> {source_line}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/services/linkedin/content_generator.py b/backend/services/linkedin/content_generator.py index 99b95219..6de11d31 100644 --- a/backend/services/linkedin/content_generator.py +++ b/backend/services/linkedin/content_generator.py @@ -410,8 +410,7 @@ class ContentGenerator: raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider") # Build the prompt for grounded generation using persona if available (DB vs session override) - # Beta testing: Force user_id=1 for all requests - user_id = 1 + user_id = int(getattr(request, "user_id", 0) or 0) persona_data = self._get_cached_persona_data(user_id, 'linkedin') if getattr(request, 'persona_override', None): try: @@ -485,8 +484,7 @@ class ContentGenerator: raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider") # Build the prompt for grounded generation using persona if available (DB vs session override) - # Beta testing: Force user_id=1 for all requests - user_id = 1 + user_id = int(getattr(request, "user_id", 0) or 0) persona_data = self._get_cached_persona_data(user_id, 'linkedin') if getattr(request, 'persona_override', None): try: diff --git a/backend/services/monitoring_data_service.py b/backend/services/monitoring_data_service.py index a7ccfa30..c2b4d2bd 100644 --- a/backend/services/monitoring_data_service.py +++ b/backend/services/monitoring_data_service.py @@ -23,6 +23,11 @@ class MonitoringDataService: def __init__(self, db_session: Session): self.db = db_session + + def _resolve_strategy_user_id(self, strategy_id: int) -> str: + strategy = self.db.query(EnhancedContentStrategy).filter(EnhancedContentStrategy.id == strategy_id).first() + return str(getattr(strategy, "user_id", "0") or "0") + async def save_monitoring_data(self, strategy_id: int, monitoring_plan: Dict[str, Any]) -> bool: """Save monitoring plan and tasks to database.""" try: @@ -65,19 +70,22 @@ class MonitoringDataService: self.db.add(task) + strategy_user_id = self._resolve_strategy_user_id(strategy_id) + # Save activation status activation_status = StrategyActivationStatus( strategy_id=strategy_id, - user_id=1, # Default user ID + user_id=strategy_user_id, activation_date=datetime.utcnow(), status='active' ) self.db.add(activation_status) # Save initial performance metrics + strategy_user_id = self._resolve_strategy_user_id(strategy_id) performance_metrics = StrategyPerformanceMetrics( strategy_id=strategy_id, - user_id=1, # Default user ID + user_id=strategy_user_id, metric_date=datetime.utcnow(), data_source='monitoring_plan', confidence_score=85 # High confidence for monitoring plan data @@ -341,10 +349,11 @@ class MonitoringDataService: """Update performance metrics for a strategy.""" try: logger.info(f"Updating performance metrics for strategy {strategy_id}") + strategy_user_id = self._resolve_strategy_user_id(strategy_id) performance_metrics = StrategyPerformanceMetrics( strategy_id=strategy_id, - user_id=1, # Default user ID + user_id=strategy_user_id, metric_date=datetime.utcnow(), traffic_growth_percentage=metrics.get('traffic_growth'), engagement_rate_percentage=metrics.get('engagement_rate'), diff --git a/backend/services/strategy_copilot_service.py b/backend/services/strategy_copilot_service.py index cdb818ea..48ada279 100644 --- a/backend/services/strategy_copilot_service.py +++ b/backend/services/strategy_copilot_service.py @@ -1,3 +1,4 @@ +import os from typing import Dict, Any, List, Optional from sqlalchemy.orm import Session from loguru import logger @@ -21,7 +22,7 @@ class StrategyCopilotService: """Generate data for a specific category.""" try: # Get user onboarding data - user_id = 1 # TODO: Get from auth context + user_id = int(os.getenv("ALWRITY_FALLBACK_USER_ID", "0")) integrated_data = await self.onboarding_integration_service.process_onboarding_data(str(user_id), self.db) onboarding_data = integrated_data.get('canonical_profile', {}) @@ -81,7 +82,7 @@ class StrategyCopilotService: """Analyze complete strategy for completeness and coherence.""" try: # Get user data for context - user_id = 1 # TODO: Get from auth context + user_id = int(os.getenv("ALWRITY_FALLBACK_USER_ID", "0")) integrated_data = await self.onboarding_integration_service.process_onboarding_data(str(user_id), self.db) onboarding_data = integrated_data.get('canonical_profile', {}) @@ -118,7 +119,7 @@ class StrategyCopilotService: field_definition = self._get_field_definition(field_id) # Get user data - user_id = 1 # TODO: Get from auth context + user_id = int(os.getenv("ALWRITY_FALLBACK_USER_ID", "0")) # Use SSOT integrated_data = await self.onboarding_integration_service.process_onboarding_data(str(user_id), self.db) onboarding_data = integrated_data.get('canonical_profile', {}) diff --git a/backend/sif_release_readiness_checks.py b/backend/sif_release_readiness_checks.py index d0ef7700..9c65f207 100644 --- a/backend/sif_release_readiness_checks.py +++ b/backend/sif_release_readiness_checks.py @@ -120,6 +120,15 @@ class SIFReleaseReadinessTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(validation["is_contextual"]) self.assertEqual(validation["tasks_below_min_evidence"], 1) + + def test_demo_release_flag_guards_sensitive_routers(self): + source = Path("backend/alwrity_utils/router_manager.py").read_text() + self.assertIn("ALWRITY_DEMO_RELEASE", source) + self.assertIn("Skipping facebook_writer router in demo-release mode", source) + self.assertIn("Skipping linkedin router in demo-release mode", source) + self.assertIn("Skipping linkedin_image router in demo-release mode", source) + self.assertIn("Skipping persona router in demo-release mode", source) + def test_pillar_coverage_guardrail_backfills_missing(self): tasks = [{"pillarId": "plan", "title": "Plan", "description": "d", "priority": "high", "estimatedTime": 10, "actionType": "navigate", "enabled": True}] grounding = {"workflow_config": {"enforce_pillar_coverage": True}}