diff --git a/.env.example b/.env.example index 4e2b09d..174dd86 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,10 @@ # ========================================== # [必填] 登入密碼(弱密碼會被 LoginManager 拒絕) LOGIN_PASSWORD=your_strong_password_here +# [預設 auto] auto/shadow/hybrid/database;auto 在兩次 durable admin DB 登入後自動退役共享密碼 +MOMO_AUTH_IDENTITY_MODE=auto +# [預設] 只有列入 CIDR 的反向代理可提供 X-Forwarded-For / X-Real-IP +MOMO_TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128,172.19.0.1/32 # [必填] Flask session 簽章密鑰(建議 openssl rand -hex 32) SECRET_KEY=your_flask_secret_key_here @@ -594,6 +598,8 @@ KUBERNETES_SERVICE_HOST= CICD_PROD_BASE_URL=https://mo.wooo.work CICD_UAT_BASE_URL=https://uat.mo.wooo.work SCHEDULER_STATS_PATH=/app/data/scheduler_stats.json +# [預設 1800] 獨立收益 watchdog 週期;避免長任務阻塞業績匯入與比價回填 +REVENUE_AUTOMATION_WATCHDOG_SECONDS=1800 DAILY_SALES_IMPORT_MAX_STALE_DAYS=3 DAILY_SALES_IMPORT_STALENESS_GUARD_ENABLED=true diff --git a/.gitignore b/.gitignore index c09a6ea..eaf857f 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,7 @@ docker-compose.override.yml # CD-generated governance receipts (deployed evidence, not source) governance/security_governance_source_receipt.json governance/security_governance_runtime_receipt.json +governance/auth_identity_runtime_receipt.json # SSL 憑證 ssl/ diff --git a/auth.py b/auth.py index 88d4c6f..7a3d885 100644 --- a/auth.py +++ b/auth.py @@ -1,400 +1,589 @@ +"""Authentication, durable lockout, database identity and session governance.""" + +from __future__ import annotations + +from datetime import datetime +from functools import wraps import hmac import os -import time -from flask import jsonify, render_template, redirect, url_for, request, session, flash -from functools import wraps -from werkzeug.security import check_password_hash -from config import LOGIN_PASSWORD -from datetime import datetime, timedelta -# ========================================== -# 🔓 登入功能開關 -# ========================================== -# 登入繞過只允許測試環境明確開啟,避免 production 因單一 env 誤設而全站失守。 -_DISABLE_LOGIN_REQUESTED = os.getenv('DISABLE_LOGIN', 'false').lower() == 'true' +from flask import ( + current_app, + flash, + g, + has_request_context, + jsonify, + redirect, + render_template, + request, + session, + url_for, +) +from werkzeug.routing import BuildError +from werkzeug.security import check_password_hash + +from config import LOGIN_PASSWORD +from database.user_models import LoginHistory +from services.auth_identity_service import ( + KNOWN_ROLES, + auth_auto_cutover_min_successes, + database_auth_can_authorize, + database_auth_is_evaluated, + identity_version, + legacy_auth_can_authorize, + resolve_auth_identity_mode, + resolve_effective_auth_identity_mode, + resolve_client_ip, + role_is_allowed, +) + + +def _bounded_int(name: str, default: int, minimum: int, maximum: int) -> int: + try: + value = int(os.getenv(name, str(default))) + except (TypeError, ValueError): + value = default + return max(minimum, min(value, maximum)) + + +_DISABLE_LOGIN_REQUESTED = os.getenv("DISABLE_LOGIN", "false").lower() == "true" _ALLOW_INSECURE_LOGIN_BYPASS = ( - os.getenv('MOMO_ALLOW_INSECURE_CONFIG_FOR_TESTS', 'false').lower() == 'true' + os.getenv("MOMO_ALLOW_INSECURE_CONFIG_FOR_TESTS", "false").lower() == "true" ) DISABLE_LOGIN = _DISABLE_LOGIN_REQUESTED and _ALLOW_INSECURE_LOGIN_BYPASS +MAX_LOGIN_ATTEMPTS = _bounded_int("MOMO_AUTH_MAX_ATTEMPTS", 5, 3, 20) +LOCKOUT_DURATION = _bounded_int("MOMO_AUTH_LOCKOUT_SECONDS", 300, 30, 86400) +ATTEMPT_RESET_TIME = _bounded_int("MOMO_AUTH_ATTEMPT_WINDOW_SECONDS", 1800, 60, 86400) + if DISABLE_LOGIN: - print("⚠️ 警告:登入驗證已關閉 (DISABLE_LOGIN=true)") + print("[Security] 登入驗證僅於明確測試模式停用") elif _DISABLE_LOGIN_REQUESTED: print("[Security] 已拒絕 DISABLE_LOGIN:僅測試環境可使用登入繞過") -# ========================================== -# 🔒 登入失敗追蹤與帳號鎖定機制 -# ========================================== -# 登入失敗記錄(IP-based) -# 格式:{ip: {'count': 失敗次數, 'locked_until': 鎖定到期時間, 'first_attempt': 首次失敗時間}} -LOGIN_ATTEMPTS = {} +def get_identity_session(): + """Late-bind the database session so tests and workers share one seam.""" + from database.manager import get_session -# 鎖定設定 -MAX_LOGIN_ATTEMPTS = 5 # 最多失敗次數 -LOCKOUT_DURATION = 300 # 鎖定時長(秒)= 5 分鐘 -ATTEMPT_RESET_TIME = 1800 # 失敗記錄重置時間(秒)= 30 分鐘 + return get_session() -def get_client_ip(): - """ - 取得客戶端 IP 位址(支援代理伺服器) - """ - if request.headers.get('X-Forwarded-For'): - # 如果通過代理,取第一個 IP - return request.headers.get('X-Forwarded-For').split(',')[0].strip() - elif request.headers.get('X-Real-IP'): - return request.headers.get('X-Real-IP') - else: - return request.remote_addr -def is_ip_locked(ip): - """ - 檢查 IP 是否被鎖定 +def get_client_ip() -> str: + return resolve_client_ip( + request.remote_addr, + request.headers.get("X-Forwarded-For"), + request.headers.get("X-Real-IP"), + ) - Returns: - tuple: (is_locked, remaining_seconds) - """ - if ip not in LOGIN_ATTEMPTS: - return False, 0 - attempt_data = LOGIN_ATTEMPTS[ip] +def _user_agent() -> str: + return (request.headers.get("User-Agent") or "")[:256] - # 檢查是否已鎖定 - if 'locked_until' in attempt_data: - locked_until = attempt_data['locked_until'] - current_time = time.time() - if current_time < locked_until: - # 仍在鎖定期間 - remaining = int(locked_until - current_time) - return True, remaining - else: - # 鎖定期已過,清除記錄 - del LOGIN_ATTEMPTS[ip] - return False, 0 +def _lockout_status(username: str | None, client_ip: str) -> tuple[bool, int, int]: + from services.user_service import UserService - return False, 0 + db_session = get_identity_session() + try: + return UserService(db_session).get_lockout_status( + username=username, + ip_address=client_ip, + max_attempts=MAX_LOGIN_ATTEMPTS, + reset_seconds=ATTEMPT_RESET_TIME, + lockout_seconds=LOCKOUT_DURATION, + ) + finally: + db_session.close() -def record_login_failure(ip): - """ - 記錄登入失敗,並檢查是否需要鎖定 - Returns: - bool: 是否已達到鎖定條件 - """ - current_time = time.time() +def is_ip_locked(ip: str) -> tuple[bool, int]: + """Compatibility wrapper backed by durable login_history state.""" + locked, remaining, _ = _lockout_status(None, ip) + return locked, remaining - if ip not in LOGIN_ATTEMPTS: - LOGIN_ATTEMPTS[ip] = { - 'count': 1, - 'first_attempt': current_time - } - return False - attempt_data = LOGIN_ATTEMPTS[ip] +def record_login_failure(ip: str) -> bool: + """Compatibility wrapper; new login flow records username and audit detail.""" + from services.user_service import UserService - # 檢查是否超過重置時間(30分鐘無活動則重置計數) - if current_time - attempt_data.get('first_attempt', 0) > ATTEMPT_RESET_TIME: - LOGIN_ATTEMPTS[ip] = { - 'count': 1, - 'first_attempt': current_time - } - return False + db_session = get_identity_session() + try: + service = UserService(db_session) + recorded = service.record_login( + None, + "legacy-shared", + ip, + _user_agent() if has_request_context() else None, + LoginHistory.STATUS_FAILED, + "invalid_credentials", + ) + if recorded is None: + return True + locked, _, _ = service.get_lockout_status( + username="legacy-shared", + ip_address=ip, + max_attempts=MAX_LOGIN_ATTEMPTS, + reset_seconds=ATTEMPT_RESET_TIME, + lockout_seconds=LOCKOUT_DURATION, + ) + return locked + finally: + db_session.close() - # 增加失敗次數 - attempt_data['count'] += 1 - # 檢查是否達到鎖定條件 - if attempt_data['count'] >= MAX_LOGIN_ATTEMPTS: - attempt_data['locked_until'] = current_time + LOCKOUT_DURATION - return True +def clear_login_attempts(_ip: str) -> None: + """A durable success event supersedes older failures; no delete is required.""" - return False - -def clear_login_attempts(ip): - """ - 清除登入失敗記錄(登入成功時調用) - """ - if ip in LOGIN_ATTEMPTS: - del LOGIN_ATTEMPTS[ip] def validate_password_strength(password): - """ - 驗證密碼強度 - - 要求: - - 至少 8 個字元 - - 包含英文字母 - - 包含數字 - - Returns: - tuple: (is_valid, error_message) - """ if not password: return False, "密碼不能為空" - if len(password) < 8: return False, "密碼長度至少需要 8 個字元" - - has_letter = any(c.isalpha() for c in password) - has_digit = any(c.isdigit() for c in password) - - if not has_letter: + if not any(c.isalpha() for c in password): return False, "密碼必須包含英文字母" - - if not has_digit: + if not any(c.isdigit() for c in password): return False, "密碼必須包含數字" - return True, None -# ========================================== -# 權限驗證裝飾器 -# ========================================== + +def _legacy_password_matches(input_password: str) -> bool: + if not LOGIN_PASSWORD: + return False + if LOGIN_PASSWORD.startswith(("pbkdf2:", "scrypt:")): + return check_password_hash(LOGIN_PASSWORD, input_password) + return hmac.compare_digest(input_password, LOGIN_PASSWORD) + + +def _effective_auth_identity_mode(service, configured_mode: str) -> str: + if configured_mode != "auto": + return configured_mode + cutover = service.get_auth_auto_cutover_state( + min_successes=auth_auto_cutover_min_successes() + ) + return resolve_effective_auth_identity_mode( + configured_mode, + auto_cutover_ready=cutover["ready"], + ) + + +def _audit_session_event(status: str, reason: str) -> None: + """Best-effort audit for logout/denial; login itself remains fail-closed.""" + from services.user_service import UserService + + db_session = None + try: + db_session = get_identity_session() + UserService(db_session).record_login( + session.get("user_id"), + session.get("username") or "legacy-shared", + get_client_ip(), + _user_agent(), + status, + reason, + ) + except Exception as exc: + print(f"[Security] audit event unavailable: {type(exc).__name__}") + finally: + if db_session is not None: + db_session.close() + + +def _session_state(required_roles=()) -> dict: + cached = getattr(g, "_ewoooc_auth_session_state", None) + if cached is not None and ( + not required_roles or role_is_allowed(cached.get("role"), required_roles) + ): + return cached + + if DISABLE_LOGIN: + state = {"ok": True, "reason": "test_bypass", "role": "admin"} + g._ewoooc_auth_session_state = state + return state + if not session.get("logged_in"): + return {"ok": False, "reason": "authentication_required", "role": None} + + try: + configured_mode = resolve_auth_identity_mode() + except ValueError: + return {"ok": False, "reason": "identity_policy_invalid", "role": None} + + auth_source = session.get("auth_source") + mode = configured_mode + if configured_mode == "auto" and auth_source == "legacy_shared_password": + from services.user_service import UserService + + db_session = None + try: + db_session = get_identity_session() + mode = _effective_auth_identity_mode( + UserService(db_session), configured_mode + ) + except Exception as exc: + print(f"[Security] auto cutover validation unavailable: {type(exc).__name__}") + return {"ok": False, "reason": "identity_store_unavailable", "role": None} + finally: + if db_session is not None: + db_session.close() + role = session.get("role") + if auth_source == "database": + from services.user_service import UserService + + db_session = None + revoke_reason = None + try: + db_session = get_identity_session() + user = UserService(db_session).get_user_by_id(session.get("user_id")) + if not user or not user.is_active or user.role not in KNOWN_ROLES: + revoke_reason = "identity_inactive_or_missing" + elif session.get("identity_version") != identity_version(user): + revoke_reason = "identity_version_changed" + else: + role = user.role + session["role"] = user.role + session["username"] = user.username + except Exception as exc: + print(f"[Security] identity validation unavailable: {type(exc).__name__}") + return {"ok": False, "reason": "identity_store_unavailable", "role": None} + finally: + if db_session is not None: + db_session.close() + if revoke_reason: + _audit_session_event(LoginHistory.STATUS_REVOKED, revoke_reason) + session.clear() + return {"ok": False, "reason": "session_revoked", "role": None} + elif auth_source == "legacy_shared_password": + if not legacy_auth_can_authorize(mode) or role != "admin": + session.clear() + return {"ok": False, "reason": "legacy_session_revoked", "role": None} + elif current_app.testing: + # Legacy fixtures may exercise business routes without constructing a + # production identity cookie. This branch is impossible in production. + role = session.get("role") or "admin" + if role not in KNOWN_ROLES: + session.clear() + return {"ok": False, "reason": "unversioned_session", "role": None} + else: + # Pre-policy cookies never inherit admin privileges from a missing field. + session.clear() + return {"ok": False, "reason": "unversioned_session", "role": None} + + if required_roles and not role_is_allowed(role, required_roles): + _audit_session_event(LoginHistory.STATUS_DENIED, "role_not_allowed") + return {"ok": False, "reason": "authorization_denied", "role": role} + + state = {"ok": True, "reason": "authenticated", "role": role} + g._ewoooc_auth_session_state = state + return state + + +def _is_api_request() -> bool: + return request.path.startswith("/api/") or "/api/" in request.path + + +def _denied_response(state: dict): + reason = state.get("reason") or "authentication_required" + if reason == "identity_store_unavailable": + if _is_api_request(): + return jsonify({"success": False, "error": reason}), 503 + return render_template("login.html", error="身分服務暫時無法使用,請稍後再試。"), 503 + if reason == "authorization_denied": + if _is_api_request(): + return jsonify({"success": False, "error": reason}), 403 + flash("您沒有權限執行此操作", "danger") + try: + return redirect(url_for("dashboard.index")) + except BuildError: + return redirect(url_for("login")) + if _is_api_request(): + return jsonify({ + "success": False, + "error": "authentication_required", + "message": "此端點需要登入。", + }), 401 + return redirect(url_for("login")) + def login_required(f): - """ - 權限驗證裝飾器:確保使用者必須登入才能存取特定路由。 - - 若環境變數 DISABLE_LOGIN=true,則跳過驗證直接放行。 - """ @wraps(f) def decorated_view(*args, **kwargs): - # 如果登入功能已關閉,直接放行 - if DISABLE_LOGIN: - return f(*args, **kwargs) - - # 檢查 session 中是否有登入標記 - if not session.get('logged_in'): - # 如果未登入,跳轉至登入頁面 - return redirect(url_for('login')) + state = _session_state() + if not state["ok"]: + return _denied_response(state) return f(*args, **kwargs) + return decorated_view -def require_authenticated_request(): - """供中央 HTTP policy 使用;API 回 401,頁面導向登入。""" - if DISABLE_LOGIN or session.get('logged_in'): +def require_authenticated_request(*required_roles): + """Central policy hook with API/page aware denial semantics.""" + state = _session_state(required_roles) + if state["ok"]: return None - - is_api_request = request.path.startswith('/api/') or '/api/' in request.path - if is_api_request: - return jsonify({ - 'success': False, - 'error': 'authentication_required', - 'message': '此端點需要登入。', - }), 401 - return redirect(url_for('login')) + return _denied_response(state) def internal_key_required(f): """Require the shared internal transport key for non-session service calls.""" @wraps(f) def decorated_view(*args, **kwargs): - expected = os.getenv('INTERNAL_API_KEY', '').strip() - provided = request.headers.get('X-Internal-Key', '').strip() + expected = os.getenv("INTERNAL_API_KEY", "").strip() + provided = request.headers.get("X-Internal-Key", "").strip() if not expected: - return jsonify({ - 'success': False, - 'error': 'internal_auth_not_configured', - }), 503 + return jsonify({"success": False, "error": "internal_auth_not_configured"}), 503 if not provided or not hmac.compare_digest(provided, expected): - return jsonify({ - 'success': False, - 'error': 'invalid_internal_key', - }), 401 + return jsonify({"success": False, "error": "invalid_internal_key"}), 401 return f(*args, **kwargs) + return decorated_view def get_current_user(): - """ - 取得目前登入的使用者資訊 - - Returns: - dict: 包含使用者資訊的字典,如果未登入則返回 None - """ - if not session.get('logged_in'): + state = _session_state() + if not state["ok"]: return None - return { - 'logged_in': True, - 'user_id': session.get('user_id'), - 'login_time': session.get('login_time'), - 'client_ip': session.get('client_ip'), - 'role': session.get('role', 'admin'), # 預設為 admin(向後兼容) - 'username': session.get('username', 'admin'), - 'auth_source': session.get('auth_source', 'legacy_shared_password'), + "logged_in": True, + "user_id": session.get("user_id"), + "login_time": session.get("login_time"), + "client_ip": session.get("client_ip"), + "role": state.get("role"), + "username": session.get("username"), + "auth_source": session.get("auth_source"), } def role_required(*roles): - """ - 角色權限驗證裝飾器:確保使用者具有指定角色之一 + unknown = set(roles) - KNOWN_ROLES + if unknown: + raise ValueError(f"Unknown roles: {', '.join(sorted(unknown))}") - Args: - *roles: 允許的角色列表,例如 'admin', 'manager', 'user' - - Usage: - @role_required('admin') - def admin_only_page(): - ... - - @role_required('admin', 'manager') - def admin_or_manager_page(): - ... - - 若環境變數 DISABLE_LOGIN=true,則跳過驗證直接放行。 - """ def decorator(f): @wraps(f) def decorated_view(*args, **kwargs): - # 如果登入功能已關閉,直接放行 - if DISABLE_LOGIN: - return f(*args, **kwargs) - - # 先檢查登入狀態 - if not session.get('logged_in'): - return redirect(url_for('login')) - - # 取得使用者角色(預設為 admin,向後兼容) - user_role = session.get('role', 'admin') - - # 檢查角色權限 - if user_role not in roles: - # 權限不足,返回 403 - flash('您沒有權限存取此頁面', 'danger') - return redirect(url_for('dashboard.index')) - + state = _session_state(roles) + if not state["ok"]: + return _denied_response(state) return f(*args, **kwargs) + return decorated_view + return decorator def admin_required(f): - """ - 管理員權限驗證裝飾器:只允許 admin 角色存取 + return role_required("admin")(f) - 這是 role_required('admin') 的便捷寫法 - 若環境變數 DISABLE_LOGIN=true,則跳過驗證直接放行。 - """ - @wraps(f) - def decorated_view(*args, **kwargs): - # 如果登入功能已關閉,直接放行 - if DISABLE_LOGIN: - return f(*args, **kwargs) +def _set_database_session(user, client_ip: str, password_change_required: bool) -> None: + session.clear() + session.permanent = True + session.update({ + "logged_in": True, + "login_time": datetime.now().isoformat(), + "client_ip": client_ip, + "user_id": user.id, + "role": user.role, + "username": user.username, + "auth_source": "database", + "identity_version": identity_version(user), + "password_change_required": bool(password_change_required), + }) - # 先檢查登入狀態 - if not session.get('logged_in'): - return redirect(url_for('login')) - # 取得使用者角色(預設為 admin,向後兼容) - user_role = session.get('role', 'admin') +def _set_legacy_session(client_ip: str) -> None: + session.clear() + session.permanent = True + session.update({ + "logged_in": True, + "login_time": datetime.now().isoformat(), + "client_ip": client_ip, + "role": "admin", + "username": "legacy-admin", + "auth_source": "legacy_shared_password", + }) - # 檢查是否為管理員 - if user_role != 'admin': - flash('此功能僅限管理員使用', 'danger') - return redirect(url_for('dashboard')) - - return f(*args, **kwargs) - return decorated_view - -# ========================================== -# 路由初始化 -# ========================================== def init_auth_routes(app): - """ - 初始化驗證相關路由:註冊 /login 與 /logout。 - """ + """Register database-first login and audited logout routes.""" - @app.route('/login', methods=['GET', 'POST']) + @app.route("/login", methods=["GET", "POST"]) def login(): + from services.user_service import UserService + client_ip = get_client_ip() + configured_mode = resolve_auth_identity_mode() + mode = configured_mode + username = (request.form.get("username") or "").strip() + lockout_identity = username or "legacy-shared" - if request.method == 'POST': - print(f"🔐 收到登入請求 | IP: {client_ip}") + db_session = None + try: + db_session = get_identity_session() + service = UserService(db_session) + mode = _effective_auth_identity_mode(service, configured_mode) + locked, remaining_seconds, failure_count = service.get_lockout_status( + username=lockout_identity, + ip_address=client_ip, + max_attempts=MAX_LOGIN_ATTEMPTS, + reset_seconds=ATTEMPT_RESET_TIME, + lockout_seconds=LOCKOUT_DURATION, + ) + except Exception as exc: + print(f"[Security] durable lockout unavailable: {type(exc).__name__}") + if db_session is not None: + db_session.close() + return render_template( + "login.html", + error="身分服務暫時無法使用,請稍後再試。", + auth_mode=mode, + username=username, + ), 503 - # 1. 檢查 IP 是否被鎖定 - is_locked, remaining_seconds = is_ip_locked(client_ip) - if is_locked: - minutes = remaining_seconds // 60 - seconds = remaining_seconds % 60 - error = f'登入失敗次數過多,帳號已鎖定。請在 {minutes} 分 {seconds} 秒後再試。' - print(f"⚠️ 登入嘗試被拒絕 | IP: {client_ip} | 原因: 帳號鎖定") - return render_template('login.html', error=error), 429 + try: + if locked: + minutes, seconds = divmod(remaining_seconds, 60) + error = f"登入嘗試已暫停,請在 {minutes} 分 {seconds} 秒後再試。" + return render_template( + "login.html", error=error, auth_mode=mode, username=username + ), 429 - # 2. 取得輸入的密碼 - input_password = request.form.get('password') + if request.method == "GET": + return render_template( + "login.html", error=None, auth_mode=mode, username=username + ) + input_password = request.form.get("password") or "" if not input_password: - error = '請輸入密碼' - return render_template('login.html', error=error), 400 + return render_template( + "login.html", + error="請輸入密碼", + auth_mode=mode, + username=username, + ), 400 - # 3. 驗證密碼 - # 注意:LOGIN_PASSWORD 可能是明文或雜湊值 - # 首先檢查是否為雜湊值(以 pbkdf2:sha256 開頭) - is_password_valid = False + db_user = None + password_change_required = False + if username and database_auth_is_evaluated(mode): + db_user, _, password_change_required = service.authenticate( + username, input_password + ) - if LOGIN_PASSWORD.startswith('pbkdf2:sha256:'): - # 使用雜湊比對 - is_password_valid = check_password_hash(LOGIN_PASSWORD, input_password) - else: - # 向後兼容:明文比對(僅用於過渡期) - is_password_valid = hmac.compare_digest(input_password, LOGIN_PASSWORD) - if is_password_valid: - print("⚠️ 警告:系統仍在使用明文密碼,請盡快執行密碼雜湊更新") + legacy_valid = ( + legacy_auth_can_authorize(mode) + and _legacy_password_matches(input_password) + ) + database_valid = bool( + db_user and database_auth_can_authorize(mode) + ) - # 4. 驗證結果處理 - if is_password_valid: - # 登入成功 - session.clear() - session.permanent = True - session['logged_in'] = True - session['login_time'] = datetime.now().isoformat() - session['client_ip'] = client_ip - session['role'] = 'admin' - session['username'] = 'legacy-admin' - session['auth_source'] = 'legacy_shared_password' + if mode == "shadow" and username: + shadow_event = service.record_login( + db_user.id if db_user else None, + username, + client_ip, + _user_agent(), + LoginHistory.STATUS_SHADOW, + "database_match" if db_user else "database_miss", + ) + if shadow_event is None: + return render_template( + "login.html", + error="登入稽核暫時無法使用,請稍後再試。", + auth_mode=mode, + username=username, + ), 503 - # 清除失敗記錄 - clear_login_attempts(client_ip) + if database_valid: + history = service.record_login( + db_user.id, + db_user.username, + client_ip, + _user_agent(), + LoginHistory.STATUS_SUCCESS, + "auth_source=database", + ) + if history is None: + return render_template( + "login.html", + error="登入稽核暫時無法使用,請稍後再試。", + auth_mode=mode, + username=username, + ), 503 + _set_database_session(db_user, client_ip, password_change_required) + return redirect(url_for("dashboard.index")) - print(f"✅ 登入成功 | IP: {client_ip}") - return redirect(url_for('dashboard.index')) - else: - # 登入失敗 - is_now_locked = record_login_failure(client_ip) + if legacy_valid: + history = service.record_login( + None, + "legacy-shared", + client_ip, + _user_agent(), + LoginHistory.STATUS_SUCCESS, + "auth_source=legacy_shared_password", + ) + if history is None: + return render_template( + "login.html", + error="登入稽核暫時無法使用,請稍後再試。", + auth_mode=mode, + username=username, + ), 503 + _set_legacy_session(client_ip) + return redirect(url_for("dashboard.index")) - if is_now_locked: - error = f'登入失敗次數過多,帳號已鎖定 {LOCKOUT_DURATION // 60} 分鐘。' - print(f"🔒 帳號已鎖定 | IP: {client_ip} | 原因: 連續 {MAX_LOGIN_ATTEMPTS} 次失敗") - else: - remaining_attempts = MAX_LOGIN_ATTEMPTS - LOGIN_ATTEMPTS.get(client_ip, {}).get('count', 0) - error = f'密碼錯誤,請重新輸入。(剩餘嘗試次數:{remaining_attempts})' - print(f"❌ 登入失敗 | IP: {client_ip} | 剩餘嘗試: {remaining_attempts}") + failure = service.record_login( + db_user.id if db_user else None, + lockout_identity, + client_ip, + _user_agent(), + LoginHistory.STATUS_FAILED, + "invalid_credentials", + ) + if failure is None: + return render_template( + "login.html", + error="登入稽核暫時無法使用,請稍後再試。", + auth_mode=mode, + username=username, + ), 503 - return render_template('login.html', error=error), 401 + now_locked, remaining_seconds, failure_count = service.get_lockout_status( + username=lockout_identity, + ip_address=client_ip, + max_attempts=MAX_LOGIN_ATTEMPTS, + reset_seconds=ATTEMPT_RESET_TIME, + lockout_seconds=LOCKOUT_DURATION, + ) + if now_locked: + service.record_login( + None, + lockout_identity, + client_ip, + _user_agent(), + LoginHistory.STATUS_LOCKED, + "max_attempts_reached", + ) + error = f"登入嘗試已暫停 {LOCKOUT_DURATION // 60} 分鐘。" + return render_template( + "login.html", error=error, auth_mode=mode, username=username + ), 429 - # GET 請求:檢查是否被鎖定 - is_locked, remaining_seconds = is_ip_locked(client_ip) - if is_locked: - minutes = remaining_seconds // 60 - seconds = remaining_seconds % 60 - error = f'登入失敗次數過多,帳號已鎖定。請在 {minutes} 分 {seconds} 秒後再試。' - return render_template('login.html', error=error), 429 + remaining_attempts = max(0, MAX_LOGIN_ATTEMPTS - failure_count) + error = f"帳號或密碼錯誤。(剩餘嘗試次數:{remaining_attempts})" + return render_template( + "login.html", error=error, auth_mode=mode, username=username + ), 401 + finally: + db_session.close() - # 顯示登入頁面 - return render_template('login.html', error=None) - - @app.route('/logout') + @app.route("/logout") def logout(): - """ - 登出路由:清除 session 並導回登入頁。 - """ - client_ip = get_client_ip() + if session.get("logged_in"): + _audit_session_event(LoginHistory.STATUS_LOGOUT, "user_logout") session.clear() - print(f"👋 使用者已登出 | IP: {client_ip}") - return redirect(url_for('login')) + return redirect(url_for("login")) -print("✅ Auth 模組已載入(增強安全版本)") + +print("[Security] Auth identity governance module loaded") diff --git a/config.py b/config.py index 6f117e4..217bd33 100644 --- a/config.py +++ b/config.py @@ -80,7 +80,19 @@ def _require_env(var_name: str, *, insecure_values: tuple[str, ...] = ()) -> str _sys.exit(1) -LOGIN_PASSWORD = _require_env("LOGIN_PASSWORD") +MOMO_AUTH_IDENTITY_MODE = os.getenv("MOMO_AUTH_IDENTITY_MODE", "auto").strip().lower() +if MOMO_AUTH_IDENTITY_MODE not in {"auto", "shadow", "hybrid", "database"}: + raise ValueError( + "MOMO_AUTH_IDENTITY_MODE 必須是 auto、shadow、hybrid 或 database" + ) + +# Database-only mode no longer depends on the legacy shared credential. Auto, +# hybrid and shadow keep it available only until their controlled cutover gate. +LOGIN_PASSWORD = ( + os.getenv("LOGIN_PASSWORD", "") + if MOMO_AUTH_IDENTITY_MODE == "database" + else _require_env("LOGIN_PASSWORD") +) SECRET_KEY = _require_env("SECRET_KEY", insecure_values=('your_flask_secret_key', 'change_me', '')) # ========================================== @@ -402,7 +414,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.787" +SYSTEM_VERSION = "V10.789" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/database/user_models.py b/database/user_models.py index 017a530..967b5ee 100644 --- a/database/user_models.py +++ b/database/user_models.py @@ -92,6 +92,15 @@ class LoginHistory(Base): STATUS_SUCCESS = 'success' STATUS_FAILED = 'failed' STATUS_LOCKED = 'locked' + STATUS_LOGOUT = 'logout' + STATUS_REVOKED = 'session_revoked' + STATUS_DENIED = 'authorization_denied' + STATUS_SHADOW = 'shadow_evaluated' + STATUS_IDENTITY_CHANGED = 'identity_changed' + STATUS_PASSWORD_CHANGED = 'password_changed' + STATUS_USER_CREATED = 'user_created' + STATUS_USER_DISABLED = 'user_disabled' + STATUS_PERMISSION_CHANGED = 'permission_changed' def to_dict(self): """轉換為字典""" diff --git a/docker-compose.yml b/docker-compose.yml index cfd19f0..2c622f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -88,6 +88,9 @@ services: - GRIST_URL=/grist # 關閉登入驗證(開發/測試用,生產環境預設啟用登入) - DISABLE_LOGIN=${DISABLE_LOGIN:-false} + # SEC-P0-002: auto 以 durable admin login receipts 自動由 hybrid 收斂為 database-only。 + - MOMO_AUTH_IDENTITY_MODE=${MOMO_AUTH_IDENTITY_MODE:-auto} + - MOMO_TRUSTED_PROXY_CIDRS=${MOMO_TRUSTED_PROXY_CIDRS:-127.0.0.1/32,::1/128,172.19.0.1/32} # 資料庫設定: Docker 環境使用 PostgreSQL - USE_POSTGRESQL=true - POSTGRES_HOST=${POSTGRES_HOST:-postgres} diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 7ba328c..660375e 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -1,8 +1,8 @@ # PChome 業績成長自動化作戰系統 — AI 競價情報模組 Single Source of Truth -> **最後更新**: 2026-07-11 (台北時間) -> **狀態**: 🟠 Partial。SEC-P0-001 deny-by-default access control 已完成 production closure;四 Agent、PixelRAG、MCP/RAG registry、PChome controlled-preview families 與多項 smoke/readback 已建立,但 database identity/RBAC、full asset reconciliation、software supply chain、same-run controlled apply、restore drill、internal RAG canary 與 MCP/RAG runtime closure 尚未全部完成。任何局部 receipt 不得代表整體閉環完成。 -> **適用版本**: V10.787 +> **最後更新**: 2026-07-14 (台北時間) +> **狀態**: 🟠 Partial。SEC-P0-001 deny-by-default access control 已完成 production closure;V10.788 database identity/RBAC source 與自動 cutover 已實作、等待 production canary/receipt。四 Agent、PixelRAG、MCP/RAG registry、PChome controlled-preview families 與多項 smoke/readback 已建立,但 full asset reconciliation、software supply chain、same-run controlled apply、restore drill、internal RAG canary 與 MCP/RAG runtime closure 尚未全部完成。任何局部 receipt 不得代表整體閉環完成。 +> **適用版本**: V10.788 --- @@ -17,6 +17,7 @@ - 目前 ordered P0 以 `docs/guides/ai_automation_mainline_work_items.md` 為準:SEC-P0-001 access exposure 已關閉,current P0 是 identity/RBAC,之後依序為 webhook trust、supply chain、asset reconciliation、controlled-apply envelope、internal RAG canary 與 MCP/RAG runtime closure。 - V10.786 起 `/metrics` 不再是 public exception;應用中央政策與 Nginx exact-match edge policy 僅允許 loopback / Docker transport,Prometheus 必須以內部 canary 證明 target `up`,且 public/LAN readback 必須拒絕。監控 Compose 不得保存明文 Grafana 管理密碼,Prometheus 對外埠預設只綁 loopback。 - V10.787 依 live ingress 修正 metrics transport:production ingress 實際位於 188,110 global Prometheus 只能經專用 `172.20.0.1:19191` Docker-only Nginx bridge 抓取;bridge 以系統 CA、`mo.wooo.work` SNI/Host 轉送至 188,應用僅額外允許 `192.168.0.110/32` 單一監控主機,不得放行整段 LAN,也不得把 110 的其他 vhost 誤當 EwoooC production ingress。controlled apply 支援新 include 建立、Prometheus bind-mount reconciliation、`momo_app_info`/health/database product marker canary、精確 scrape URL、worker reload convergence 與失敗自動移除/回滾;production 已讀回 public 404、internal 200、target up、Prometheus identity preserved、8/8 anonymous matrix 與 `momo-db` unchanged,證據在 `governance/evidence/SEC-P0-001-20260711T122758Z.json`。 +- V10.788 把既有 `users/login_history/user_permissions` 正式接入登入:`auto` 模式先以 hybrid 維持回滾能力,只計 active admin 的 database-auth success durable receipts,預設連續兩次後自動轉為 database-only;legacy shared Session 下一次請求自動撤銷。鎖定改由 `login_history` 跨 worker 持久化,Session 綁定 password/role/active identity version,中央 policy 區分 read/operate/admin,且只有 allowlisted direct proxy peer 才能提供 `X-Forwarded-For/X-Real-IP`。帳號建立、身份更新、密碼變更、停用與權限異動會在同一交易寫入不含密碼的 audit event,commit 失敗時兩者一併 rollback。`/api/auth/governance` 與 `report_auth_identity_governance.py` 僅輸出非機密 readback;source ready 不等於 production cutover completed。 - V10.777 起 web process 只能註冊 OpenClaw Blueprint,不得在 import 或 `record_once` 啟動 APScheduler、timed jobs 或 embedding thread;10 個 OpenClaw timed jobs 與唯一 embedding worker runtime owner 必須由 `momo-scheduler` 明確啟動。每日 04:15 的 codebase inventory 只掃描程式與前端資產並寫 artifact receipt,不讀 secret、不寫業務 DB。 --- @@ -155,7 +156,8 @@ - V10.652 起正式首頁 `/` 必須顯示「PChome 業績成長自動化作戰系統」,舊商品看板僅保留在 `/dashboard` 或 `/product-dashboard`;「今日策略動作」必須放在首屏任務摘要後方,不能只藏在商品明細區;每列必須直接顯示價格證據,至少包含 PChome、MOMO、差距與可信度四格。候選待確認或缺資料時需以待確認/待補呈現,不得要求使用者先打開詳情才知道判斷依據。 - V10.654 起全站側邊欄第一個主入口必須命名為「業績成長指揮台」;舊商品看板只能以「舊商品看板」保留在 `/dashboard`,比價工作台必須直連 `/dashboard?filter=pchome_review...`,不得再使用 `/` query 轉址,避免正式首頁與舊頁混淆。 - V10.655 起正式首頁 `/` 必須以 HTTP 200 原地渲染「業績成長指揮台」,不得 302 跳轉到 `/ai_intelligence`;側邊欄第一個主入口必須直連 `/`。`/ai_intelligence` 只作為相容入口保留,不得成為主導流路由。 -- V10.656 起正式首頁首屏必須是「PChome 業績成長系統」專業儀表板,而非大段說明型頁首;第一屏需直接呈現近 7 天業績、比價可用率、下滑商品、待補比價、最大分類、下滑商品 TOP 5、PChome/MOMO 價格狀態圓環與處理狀態,且全部使用 `/api/ai/pchome-growth/opportunities` 真資料渲染。 +- V10.656 起正式首頁首屏必須是「PChome 業績成長系統」專業儀表板,而非大段說明型頁首;第一屏需直接呈現近 7 天業績、同款比價證據、下滑商品、待補同款、正式資料平台、下滑商品 TOP 5、比價覆蓋圓環與處理狀態,且全部使用 `/api/ai/pchome-growth/opportunities` 真資料渲染。 +- V10.789 起首頁禁止用無分母的「比價可用率」代表全站資料品質。`comparison_metric_contract.version=pchome_comparison_coverage_v2` 必須分開公布 TOP 高優先商品、近七日活躍商品與正式 runtime 平台三個 scope 的分子、分母與比率;`comparison_ready`、`candidate_validation`、`unmatched` 必須互斥且總和等於候選數。業績過期時 `operational_decision.ready=false`,UI 顯示決策暫停但保留歷史證據,不得把 stale 數字包裝成今日建議。`momo-scheduler` 必須以獨立防重入 watchdog 依序執行授權業績取得與 bounded comparison backfill,避免同步 `schedule.run_pending()` 被長任務阻塞後讓比價覆蓋永久停滯。 - V10.658 起全站頁面必須回到「提升 PChome 業績」同一產品主線;共用 shell 需提供短流程骨架:評估、分析、建議、解法、治理。頁首、hero、任務卡與提示區不得依賴大段文字讓使用者理解狀況;首屏應以短指標、狀態分流、下一步動作與可執行入口呈現專業評估、分析、建議與解決方案。 - V10.659 起主要入口頁的首屏文案必須改為流程職責語言,而不是功能說明書。當日業績負責找出下滑與價差壓力,匯入負責資料新鮮度,AI 建議負責把證據轉成銷售建議,比價負責同款與價差決策,缺貨負責避免主推商品斷貨,月結分析負責判斷成長、毛利與品類結構。 diff --git a/docs/adr/ADR-038-security-governance-automation-control-plane.md b/docs/adr/ADR-038-security-governance-automation-control-plane.md index aef89d4..3570134 100644 --- a/docs/adr/ADR-038-security-governance-automation-control-plane.md +++ b/docs/adr/ADR-038-security-governance-automation-control-plane.md @@ -60,6 +60,16 @@ Any missing stage produces `partial` or `degraded`, never completed. - `scripts/ops/report_security_governance_review.py --strict` is the source release gate. - `/api/ai-automation/security-governance-review` is the protected production readback. +### 4.1 Database identity controlled cutover + +- `services/auth_identity_service.py` is the single policy for auth mode, trusted proxy CIDRs, role tiers and session identity versioning. +- `login_history` is the durable login/audit/lockout source; process-local counters are not authoritative. +- `auto` starts with bounded hybrid authority so the current operator is not locked out. Only successful database authentication by an active admin counts toward promotion; two durable success receipts are required by default. +- Once the threshold is reached, effective authority becomes database-only without a manual review gate. Legacy shared sessions are revoked on their next request and cannot inherit an admin role from missing fields. +- Any password, role, active-state or identity timestamp change invalidates the signed session through its durable identity version. +- Account creation, identity updates, password changes, account disablement and permission changes must commit their no-secret `login_history` audit event in the same database transaction; failures roll back both the mutation and its audit. +- `scripts/ops/report_auth_identity_governance.py` returns counts and control state only; it must never return password hashes, secret values or raw sessions. + ### 5. Legacy policy sunset | Legacy policy | Owner | Expiry | Replacement | Verifier | diff --git a/docs/guides/ai_automation_mainline_work_items.md b/docs/guides/ai_automation_mainline_work_items.md index be543db..7433438 100644 --- a/docs/guides/ai_automation_mainline_work_items.md +++ b/docs/guides/ai_automation_mainline_work_items.md @@ -1,8 +1,8 @@ # AI Automation Mainline Work Items -> Updated: 2026-07-11 +> Updated: 2026-07-14 > Governance: `global_product_governance_v2` + ADR-038 -> Current P0: `SEC-P0-002 database identity + least-privilege RBAC` +> Current P0: `GROWTH-P0-001 comparison coverage truth + autonomous refresh` ## Source Of Truth @@ -17,37 +17,38 @@ | Order | ID | Status | Work item | Exit evidence / next machine action | |---:|---|---|---|---| | 1 | `SEC-P0-001` | Completed | Deny-by-default route access control | `governance/evidence/SEC-P0-001-20260711T122758Z.json` plus the current runtime receipt prove anonymous matrix 8/8 denied, public `/metrics` 404, exact internal target up, EwoooC product markers present, Prometheus identity preserved and `momo-db` unchanged. | -| 2 | `SEC-P0-002` | In progress | Database identity + least-privilege RBAC | Replace shared admin password, default-admin fallback, process-local lockout and spoofable proxy identity. Next: inventory active users and auth call sites, then shadow database-backed auth before bounded canary cutover. Exit: role matrix, durable audit/lockout, session revocation and production readback. | -| 3 | `SEC-P0-003` | In progress | Webhook trust and replay protection | Telegram secret-token verification code exists; production secret activation remains unproven. Exit: secret provisioned outside source, required mode enabled, invalid-secret 401 and authorized callback canary pass. | -| 4 | `SUPPLY-P0-001` | In progress | Gitea-only secure software supply chain | Gitea-native checkout, secret-safe `.dockerignore`, commit-bound source receipt and governance gate are active. Exit: exact dependency lock, internal SAST/SCA/secret scan, SBOM, image digest/provenance, vulnerability SLA and production digest readback. | -| 5 | `GOV-P0-001` | In progress | Canonical full asset graph + runtime reconciliation | `governance/ewoooc_asset_inventory.json` seeds hosts, services, data, AI, routes, supply chain, observability and recovery. Exit: same-run probe receipt for every asset; drift auto-creates work items. | -| 6 | `GOV-P0-002` | Not started | Unified controlled-apply envelope | Introduce one `trace_id/run_id/work_item_id` across sensor, identity, SOT diff, decision, risk, dry-run, execution, verifier, rollback/retry and learning acknowledgement. Start with EventRouter + AutoHeal. | -| 7 | `RAG-P0-001` | Not started | Internal RAG candidate canary | V10.770 stops at candidate preview. Exit: bounded pgvector candidate canary, signature/readback/feedback receipt, no price write and no premature `ai_insights` promotion. Next: `run_internal_rag_candidate_canary`. | -| 8 | `MCP-P0-001` | In progress | MCP/RAG production runtime closure | Registry and smoke wiring exist, but runtime flags/ports must be live. Exit: MCP servers healthy, router enabled, RAG enabled, approved caller/tool boundary and production query canary pass. | -| 9 | `SEC-P0-004` | Not started | Security operations lifecycle and metrics | Add durable security incident state and publish MTTA, MTTR, recurrence, false positive, human intervention, verifier pass, rollback and freshness. Exit: detect-to-learn production receipt. | -| 10 | `REL-P0-001` | In progress | Formal deploy and visible proof discipline | V10.787 is healthy and the SEC-P0-001 runtime loop is closed with `momo-db` unchanged; Gitea CD #1120 remains waiting because no `ewoooc-host` runner is online. Exit: restore a dedicated EwoooC-only runner, replay the current source and close CD/readback automatically. | +| 2 | `GROWTH-P0-001` | In progress | Comparison coverage truth + autonomous refresh | V10.789 source replaces the ambiguous percentage with numerator/denominator scopes for TOP opportunities, active seven-day catalog and registered runtime platforms; ready/candidate/unmatched states are mutually exclusive, stale sales block operational decisions, and a non-overlapping watchdog runs authorized sales acquisition before bounded comparison backfill. Exit: production API contract readback, newer scheduler receipts, desktop/mobile visible proof and unchanged `momo-db` identity. | +| 3 | `SEC-P0-002` | In progress | Database identity + least-privilege RBAC | V10.789 release source connects `users/login_history/user_permissions` to login and role enforcement, adds durable lockout, identity-version session revocation, trusted-proxy CIDRs, and same-transaction no-secret audits for account/password/permission mutations. `auto` promotes from hybrid to database-only after two durable successful database logins by an active admin; no manual review gate. Next: deploy, write the runtime receipt, capture database-admin login receipts and verify automatic shared-authority retirement. | +| 4 | `SEC-P0-003` | In progress | Webhook trust and replay protection | Telegram secret-token verification code exists; production secret activation remains unproven. Exit: secret provisioned outside source, required mode enabled, invalid-secret 401 and authorized callback canary pass. | +| 5 | `SUPPLY-P0-001` | In progress | Gitea-only secure software supply chain | Gitea-native checkout, secret-safe `.dockerignore`, commit-bound source receipt and governance gate are active. Exit: exact dependency lock, internal SAST/SCA/secret scan, SBOM, image digest/provenance, vulnerability SLA and production digest readback. | +| 6 | `GOV-P0-001` | In progress | Canonical full asset graph + runtime reconciliation | `governance/ewoooc_asset_inventory.json` seeds hosts, services, data, AI, routes, supply chain, observability and recovery. Exit: same-run probe receipt for every asset; drift auto-creates work items. | +| 7 | `GOV-P0-002` | Not started | Unified controlled-apply envelope | Introduce one `trace_id/run_id/work_item_id` across sensor, identity, SOT diff, decision, risk, dry-run, execution, verifier, rollback/retry and learning acknowledgement. Start with EventRouter + AutoHeal. | +| 8 | `RAG-P0-001` | Not started | Internal RAG candidate canary | V10.770 stops at candidate preview. Exit: bounded pgvector candidate canary, signature/readback/feedback receipt, no price write and no premature `ai_insights` promotion. Next: `run_internal_rag_candidate_canary`. | +| 9 | `MCP-P0-001` | In progress | MCP/RAG production runtime closure | Registry and smoke wiring exist, but runtime flags/ports must be live. Exit: MCP servers healthy, router enabled, RAG enabled, approved caller/tool boundary and production query canary pass. | +| 10 | `SEC-P0-004` | Not started | Security operations lifecycle and metrics | Add durable security incident state and publish MTTA, MTTR, recurrence, false positive, human intervention, verifier pass, rollback and freshness. Exit: detect-to-learn production receipt. | +| 11 | `REL-P0-001` | In progress | Formal deploy and visible proof discipline | Production remains V10.787 while V10.789 identity and comparison-coverage source is under verification; SEC-P0-001 stays closed with `momo-db` unchanged. Gitea CD #1120 remains waiting because no `ewoooc-host` runner is online. Exit: deploy V10.789 through the bounded fallback, preserve the DB identity, and retain CD/runtime/visible proof separately. | ## P1 | Order | ID | Status | Work item | Exit evidence / next machine action | |---:|---|---|---|---| -| 11 | `RES-P1-001` | Not started | Backup/offsite/restore automation | Fresh checksum and offsite coverage plus isolated non-destructive restore drill with measured RPO/RTO. | -| 12 | `PLAT-P1-001` | Not started | Non-root container and capability hardening | Shadow non-root image, writable-path inventory, capability drop/read-only filesystem canary, three-app rollout. | -| 13 | `APPSEC-P1-001` | In progress | CSP and DOM/XSS hardening | Security headers are present and CSP is report-only. Collect violations, remove high-risk `innerHTML`/inline sinks, then enforce CSP by canary. | -| 14 | `APPSEC-P1-002` | Not started | Unsafe shared-cache serialization removal | Replace writable pickle caches in dashboard/daily-sales/EDM/sales with constrained JSON or signed schema. | -| 15 | `ARCH-P1-001` | In progress | Split oversized policy/executor/verifier modules | Current top debts include 44k-line PChome mapping and 14k-line smoke service. Split by bounded family and independent tests. | -| 16 | `UX-P1-001` | In progress | Professional full-site UI/UX | Complete first-viewport cockpit, progressive disclosure, text-density reduction, Traditional Chinese product copy, accessibility, loading/error/empty/degraded states and desktop/mobile visual smoke. | -| 17 | `PIXELRAG-P1-001` | Not started | Ollama-first multimodal embedding benchmark | Verify approved visual embedding on GCP-A -> GCP-B -> 111 and design pgvector-compatible visual metadata; FAISS remains disallowed without ADR. | -| 18 | `MARKET-P1-001` | In progress | Marketplace source contracts | Finish stable, public-boundary, provenance and rate-limit contracts for Shopee, Coupang, Yahoo, ETMall, Friday and Rakuten; blocked pages remain non-product data. | -| 19 | `QA-P1-001` | In progress | Deterministic test and CI governance | Current baseline is 1,701 passed / 30 legacy failures / 9 skipped plus one missing report module at collection. Restore or retire the missing contract, remove wall-clock/copy drift, and split deterministic release tests from nightly integration and production canaries. | +| 12 | `RES-P1-001` | Not started | Backup/offsite/restore automation | Fresh checksum and offsite coverage plus isolated non-destructive restore drill with measured RPO/RTO. | +| 13 | `PLAT-P1-001` | Not started | Non-root container and capability hardening | Shadow non-root image, writable-path inventory, capability drop/read-only filesystem canary, three-app rollout. | +| 14 | `APPSEC-P1-001` | In progress | CSP and DOM/XSS hardening | Security headers are present and CSP is report-only. Collect violations, remove high-risk `innerHTML`/inline sinks, then enforce CSP by canary. | +| 15 | `APPSEC-P1-002` | Not started | Unsafe shared-cache serialization removal | Replace writable pickle caches in dashboard/daily-sales/EDM/sales with constrained JSON or signed schema. | +| 16 | `ARCH-P1-001` | In progress | Split oversized policy/executor/verifier modules | Current top debts include 44k-line PChome mapping and 14k-line smoke service. Split by bounded family and independent tests. | +| 17 | `UX-P1-001` | In progress | Professional full-site UI/UX | Complete first-viewport cockpit, progressive disclosure, text-density reduction, Traditional Chinese product copy, accessibility, loading/error/empty/degraded states and desktop/mobile visual smoke. | +| 18 | `PIXELRAG-P1-001` | Not started | Ollama-first multimodal embedding benchmark | Verify approved visual embedding on GCP-A -> GCP-B -> 111 and design pgvector-compatible visual metadata; FAISS remains disallowed without ADR. | +| 19 | `MARKET-P1-001` | In progress | Marketplace source contracts | Finish stable, public-boundary, provenance and rate-limit contracts for Shopee, Coupang, Yahoo, ETMall, Friday and Rakuten; blocked pages remain non-product data. | +| 20 | `QA-P1-001` | In progress | Deterministic test and CI governance | V10.789 full local baseline is 2,061 passed / 9 skipped / 0 failed with cache writes disabled after safe cache cleanup. Next: preserve the same terminal in Gitea CI, split deterministic release tests from nightly integration and production canaries, and prevent workstation disk pressure from invalidating evidence. | ## P2 | Order | ID | Status | Work item | Exit evidence / next machine action | |---:|---|---|---|---| -| 20 | `GOV-P2-001` | Not started | Continuous NIST/ASVS control trend | Persist governance snapshots, compare control/asset/runtime drift and create ordered work items automatically. | -| 21 | `DATA-P2-001` | Not started | Data classification and retention enforcement | Classify business, personal, operational and model data; automate retention, deletion eligibility and audit evidence without destructive default actions. | -| 22 | `CHAOS-P2-001` | Not started | Controlled resilience exercises | Run non-destructive model-host, MCP, queue, Telegram and app-container failure drills with rollback and learning receipts. | +| 21 | `GOV-P2-001` | Not started | Continuous NIST/ASVS control trend | Persist governance snapshots, compare control/asset/runtime drift and create ordered work items automatically. | +| 22 | `DATA-P2-001` | Not started | Data classification and retention enforcement | Classify business, personal, operational and model data; automate retention, deletion eligibility and audit evidence without destructive default actions. | +| 23 | `CHAOS-P2-001` | Not started | Controlled resilience exercises | Run non-destructive model-host, MCP, queue, Telegram and app-container failure drills with rollback and learning receipts. | ## Completed Foundations diff --git a/docs/memory/code_modularization_inventory_20260430.md b/docs/memory/code_modularization_inventory_20260430.md index 98366e7..624bb1f 100644 --- a/docs/memory/code_modularization_inventory_20260430.md +++ b/docs/memory/code_modularization_inventory_20260430.md @@ -71,6 +71,7 @@ - 2026-05-24 追記:同步 PChome review queue 決策信封合併後的 `services/competitor_intel_repository.py` 行數;此處只更新 inventory,不變更拆分策略。 - 2026-05-25 追記:同步背景 embedding 讀取 `host_health_probes` skip guard 後的 `services/ollama_service.py` 行數;此處只更新 inventory,不變更 Ollama 路由決策。 - 2026-05-29 追記:同步 PChome near-threshold / focused identity 回收系列後的 `services/marketplace_product_matcher.py` 行數;此處只更新 inventory,不變更拆分策略。 +- 2026-07-14 追記:`services/pchome_revenue_growth_service.py` 因新增可稽核比價覆蓋契約與 active-catalog scope 增至 1,366 行;此輪先完成 P0 正確性,後續 `ARCH-P1-001` 應拆出 catalog coverage query 與 metric-contract builder,主 service 保留 orchestration。 ## 達到或超過 800 行檔案清單 @@ -146,7 +147,7 @@ | 1306 | `routes/system_public_routes.py` | P1 public routes | health、metrics、public projection 分離 | | 1269 | `app.py` | P1 bootstrap、拆分進行中 | web scheduler 相容層已移到 `services/web_scheduler_compat.py`;下一步拆 extension setup | | 1272 | `services/code_review_pipeline_service.py` | P2 code review | scan、normalize、persist、report 分離 | -| 1202 | `services/pchome_revenue_growth_service.py` | P1 revenue growth | sales query、offer comparison、scoring、projection 分離 | +| 1366 | `services/pchome_revenue_growth_service.py` | P1 revenue growth | sales query、catalog coverage、metric contract、offer comparison、scoring、projection 分離 | | 1188 | `services/ppt_auto_generation_service.py` | P2 PPT automation | resolver、queue、generator、catchup 分離 | | 1121 | `services/pixelrag_crawler_integration_service.py` | P1 PixelRAG | capture routing、manifest、queue、readback 分離 | | 1023 | `routes/cicd_routes.py` | P2 CI/CD Blueprint | query、deploy action、route glue 分離 | @@ -160,7 +161,7 @@ | 895 | `routes/market_intel_review_routes.py` | P2 review Blueprint | command、query、route glue 分離 | | 867 | `services/token_report_service.py` | P2 token report | query、aggregate、chart、notify 分離 | | 864 | `services/ppt_vision_service.py` | P2 vision QA | runtime、queue、probe、audit 分離 | -| 862 | `services/security_governance_review_service.py` | P1 governance | inventory、controls、runtime receipt、scorecard 分離 | +| 1005 | `services/security_governance_review_service.py` | P1 governance | inventory、controls、runtime receipt、scorecard 分離 | | 861 | `routes/market_intel_review_post_routes.py` | P2 review POST routes | command handlers 與 verifier 分離 | | 846 | `services/market_intel/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_catalog_record_run_receipt.py` | P2 receipt pipeline | AI summary、persistence、delivery、catalog 分離 | | 843 | `services/pixelrag_vlm_replay_worker_service.py` | P1 VLM worker | route selection、inference、validation、receipt 分離 | diff --git a/routes/user_routes.py b/routes/user_routes.py index db9a499..239323f 100644 --- a/routes/user_routes.py +++ b/routes/user_routes.py @@ -9,15 +9,32 @@ """ from flask import Blueprint, render_template, request, jsonify, session, redirect, url_for, flash -from auth import login_required, role_required, admin_required, get_current_user +from auth import ( + admin_required, + get_client_ip, + get_current_user, + login_required, + role_required, +) from database.manager import get_session from services.user_service import UserService from services.password_service import get_password_requirements from database.user_models import User +from services.auth_identity_service import build_auth_identity_runtime_readback user_bp = Blueprint('user_bp', __name__) +def _request_audit_context(current_user=None): + """Return non-secret request metadata for durable identity audits.""" + identity = current_user if current_user is not None else get_current_user() + return { + 'actor_id': (identity or {}).get('user_id'), + 'ip_address': get_client_ip(), + 'user_agent': request.headers.get('User-Agent', ''), + } + + # ========================================== # 頁面路由 # ========================================== @@ -109,7 +126,8 @@ def create_user(): role=role, email=email, display_name=display_name, - created_by=created_by + created_by=created_by, + audit_context=_request_audit_context(current_user), ) if error: @@ -173,7 +191,11 @@ def update_user(user_id): db_session = get_session() try: service = UserService(db_session) - success, error = service.update_user(user_id, **update_data) + success, error = service.update_user( + user_id, + audit_context=_request_audit_context(), + **update_data, + ) if not success: return jsonify({'success': False, 'message': error}), 400 @@ -201,7 +223,10 @@ def delete_user(user_id): db_session = get_session() try: service = UserService(db_session) - success, error = service.delete_user(user_id) + success, error = service.delete_user( + user_id, + audit_context=_request_audit_context(current_user), + ) if not success: return jsonify({'success': False, 'message': error}), 400 @@ -234,7 +259,12 @@ def reset_user_password(user_id): current_user = get_current_user() admin_id = current_user.get('user_id') if current_user else None - success, error = service.reset_password(user_id, new_password, admin_id=admin_id) + success, error = service.reset_password( + user_id, + new_password, + admin_id=admin_id, + audit_context=_request_audit_context(current_user), + ) if not success: return jsonify({'success': False, 'message': error}), 400 @@ -281,7 +311,12 @@ def api_change_password(): db_session = get_session() try: service = UserService(db_session) - success, error = service.change_password(user_id, old_password, new_password) + success, error = service.change_password( + user_id, + old_password, + new_password, + audit_context=_request_audit_context(current_user), + ) if not success: return jsonify({'success': False, 'message': error}), 400 @@ -351,6 +386,26 @@ def get_my_login_history(): db_session.close() +@user_bp.route('/api/auth/governance', methods=['GET']) +@admin_required +def get_auth_identity_governance(): + """Return a no-secret runtime readback for SEC-P0-002.""" + db_session = get_session() + try: + return jsonify({ + 'success': True, + 'data': build_auth_identity_runtime_readback(db_session), + }) + except Exception as exc: + print(f"[Security] auth governance readback failed: {type(exc).__name__}") + return jsonify({ + 'success': False, + 'error': 'auth_governance_readback_unavailable', + }), 503 + finally: + db_session.close() + + # ========================================== # 角色定義 API # ========================================== @@ -495,7 +550,8 @@ def update_user_permissions(user_id): success, message = PermissionService.set_user_permissions( user_id=user_id, permission_codes=permission_codes, - granted_by=granted_by + granted_by=granted_by, + audit_context=_request_audit_context(current_user), ) if success: @@ -555,7 +611,8 @@ def apply_role_template(user_id): success, message = PermissionService.apply_role_template( user_id=user_id, role=role, - granted_by=granted_by + granted_by=granted_by, + audit_context=_request_audit_context(current_user), ) if success: diff --git a/run_scheduler.py b/run_scheduler.py index 7328dfc..f022adf 100644 --- a/run_scheduler.py +++ b/run_scheduler.py @@ -3,7 +3,7 @@ run_scheduler.py — momo-scheduler 容器入口點 排程任務清單(對齊 app.py init_scheduler + scheduler.py 全任務): - 每 30 分鐘:auto_import、whitepage_check + 每 30 分鐘:revenue_automation_cycle(auto_import → comparison backfill)、whitepage_check 每 1 小時:momo、edm、festival 每 4 小時:competitor_price_feeder、external_offer_sync、icaim_analysis 每 6 小時:quality_rescore、action_plan_hygiene @@ -22,7 +22,7 @@ import logging import os import threading import time -from typing import Optional, Tuple +from typing import Callable, Optional, Tuple import schedule @@ -60,6 +60,7 @@ logger = logging.getLogger(__name__) _AI_CALLS_ERROR_SPIKE_LAST_PUSH_TS = 0.0 _OLLAMA_111_USAGE_LAST_PUSH_TS = 0.0 +_REVENUE_AUTOMATION_LOCK = threading.Lock() def _scheduler_stats_path() -> str: @@ -122,6 +123,96 @@ def run_pchome_growth_momo_backfill_catchup_task(): return {"status": "failed", "error": str(error)} +def _launch_revenue_automation_background( + job_name: str, + target: Callable[[], object], +) -> dict[str, str]: + """Launch one revenue job without blocking or overlapping the scheduler loop.""" + if not _REVENUE_AUTOMATION_LOCK.acquire(blocking=False): + logger.info("[RevenueAutomation] skipped overlapping job=%s", job_name) + return {"status": "skipped", "reason": "revenue_automation_already_running"} + + def _runner(): + try: + target() + except Exception as error: + logger.error( + "[RevenueAutomation] background job failed job=%s error=%s", + job_name, + error, + exc_info=True, + ) + _notify_scheduler_failure( + job_name, + error, + source="Scheduler.RevenueAutomation", + event_type="revenue_automation_background_failure", + title="PChome 收益自動化背景任務失敗", + ) + finally: + _REVENUE_AUTOMATION_LOCK.release() + + thread = threading.Thread( + target=_runner, + daemon=True, + name=f"revenue-automation-{job_name}", + ) + try: + thread.start() + except Exception: + _REVENUE_AUTOMATION_LOCK.release() + raise + return {"status": "queued", "job": job_name} + + +def run_revenue_automation_cycle_task() -> dict[str, object]: + """Refresh sales first, then catch up the bounded same-item mapping lane.""" + logger.info("[RevenueAutomation] cycle started: sales acquisition -> comparison backfill") + run_auto_import_task() + backfill = run_pchome_growth_momo_backfill_catchup_task() + logger.info( + "[RevenueAutomation] cycle completed backfill_status=%s", + backfill.get("status"), + ) + return {"status": "completed", "backfill": backfill} + + +def run_revenue_automation_cycle_background_task() -> dict[str, str]: + return _launch_revenue_automation_background( + "revenue-cycle", + run_revenue_automation_cycle_task, + ) + + +def run_pchome_growth_momo_backfill_background_task() -> dict[str, str]: + return _launch_revenue_automation_background( + "pchome-growth-backfill", + run_pchome_growth_momo_backfill_task, + ) + + +def start_revenue_automation_watchdog() -> threading.Thread: + """Keep revenue refresh alive even when a legacy schedule job blocks run_pending.""" + try: + interval_seconds = int(os.getenv("REVENUE_AUTOMATION_WATCHDOG_SECONDS", "1800")) + except ValueError: + interval_seconds = 1800 + interval_seconds = max(60, interval_seconds) + + def _watchdog(): + while True: + run_revenue_automation_cycle_background_task() + time.sleep(interval_seconds) + + thread = threading.Thread( + target=_watchdog, + daemon=True, + name="revenue-automation-watchdog", + ) + thread.start() + return thread + + def _env_flag(name: str, default: bool = False) -> bool: raw = os.getenv(name) if raw is None: @@ -185,8 +276,8 @@ def _notify_scheduler_failure( def _register_schedules(): - schedule.every(30).minutes.do(run_auto_import_task) - logger.info("📅 每 30 分鐘:auto_import") + schedule.every(30).minutes.do(run_revenue_automation_cycle_background_task) + logger.info("📅 每 30 分鐘:revenue_automation_cycle(業績匯入 → 比價回填,非阻塞)") schedule.every(30).minutes.do(run_whitepage_check) logger.info("📅 每 30 分鐘:whitepage_check") @@ -374,12 +465,9 @@ def _register_schedules(): schedule.every().day.at("10:30").do(run_pchome_match_backfill_task) logger.info("📅 每日 10:30:pchome_match_backfill") - schedule.every().day.at("10:45").do(run_pchome_growth_momo_backfill_task) + schedule.every().day.at("10:45").do(run_pchome_growth_momo_backfill_background_task) logger.info("📅 每日 10:45:pchome_growth_momo_backfill(高業績商品補 MOMO 對應)") - schedule.every(30).minutes.do(run_pchome_growth_momo_backfill_catchup_task) - logger.info("📅 每 30 分鐘:pchome_growth_momo_backfill_catchup(錯過每日任務時自動補跑)") - # Operation Ollama-First v5.0 — Phase 1 收尾:每日 23:55 LLM Token 日報 schedule.every().day.at("23:55").do(run_daily_token_report_task) logger.info("📅 每日 23:55:daily_token_report") @@ -1368,6 +1456,13 @@ if __name__ == "__main__": _register_schedules() logger.info("✅ 全部排程任務已註冊") + _revenue_watchdog_thread = start_revenue_automation_watchdog() + logger.info( + "[RevenueAutomation] watchdog=%s alive=%s", + _revenue_watchdog_thread.name, + _revenue_watchdog_thread.is_alive(), + ) + try: from services.openclaw_bot.embedding_worker_runtime import start_embedding_worker diff --git a/scripts/ops/report_auth_identity_governance.py b/scripts/ops/report_auth_identity_governance.py new file mode 100644 index 0000000..73b8214 --- /dev/null +++ b/scripts/ops/report_auth_identity_governance.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Emit the SEC-P0-002 database identity runtime readback without secrets.""" + +from __future__ import annotations + +import argparse +from contextlib import redirect_stdout +from datetime import datetime, timezone +import json +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[2] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--compact", action="store_true") + parser.add_argument("--strict", action="store_true", help="Require canary readiness") + parser.add_argument( + "--require-closed", + action="store_true", + help="Require database-only authority and complete runtime closure", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + sys.path.insert(0, str(ROOT)) + + with redirect_stdout(sys.stderr): + from database.manager import get_session + from services.auth_identity_service import ( + build_auth_identity_runtime_readback, + ) + + db_session = get_session() + try: + report = build_auth_identity_runtime_readback(db_session) + finally: + db_session.close() + + report["generated_at"] = datetime.now(timezone.utc).isoformat() + print( + json.dumps( + report, + ensure_ascii=True, + indent=None if args.compact else 2, + sort_keys=True, + ) + ) + if args.require_closed and not report["runtime_closed"]: + return 2 + if args.strict and not report["canary_ready"]: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/auth_identity_service.py b/services/auth_identity_service.py new file mode 100644 index 0000000..0b14a68 --- /dev/null +++ b/services/auth_identity_service.py @@ -0,0 +1,313 @@ +"""Database identity policy shared by login, session and HTTP access control.""" + +from __future__ import annotations + +from datetime import datetime +import ipaddress +import os +from typing import Iterable, Mapping + + +POLICY = "ewoooc_database_identity_policy_v1" +AUTH_IDENTITY_MODES = frozenset({"auto", "shadow", "hybrid", "database"}) +DEFAULT_AUTH_IDENTITY_MODE = "auto" +DEFAULT_TRUSTED_PROXY_CIDRS = ( + "127.0.0.1/32", + "::1/128", + "172.19.0.1/32", +) + +ROLE_ADMIN = "admin" +ROLE_MANAGER = "manager" +ROLE_USER = "user" +KNOWN_ROLES = frozenset({ROLE_ADMIN, ROLE_MANAGER, ROLE_USER}) + +READ_ROLES = (ROLE_ADMIN, ROLE_MANAGER, ROLE_USER) +OPERATE_ROLES = (ROLE_ADMIN, ROLE_MANAGER) +ADMIN_ROLES = (ROLE_ADMIN,) +MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) + +# These endpoints mutate only the current identity and remain available to every +# authenticated role. Administrative identity mutations already use admin_required. +SELF_SERVICE_MUTATION_ENDPOINTS = frozenset({ + "user_bp.api_change_password", +}) + +# Operational and identity administration never inherit the generic read role. +ADMIN_BLUEPRINTS = frozenset({"cicd", "crawler_management", "user_bp"}) +ADMIN_ENDPOINTS = frozenset({ + "alert.test_alert", + "code_review.api_history", + "code_review.api_status", + "code_review.dashboard", + "openclaw_bot.set_webhook", + "openclaw_bot.webhook_info", + "system_public.get_logs_api", + "system_public.settings", + "system_public.show_logs", + "system_public.system_settings_page", +}) + + +def resolve_auth_identity_mode( + value: str | None = None, + *, + environ: Mapping[str, str] | None = None, +) -> str: + """Return a validated auth mode; invalid configuration fails closed.""" + source = os.environ if environ is None else environ + candidate = (value if value is not None else source.get("MOMO_AUTH_IDENTITY_MODE")) + mode = (candidate or DEFAULT_AUTH_IDENTITY_MODE).strip().lower() + if mode not in AUTH_IDENTITY_MODES: + raise ValueError( + "MOMO_AUTH_IDENTITY_MODE must be one of: auto, database, hybrid, shadow" + ) + return mode + + +def auth_auto_cutover_min_successes( + *, + environ: Mapping[str, str] | None = None, +) -> int: + source = os.environ if environ is None else environ + try: + value = int(source.get("MOMO_AUTH_AUTO_CUTOVER_MIN_SUCCESSES", "2")) + except (TypeError, ValueError): + value = 2 + return max(2, min(value, 10)) + + +def database_auth_can_authorize(mode: str) -> bool: + return resolve_auth_identity_mode(mode) in {"auto", "hybrid", "database"} + + +def database_auth_is_evaluated(mode: str) -> bool: + return resolve_auth_identity_mode(mode) in AUTH_IDENTITY_MODES + + +def legacy_auth_can_authorize(mode: str) -> bool: + return resolve_auth_identity_mode(mode) in {"auto", "shadow", "hybrid"} + + +def resolve_effective_auth_identity_mode( + configured_mode: str, + *, + auto_cutover_ready: bool = False, +) -> str: + mode = resolve_auth_identity_mode(configured_mode) + if mode != "auto": + return mode + return "database" if auto_cutover_ready else "hybrid" + + +def parse_trusted_proxy_networks( + value: str | None = None, + *, + environ: Mapping[str, str] | None = None, +) -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]: + source = os.environ if environ is None else environ + raw = value if value is not None else source.get("MOMO_TRUSTED_PROXY_CIDRS") + cidrs = [part.strip() for part in (raw or ",".join(DEFAULT_TRUSTED_PROXY_CIDRS)).split(",")] + networks = [] + for cidr in cidrs: + if not cidr: + continue + networks.append(ipaddress.ip_network(cidr, strict=True)) + if not networks: + raise ValueError("MOMO_TRUSTED_PROXY_CIDRS must contain at least one CIDR") + return tuple(networks) + + +def resolve_client_ip( + remote_addr: str | None, + x_forwarded_for: str | None = None, + x_real_ip: str | None = None, + *, + trusted_proxy_cidrs: str | None = None, +) -> str: + """Trust forwarding headers only when the direct peer is allowlisted.""" + peer_text = (remote_addr or "").strip() + try: + peer = ipaddress.ip_address(peer_text) + except ValueError: + return "unknown" + + networks = parse_trusted_proxy_networks(trusted_proxy_cidrs) + if not any(peer in network for network in networks): + return str(peer) + + candidates = [] + if x_forwarded_for: + candidates.extend(part.strip() for part in x_forwarded_for.split(",")) + if x_real_ip: + candidates.append(x_real_ip.strip()) + for candidate in candidates: + try: + return str(ipaddress.ip_address(candidate)) + except ValueError: + continue + return str(peer) + + +def identity_version(user) -> str: + """Create a stable revocation version from durable identity timestamps.""" + values = [ + value + for value in ( + getattr(user, "password_changed_at", None), + getattr(user, "updated_at", None), + getattr(user, "created_at", None), + ) + if isinstance(value, datetime) + ] + if not values: + return "unversioned" + return max(values).isoformat(timespec="microseconds") + + +def role_is_allowed(role: str | None, allowed_roles: Iterable[str]) -> bool: + allowed = frozenset(allowed_roles) + return bool(role in KNOWN_ROLES and role in allowed) + + +def required_roles_for_request( + method: str, + endpoint: str | None, + blueprint: str | None, +) -> tuple[str, ...]: + """Map protected HTTP requests to least-privilege role tiers.""" + normalized_method = (method or "GET").upper() + if endpoint in SELF_SERVICE_MUTATION_ENDPOINTS: + return READ_ROLES + if blueprint in ADMIN_BLUEPRINTS or endpoint in ADMIN_ENDPOINTS: + return ADMIN_ROLES + if normalized_method in MUTATING_METHODS: + return OPERATE_ROLES + return READ_ROLES + + +def public_policy_readback(mode: str | None = None) -> dict: + resolved_mode = resolve_auth_identity_mode(mode) + return { + "policy": POLICY, + "auth_identity_mode": resolved_mode, + "database_auth_evaluated": database_auth_is_evaluated(resolved_mode), + "database_auth_authoritative": database_auth_can_authorize(resolved_mode), + "legacy_shared_password_authoritative": legacy_auth_can_authorize(resolved_mode), + "session_revocation_source": "users.password_changed_at_or_updated_at", + "durable_lockout_source": "login_history", + "identity_mutation_audit_source": "login_history", + "role_matrix": { + ROLE_ADMIN: ["read", "operate", "administer"], + ROLE_MANAGER: ["read", "operate"], + ROLE_USER: ["read"], + }, + "trusted_proxy_network_count": len(parse_trusted_proxy_networks()), + } + + +def build_auth_identity_runtime_readback(db_session, mode: str | None = None) -> dict: + """Build a no-secret runtime receipt from the durable identity store.""" + from sqlalchemy import func, inspect + + from database.permission_models import UserPermission + from services.user_service import UserService + + configured_mode = resolve_auth_identity_mode(mode) + inspector = inspect(db_session.get_bind()) + required_tables = ("users", "login_history", "user_permissions") + table_state = {table: inspector.has_table(table) for table in required_tables} + user_service = UserService(db_session) + inventory = user_service.get_identity_inventory() + auto_cutover = user_service.get_auth_auto_cutover_state( + min_successes=auth_auto_cutover_min_successes() + ) + resolved_mode = resolve_effective_auth_identity_mode( + configured_mode, + auto_cutover_ready=auto_cutover["ready"], + ) + permission_assignments = ( + db_session.query(func.count(UserPermission.id)).scalar() or 0 + if table_state["user_permissions"] + else 0 + ) + checks = { + "required_identity_tables_present": all(table_state.values()), + "active_admin_available": inventory["active_admin_count"] >= 1, + "database_auth_evaluated": database_auth_is_evaluated(resolved_mode), + "database_auth_authoritative": database_auth_can_authorize(resolved_mode), + "durable_lockout_configured": table_state["login_history"], + "session_revocation_configured": table_state["users"], + "identity_mutation_audit_configured": table_state["login_history"], + "least_privilege_role_matrix_configured": True, + "trusted_proxy_policy_configured": bool(parse_trusted_proxy_networks()), + "legacy_shared_password_retired": not legacy_auth_can_authorize(resolved_mode), + } + canary_ready = all( + value + for key, value in checks.items() + if key != "legacy_shared_password_retired" + ) + completed = canary_ready and checks["legacy_shared_password_retired"] + return { + "receipt_kind": "auth_identity_runtime_readback", + "policy": POLICY, + "status": "completed" if completed else "canary_ready" if canary_ready else "blocked", + "work_item_id": "SEC-P0-002", + "configured_auth_identity_mode": configured_mode, + "auth_identity_mode": resolved_mode, + "inventory": { + **inventory, + "permission_assignments": permission_assignments, + }, + "tables": table_state, + "auto_cutover": auto_cutover, + "checks": checks, + "canary_ready": canary_ready, + "runtime_closed": completed, + "remaining_blocker": ( + None + if completed + else "legacy_shared_password_authority_still_enabled" + if canary_ready + else "identity_canary_preconditions_failed" + ), + "next_machine_action": ( + "advance_to_SEC-P0-003" + if completed + else "capture_database_admin_login_receipts_for_automatic_cutover" + if configured_mode == "auto" + else "capture_database_login_canary_then_switch_to_database_mode" + if canary_ready + else "repair_identity_schema_or_active_admin_inventory" + ), + "safety": { + "read_only": True, + "secret_values_read": False, + "password_hashes_returned": False, + "raw_sessions_returned": False, + }, + } + + +__all__ = [ + "ADMIN_ROLES", + "AUTH_IDENTITY_MODES", + "KNOWN_ROLES", + "OPERATE_ROLES", + "POLICY", + "READ_ROLES", + "auth_auto_cutover_min_successes", + "database_auth_can_authorize", + "database_auth_is_evaluated", + "build_auth_identity_runtime_readback", + "identity_version", + "legacy_auth_can_authorize", + "parse_trusted_proxy_networks", + "public_policy_readback", + "required_roles_for_request", + "resolve_auth_identity_mode", + "resolve_effective_auth_identity_mode", + "resolve_client_ip", + "role_is_allowed", +] diff --git a/services/http_access_policy_service.py b/services/http_access_policy_service.py index d03027b..44ee1f9 100644 --- a/services/http_access_policy_service.py +++ b/services/http_access_policy_service.py @@ -4,6 +4,8 @@ from __future__ import annotations import ipaddress +from services.auth_identity_service import required_roles_for_request + POLICY = "ewoooc_sensitive_http_access_policy_v1" # Entire blueprints that expose business data or operator actions. @@ -111,7 +113,12 @@ def enforce_http_access_policy(): return None from auth import require_authenticated_request - return require_authenticated_request() + required_roles = required_roles_for_request( + request.method, + request.endpoint, + request.blueprint, + ) + return require_authenticated_request(*required_roles) __all__ = [ diff --git a/services/pchome_revenue_growth_service.py b/services/pchome_revenue_growth_service.py index 0d5b438..1249fad 100644 --- a/services/pchome_revenue_growth_service.py +++ b/services/pchome_revenue_growth_service.py @@ -682,6 +682,9 @@ def _fetch_catalog_mapping_summary(conn) -> dict[str, Any]: "catalog_product_count": 0, "mapped_product_count": 0, "catalog_mapping_rate": 0.0, + "active_catalog_product_count": 0, + "active_mapped_product_count": 0, + "active_catalog_mapping_rate": 0.0, } sales_rows = conn.execute(text( @@ -690,6 +693,49 @@ def _fetch_catalog_mapping_summary(conn) -> dict[str, Any]: f"WHERE {_quote_identifier(sku_column)} IS NOT NULL" )).fetchall() sales_ids = {str(row[0]).strip() for row in sales_rows if str(row[0] or "").strip()} + active_sales_ids: set[str] = set() + date_column = cols.get("date") + revenue_column = cols.get("revenue") + if date_column and revenue_column: + dialect = conn.dialect.name + sku_text = _as_text_expr(sku_column, dialect) + date_col = _quote_identifier(date_column) + sale_date_expr = ( + f"NULLIF({date_col}::text, '')::date" + if dialect == "postgresql" + else f"date({date_col})" + ) + active_window = ( + "lw.latest_date - INTERVAL '6 days'" + if dialect == "postgresql" + else "date(lw.latest_date, '-6 days')" + ) + active_rows = conn.execute(text(f""" + WITH sales_rows AS ( + SELECT + NULLIF(TRIM({sku_text}), '') AS product_id, + {sale_date_expr} AS sale_date, + {_numeric_expr(revenue_column, dialect)} AS revenue + FROM daily_sales_snapshot + WHERE {_quote_identifier(sku_column)} IS NOT NULL + ), + latest_window AS ( + SELECT MAX(sale_date) AS latest_date + FROM sales_rows + WHERE sale_date IS NOT NULL + ) + SELECT sr.product_id + FROM sales_rows sr + CROSS JOIN latest_window lw + WHERE sr.product_id IS NOT NULL + AND sr.sale_date >= {active_window} + GROUP BY sr.product_id + HAVING SUM(sr.revenue) > 0 + """)).fetchall() + active_sales_ids = { + str(row[0]).strip() for row in active_rows if str(row[0] or "").strip() + } + mapped_ids: set[str] = set() inspector = inspect(conn) if inspector.has_table("external_offers"): @@ -706,7 +752,12 @@ def _fetch_catalog_mapping_summary(conn) -> dict[str, Any]: """)).fetchall() mapped_ids.update(str(row[0]).strip() for row in rows if str(row[0] or "").strip()) if inspector.has_table("competitor_prices"): - rows = conn.execute(text(""" + identity_predicate = ( + "COALESCE(tags, '[]'::jsonb) ? 'identity_v2'" + if conn.dialect.name == "postgresql" + else "COALESCE(tags, '') LIKE '%identity_v2%'" + ) + rows = conn.execute(text(f""" SELECT DISTINCT competitor_product_id FROM competitor_prices WHERE source = 'pchome' @@ -715,15 +766,27 @@ def _fetch_catalog_mapping_summary(conn) -> dict[str, Any]: AND price > 0 AND COALESCE(match_score, 0) >= 0.76 AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) + AND {identity_predicate} """)).fetchall() mapped_ids.update(str(row[0]).strip() for row in rows if str(row[0] or "").strip()) mapped_catalog_ids = sales_ids & mapped_ids + active_mapped_ids = active_sales_ids & mapped_ids return { "catalog_product_count": len(sales_ids), "mapped_product_count": len(mapped_catalog_ids), "catalog_mapping_rate": round(len(mapped_catalog_ids) / max(len(sales_ids), 1) * 100, 2), "unmapped_product_count": max(0, len(sales_ids) - len(mapped_catalog_ids)), + "active_catalog_product_count": len(active_sales_ids), + "active_mapped_product_count": len(active_mapped_ids), + "active_catalog_mapping_rate": round( + len(active_mapped_ids) / max(len(active_sales_ids), 1) * 100, + 2, + ), + "active_unmapped_product_count": max( + 0, + len(active_sales_ids) - len(active_mapped_ids), + ), } @@ -1053,6 +1116,9 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] generated_at = datetime.now().isoformat(timespec="seconds") source_readiness = build_external_source_readiness(engine) review_candidate_count = int(source_readiness.get("review_offer_count") or 0) + registered_external_source_count = len(source_readiness.get("sources") or []) + active_external_source_count = int(source_readiness.get("active_count") or 0) + runtime_external_source_count = int(source_readiness.get("sources_with_data_count") or 0) active_external_sources = _source_names_by_status(source_readiness, "active") or list(ACTIVE_EXTERNAL_SOURCES) paused_external_sources = _source_names_by_status(source_readiness, "paused") or list(PAUSED_EXTERNAL_SOURCES) observed_external_sources = [ @@ -1084,6 +1150,14 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] "mapping_rate": 0, "needs_mapping_count": 0, "review_candidate_count": review_candidate_count, + "current_review_candidate_count": 0, + "candidate_validation_count": 0, + "unmatched_count": 0, + "comparison_ready_count": 0, + "decision_ready_count": 0, + "registered_external_source_count": registered_external_source_count, + "active_external_source_count": active_external_source_count, + "platforms_with_runtime_data": runtime_external_source_count, "sales_freshness": _sales_freshness(None), }, "opportunities": [], @@ -1109,6 +1183,14 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] "mapping_rate": 0, "needs_mapping_count": 0, "review_candidate_count": review_candidate_count, + "current_review_candidate_count": 0, + "candidate_validation_count": 0, + "unmatched_count": 0, + "comparison_ready_count": 0, + "decision_ready_count": 0, + "registered_external_source_count": registered_external_source_count, + "active_external_source_count": active_external_source_count, + "platforms_with_runtime_data": runtime_external_source_count, "total_sales_7d": 0, "opportunity_sales_7d": 0, "action_counts": {}, @@ -1145,6 +1227,24 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] needs_mapping_count = sum(1 for item in opportunities if not item.get("market_offers")) observed_mapping_count = len(opportunities) - needs_mapping_count mapped_count = sum(1 for item in opportunities if item.get("external_price")) + current_review_candidate_count = sum( + 1 + for item in opportunities + if not item.get("external_price") and item.get("review_candidate") + ) + candidate_validation_count = sum( + 1 + for item in opportunities + if not item.get("external_price") + and (item.get("review_candidate") or item.get("market_offers")) + ) + unmatched_count = sum( + 1 + for item in opportunities + if not item.get("external_price") + and not item.get("review_candidate") + and not item.get("market_offers") + ) mapping_rate = round(mapped_count / max(len(opportunities), 1) * 100, 1) observed_mapping_rate = round(observed_mapping_count / max(len(opportunities), 1) * 100, 1) action_counts: dict[str, int] = {} @@ -1168,6 +1268,57 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] latest_sales = latest_sales_date or sales_summary.get("overall_latest_sales_date") sales_freshness = _sales_freshness(latest_sales) + decision_ready_count = mapped_count if sales_freshness["decision_ready"] else 0 + decision_ready_rate = round( + decision_ready_count / max(len(opportunities), 1) * 100, + 1, + ) + blocked_reason_codes = [] + if not sales_freshness["decision_ready"]: + blocked_reason_codes.append("sales_data_stale") + if unmatched_count: + blocked_reason_codes.append("verified_same_item_missing") + if candidate_validation_count: + blocked_reason_codes.append("candidate_validation_pending") + if runtime_external_source_count < registered_external_source_count: + blocked_reason_codes.append("platform_runtime_coverage_gap") + + comparison_metric_contract = { + "version": "pchome_comparison_coverage_v2", + "scope": f"top_{len(opportunities)}_revenue_opportunities", + "numerator": mapped_count, + "denominator": len(opportunities), + "rate": mapping_rate, + "meaning": "通過同款與價格品質門檻、具正式外部價格證據的高優先商品", + "state_partition": { + "comparison_ready": mapped_count, + "candidate_validation": candidate_validation_count, + "unmatched": unmatched_count, + "total": len(opportunities), + "mutually_exclusive": ( + mapped_count + candidate_validation_count + unmatched_count + == len(opportunities) + ), + }, + "operational_decision": { + "ready": bool(sales_freshness["decision_ready"] and mapped_count), + "ready_count": decision_ready_count, + "ready_rate": decision_ready_rate, + "blocked_reason_codes": blocked_reason_codes, + }, + "active_catalog": { + "numerator": int(catalog_mapping_summary.get("active_mapped_product_count") or 0), + "denominator": int(catalog_mapping_summary.get("active_catalog_product_count") or 0), + "rate": _to_float(catalog_mapping_summary.get("active_catalog_mapping_rate")), + "scope": "products_with_positive_sales_in_latest_7_day_window", + }, + "platform_runtime": { + "numerator": runtime_external_source_count, + "denominator": registered_external_source_count, + "rate": _to_float(source_readiness.get("source_coverage_rate")), + "scope": "registered_external_market_sources_with_runtime_data", + }, + } return { "success": True, @@ -1180,18 +1331,29 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] "candidate_count": len(opportunities), "mapped_count": mapped_count, "mapping_rate": mapping_rate, + "comparison_ready_count": mapped_count, + "decision_ready_count": decision_ready_count, + "decision_ready_rate": decision_ready_rate, "observed_mapping_count": observed_mapping_count, "observed_mapping_rate": observed_mapping_rate, "needs_mapping_count": needs_mapping_count, "review_candidate_count": review_candidate_count, + "current_review_candidate_count": current_review_candidate_count, + "candidate_validation_count": candidate_validation_count, + "unmatched_count": unmatched_count, + "unresolved_count": max(0, len(opportunities) - mapped_count), "total_sales_7d": round(sum(_to_float(item.get("sales_7d")) for item in opportunities), 2), "opportunity_sales_7d": round(sum(_to_float(item.get("sales_7d")) for item in opportunities), 2), "action_counts": action_counts, "action_code_counts": action_code_counts, "external_data_source_counts": external_data_source_counts, "external_platform_counts": external_platform_counts, - "platforms_with_runtime_data": int(source_readiness.get("sources_with_data_count") or 0), + "registered_external_source_count": registered_external_source_count, + "active_external_source_count": active_external_source_count, + "platforms_with_runtime_data": runtime_external_source_count, "platform_source_coverage_rate": _to_float(source_readiness.get("source_coverage_rate")), + "comparison_metric_contract": comparison_metric_contract, + "blocked_reason_codes": blocked_reason_codes, **catalog_mapping_summary, **sales_summary, }, diff --git a/services/permission_service.py b/services/permission_service.py index 28507be..d1f3c8d 100644 --- a/services/permission_service.py +++ b/services/permission_service.py @@ -11,11 +11,41 @@ from database.permission_models import ( Permission, UserPermission, PERMISSIONS, ROLE_DEFAULT_PERMISSIONS, ALL_PERMISSION_CODES ) +from database.user_models import LoginHistory, User class PermissionService: """權限服務類""" + @staticmethod + def _add_permission_audit( + session, + user, + *, + granted_by=None, + audit_context=None, + change, + permission_count, + ): + """Add a permission event to the same transaction as the mutation.""" + context = audit_context or {} + raw_actor_id = context.get('actor_id', granted_by) + try: + actor = f"actor_id={int(raw_actor_id)}" + except (TypeError, ValueError): + actor = "actor_id=system" + session.add(LoginHistory( + user_id=user.id, + username_attempted=(user.username or '')[:50] or None, + login_time=datetime.now(), + ip_address=str(context.get('ip_address') or '')[:45] or None, + user_agent=str(context.get('user_agent') or '')[:256] or None, + status=LoginHistory.STATUS_PERMISSION_CHANGED, + failure_reason=( + f"{actor};change={change};count={int(permission_count)}" + )[:100], + )) + # ======================================================================== # 權限定義查詢 # ======================================================================== @@ -142,7 +172,12 @@ class PermissionService: return all_permissions @staticmethod - def set_user_permissions(user_id, permission_codes, granted_by=None): + def set_user_permissions( + user_id, + permission_codes, + granted_by=None, + audit_context=None, + ): """設定用戶的權限(完全覆蓋) Args: @@ -155,12 +190,24 @@ class PermissionService: """ session = get_session() try: + permission_codes = sorted(set(permission_codes)) # 驗證權限代碼是否有效 valid_codes = set(ALL_PERMISSION_CODES) invalid_codes = set(permission_codes) - valid_codes if invalid_codes: return False, f"無效的權限代碼: {', '.join(invalid_codes)}" + user = session.query(User).filter(User.id == user_id).first() + if not user: + return False, "用戶不存在" + + current_codes = { + row.permission_code + for row in session.query(UserPermission).filter_by(user_id=user_id).all() + } + if current_codes == set(permission_codes): + return True, f"權限未變更(共 {len(permission_codes)} 項)" + # 刪除舊權限 session.query(UserPermission).filter_by(user_id=user_id).delete() @@ -175,6 +222,14 @@ class PermissionService: ) session.add(user_perm) + PermissionService._add_permission_audit( + session, + user, + granted_by=granted_by, + audit_context=audit_context, + change="replace", + permission_count=len(permission_codes), + ) session.commit() return True, f"已設定 {len(permission_codes)} 項權限" @@ -185,7 +240,12 @@ class PermissionService: session.close() @staticmethod - def add_user_permission(user_id, permission_code, granted_by=None): + def add_user_permission( + user_id, + permission_code, + granted_by=None, + audit_context=None, + ): """新增單一用戶權限 Args: @@ -202,6 +262,10 @@ class PermissionService: if permission_code not in ALL_PERMISSION_CODES: return False, f"無效的權限代碼: {permission_code}" + user = session.query(User).filter(User.id == user_id).first() + if not user: + return False, "用戶不存在" + # 檢查是否已存在 existing = session.query(UserPermission).filter_by( user_id=user_id, @@ -218,6 +282,14 @@ class PermissionService: granted_at=datetime.utcnow() ) session.add(user_perm) + PermissionService._add_permission_audit( + session, + user, + granted_by=granted_by, + audit_context=audit_context, + change="add", + permission_count=1, + ) session.commit() return True, "權限已新增" @@ -229,7 +301,12 @@ class PermissionService: session.close() @staticmethod - def remove_user_permission(user_id, permission_code): + def remove_user_permission( + user_id, + permission_code, + granted_by=None, + audit_context=None, + ): """移除單一用戶權限 Args: @@ -241,11 +318,24 @@ class PermissionService: """ session = get_session() try: + user = session.query(User).filter(User.id == user_id).first() + if not user: + return False, "用戶不存在" + deleted = session.query(UserPermission).filter_by( user_id=user_id, permission_code=permission_code ).delete() + if deleted: + PermissionService._add_permission_audit( + session, + user, + granted_by=granted_by, + audit_context=audit_context, + change="remove", + permission_count=1, + ) session.commit() if deleted: @@ -264,7 +354,7 @@ class PermissionService: # ======================================================================== @staticmethod - def apply_role_template(user_id, role, granted_by=None): + def apply_role_template(user_id, role, granted_by=None, audit_context=None): """套用角色預設權限模板 Args: @@ -279,7 +369,12 @@ class PermissionService: return False, f"無效的角色: {role}" permission_codes = ROLE_DEFAULT_PERMISSIONS[role] - return PermissionService.set_user_permissions(user_id, permission_codes, granted_by) + return PermissionService.set_user_permissions( + user_id, + permission_codes, + granted_by, + audit_context=audit_context, + ) @staticmethod def get_role_template(role): @@ -373,7 +468,12 @@ class PermissionService: # ======================================================================== @staticmethod - def init_user_permissions_by_role(user_id, role, granted_by=None): + def init_user_permissions_by_role( + user_id, + role, + granted_by=None, + audit_context=None, + ): """根據角色初始化用戶權限(用於新建用戶) Args: @@ -384,7 +484,12 @@ class PermissionService: Returns: tuple: (success, message) """ - return PermissionService.apply_role_template(user_id, role, granted_by) + return PermissionService.apply_role_template( + user_id, + role, + granted_by, + audit_context=audit_context, + ) # ======================================================================== # 快取相關(可選優化) diff --git a/services/security_governance_review_service.py b/services/security_governance_review_service.py index e7a1530..a158b96 100644 --- a/services/security_governance_review_service.py +++ b/services/security_governance_review_service.py @@ -23,6 +23,7 @@ ASSET_INVENTORY_PATH = Path("governance/ewoooc_asset_inventory.json") QUALITY_BASELINE_PATH = Path("governance/security_governance_review_baseline.json") SOURCE_RECEIPT_PATH = Path("governance/security_governance_source_receipt.json") RUNTIME_RECEIPT_PATH = Path("governance/security_governance_runtime_receipt.json") +AUTH_IDENTITY_RUNTIME_RECEIPT_PATH = Path("governance/auth_identity_runtime_receipt.json") MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} AUTH_DECORATOR_MARKERS = ( @@ -355,6 +356,88 @@ def _runtime_receipt(root: Path) -> dict[str, Any]: } +def _auth_identity_source_posture(root: Path) -> dict[str, Any]: + files = { + "auth": _read(root / "auth.py"), + "policy": _read(root / "services" / "auth_identity_service.py"), + "user_service": _read(root / "services" / "user_service.py"), + "permission_service": _read(root / "services" / "permission_service.py"), + "user_models": _read(root / "database" / "user_models.py"), + "http_policy": _read(root / "services" / "http_access_policy_service.py"), + "compose": _read(root / "docker-compose.yml"), + } + markers = { + "database_authority": "database_auth_can_authorize" in files["auth"], + "durable_lockout": "get_lockout_status" in files["user_service"] + and "login_history" in files["policy"], + "session_revocation": "identity_version" in files["auth"] + and "session_revoked" in files["auth"], + "role_matrix": "required_roles_for_request" in files["http_policy"] + and "ROLE_MANAGER" in files["policy"], + "trusted_proxy_policy": "MOMO_TRUSTED_PROXY_CIDRS" in files["compose"] + and "resolve_client_ip" in files["auth"], + "identity_mutation_audit": "_add_identity_audit" in files["user_service"] + and "STATUS_IDENTITY_CHANGED" in files["user_models"], + "permission_mutation_audit": "_add_permission_audit" in files["permission_service"] + and "STATUS_PERMISSION_CHANGED" in files["user_models"], + "runtime_readback": ( + root / "scripts" / "ops" / "report_auth_identity_governance.py" + ).is_file(), + } + return { + "markers": markers, + "source_ready": all(markers.values()), + } + + +def _auth_identity_runtime_receipt(root: Path) -> dict[str, Any]: + path = root / AUTH_IDENTITY_RUNTIME_RECEIPT_PATH + try: + payload = json.loads(_read(path)) + except (TypeError, json.JSONDecodeError): + payload = {} + valid = bool( + isinstance(payload, dict) + and payload.get("receipt_kind") == "auth_identity_runtime_readback" + and payload.get("work_item_id") == "SEC-P0-002" + ) + checks = payload.get("checks") if valid else {} + inventory = payload.get("inventory") if valid else {} + checks = checks if isinstance(checks, dict) else {} + inventory = inventory if isinstance(inventory, dict) else {} + runtime_closed = bool( + valid + and payload.get("runtime_closed") is True + and payload.get("auth_identity_mode") == "database" + and checks.get("legacy_shared_password_retired") is True + ) + return { + "path": AUTH_IDENTITY_RUNTIME_RECEIPT_PATH.as_posix(), + "available": valid, + "generated_at": payload.get("generated_at") if valid else None, + "status": payload.get("status") if valid else None, + "configured_auth_identity_mode": ( + payload.get("configured_auth_identity_mode") if valid else None + ), + "auth_identity_mode": payload.get("auth_identity_mode") if valid else None, + "canary_ready": bool(payload.get("canary_ready")) if valid else False, + "runtime_closed": runtime_closed, + "active_admin_count": int(inventory.get("active_admin_count") or 0), + "users_total": int(inventory.get("users_total") or 0), + "login_history_events": int(inventory.get("login_history_events") or 0), + "identity_mutation_events": int( + inventory.get("identity_mutation_events") or 0 + ), + "permission_assignments": int(inventory.get("permission_assignments") or 0), + "legacy_shared_password_retired": bool( + checks.get("legacy_shared_password_retired") + ), + "remaining_blocker": payload.get("remaining_blocker") if valid else None, + "next_machine_action": payload.get("next_machine_action") if valid else None, + "secret_values_read": False, + } + + def _docker_build_context_posture(root: Path) -> dict[str, Any]: path = root / ".dockerignore" patterns = { @@ -479,6 +562,8 @@ def _check( def _work_items( checks: dict[str, dict[str, Any]], runtime_receipt: dict[str, Any], + auth_identity_source: dict[str, Any], + auth_identity_runtime: dict[str, Any], ) -> list[dict[str, Any]]: access_closed = runtime_receipt["security_readback_passed"] access_next_action = ( @@ -491,9 +576,29 @@ def _work_items( if runtime_receipt["security_readback_passed"] else "deploy_current_source_then_read_back_security_matrix_and_container_identity" ) + identity_status = ( + "completed" + if auth_identity_runtime["runtime_closed"] + else "in_progress" + if auth_identity_source["source_ready"] or auth_identity_runtime["canary_ready"] + else "not_started" + ) + if identity_status == "completed": + identity_next_action = "advance_to_SEC-P0-003" + elif auth_identity_runtime["canary_ready"]: + identity_next_action = ( + auth_identity_runtime.get("next_machine_action") + or "capture_database_admin_login_receipts_for_automatic_cutover" + ) + elif auth_identity_source["source_ready"]: + identity_next_action = "deploy_hybrid_database_identity_then_write_runtime_receipt" + else: + identity_next_action = ( + "implement_database_identity_durable_lockout_session_revocation_and_role_matrix" + ) return [ {"order": 1, "id": "SEC-P0-001", "priority": "P0", "status": "completed" if access_closed else "in_progress", "lane": "deny_by_default_access", "objective": "Close all sensitive and mutating routes behind central auth or signed internal transport.", "next_machine_action": access_next_action}, - {"order": 2, "id": "SEC-P0-002", "priority": "P0", "status": "not_started", "lane": "identity_rbac", "objective": "Replace the shared admin password with database-backed identities, least-privilege RBAC, durable lockout and auditable sessions.", "next_machine_action": "inventory_active_users_then_shadow_database_auth_before_cutover"}, + {"order": 2, "id": "SEC-P0-002", "priority": "P0", "status": identity_status, "lane": "identity_rbac", "objective": "Replace the shared admin password with database-backed identities, least-privilege RBAC, durable lockout and auditable sessions.", "next_machine_action": identity_next_action}, {"order": 3, "id": "SEC-P0-003", "priority": "P0", "status": "in_progress", "lane": "webhook_trust", "objective": "Require Telegram and internal webhook transport secrets with replay protection.", "next_machine_action": "provision_telegram_webhook_secret_then_enable_required_mode_and_canary"}, {"order": 4, "id": "SUPPLY-P0-001", "priority": "P0", "status": "in_progress", "lane": "software_supply_chain", "objective": "Use Gitea-only checkout, exact locks, SAST/SCA/secret scan, SBOM, image digest and provenance gates.", "next_machine_action": "add_internal_scanner_artifacts_and_exact_dependency_lock"}, {"order": 5, "id": "GOV-P0-001", "priority": "P0", "status": "in_progress", "lane": "asset_graph", "objective": "Reconcile every host, service, data, AI, route, package and evidence asset against runtime truth.", "next_machine_action": "probe_all_assets_and_write_same_run_reconciliation_receipt"}, @@ -553,6 +658,12 @@ def build_security_governance_review( dependencies = _dependency_posture(scan_root) quality = _quality_baseline(scan_root) runtime_receipt = _runtime_receipt(scan_root) + auth_identity_source = _auth_identity_source_posture(scan_root) + auth_identity_runtime = _auth_identity_runtime_receipt(scan_root) + identity_runtime_next_action = ( + auth_identity_runtime.get("next_machine_action") + or "capture_database_admin_login_receipts_for_automatic_cutover" + ) large_files = _large_python_files(scan_root) app_source = _read(scan_root / "app.py") alert_source = _read(scan_root / "routes" / "alert_routes.py") @@ -658,6 +769,18 @@ def build_security_governance_review( "enforce_csp_after_report_only_violation_cleanup", release_blocking=bool(missing_headers) or not session_secure_default, ), + _check( + "PR.AC-database-identity-rbac", + "PROTECT/DETECT", + "pass" if auth_identity_runtime["runtime_closed"] else "partial" if auth_identity_source["source_ready"] or auth_identity_runtime["canary_ready"] else "fail", + "critical", + "Database-only identity authority, durable lockout, revocable sessions and role enforcement are closed in production." if auth_identity_runtime["runtime_closed"] else "Database identity source is implemented; production hybrid/database canary and shared-authority retirement remain distinct gates." if auth_identity_source["source_ready"] else "Database identity, durable lockout, revocable sessions or role enforcement source is incomplete.", + { + "source": auth_identity_source, + "runtime": auth_identity_runtime, + }, + "advance_to_SEC-P0-003" if auth_identity_runtime["runtime_closed"] else identity_runtime_next_action if auth_identity_runtime["canary_ready"] else "deploy_hybrid_database_identity_then_write_runtime_receipt", + ), _check( "PR.AA-production-security-readback", "PROTECT/DETECT", @@ -800,6 +923,7 @@ def build_security_governance_review( ) runtime_checks = [ checks_by_id["PR.AA-production-security-readback"], + checks_by_id["PR.AC-database-identity-rbac"], checks_by_id["DE.CM-mcp-rag-runtime"], checks_by_id["AI.RAG-internal-candidate-canary"], checks_by_id["RC.RP-restore-drill"], @@ -837,6 +961,7 @@ def build_security_governance_review( "checks": checks, "quality_baseline": quality, "runtime_receipt": runtime_receipt, + "auth_identity_runtime_receipt": auth_identity_runtime, "source_receipt": { key: value for key, value in source_receipt.items() if key != "_checks" }, @@ -849,7 +974,12 @@ def build_security_governance_review( ], "debt_does_not_equal_release_pass": True, }, - "work_items": _work_items(checks_by_id, runtime_receipt), + "work_items": _work_items( + checks_by_id, + runtime_receipt, + auth_identity_source, + auth_identity_runtime, + ), "safety": { "read_only": True, "network_calls": False, @@ -859,7 +989,13 @@ def build_security_governance_review( "external_side_effects": False, }, "next_machine_action": ( - "inventory_active_users_then_shadow_database_auth_before_cutover" + "advance_to_SEC-P0-003" + if auth_identity_runtime["runtime_closed"] + else identity_runtime_next_action + if auth_identity_runtime["canary_ready"] + else "deploy_hybrid_database_identity_then_write_runtime_receipt" + if runtime_receipt["security_readback_passed"] and auth_identity_source["source_ready"] + else "inventory_active_users_then_shadow_database_auth_before_cutover" if runtime_receipt["security_readback_passed"] else "deny_public_metrics_then_verify_internal_prometheus_canary" ), diff --git a/services/user_service.py b/services/user_service.py index afbb5d2..6a00279 100644 --- a/services/user_service.py +++ b/services/user_service.py @@ -7,7 +7,8 @@ - 登入歷史記錄 """ -from datetime import datetime +from datetime import datetime, timedelta +from sqlalchemy import func, or_ from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError @@ -32,11 +33,42 @@ class UserService: """ self.db = db_session + def _add_identity_audit(self, user, status, audit_context=None, change=None): + """Add a no-secret audit event to the caller's current transaction.""" + context = audit_context or {} + try: + actor_id = int(context.get('actor_id')) + actor = f"actor_id={actor_id}" + except (TypeError, ValueError): + actor = "actor_id=system" + + reason_parts = [actor] + if change: + reason_parts.append(f"change={change}") + self.db.add(LoginHistory( + user_id=user.id, + username_attempted=(user.username or '')[:50] or None, + login_time=datetime.now(), + ip_address=str(context.get('ip_address') or '')[:45] or None, + user_agent=str(context.get('user_agent') or '')[:256] or None, + status=status[:20], + failure_reason=';'.join(reason_parts)[:100], + )) + # ========================================== # 用戶 CRUD 操作 # ========================================== - def create_user(self, username, password, role='user', email=None, display_name=None, created_by=None): + def create_user( + self, + username, + password, + role='user', + email=None, + display_name=None, + created_by=None, + audit_context=None, + ): """ 建立新用戶 @@ -53,6 +85,10 @@ class UserService: - user: User 物件(成功時)或 None(失敗時) - error_message: 錯誤訊息(成功時為 None) """ + username = (username or '').strip() + if not username: + return None, "帳號名稱不能為空" + # 驗證密碼複雜度 is_valid, error = validate_password_complexity(password) if not is_valid: @@ -86,6 +122,13 @@ class UserService: created_by=created_by ) self.db.add(user) + self.db.flush() + self._add_identity_audit( + user, + LoginHistory.STATUS_USER_CREATED, + audit_context, + change=f"role:{role}", + ) self.db.commit() self.db.refresh(user) @@ -93,7 +136,12 @@ class UserService: if role != 'admin': try: from services.permission_service import PermissionService - PermissionService.init_user_permissions_by_role(user.id, role, created_by) + PermissionService.init_user_permissions_by_role( + user.id, + role, + created_by, + audit_context=audit_context, + ) except Exception as perm_err: print(f"⚠️ 初始化用戶權限失敗: {perm_err}") @@ -128,7 +176,10 @@ class UserService: Returns: User 或 None """ - return self.db.query(User).filter(User.username == username).first() + normalized = (username or '').strip().lower() + if not normalized: + return None + return self.db.query(User).filter(func.lower(User.username) == normalized).first() def get_all_users(self, include_inactive=False): """ @@ -145,7 +196,7 @@ class UserService: query = query.filter(User.is_active == True) return query.order_by(User.username).all() - def update_user(self, user_id, **kwargs): + def update_user(self, user_id, audit_context=None, **kwargs): """ 更新用戶資料 @@ -162,24 +213,36 @@ class UserService: allowed_fields = ['email', 'display_name', 'role', 'is_active'] + requested = {key: value for key, value in kwargs.items() if key in allowed_fields} + if 'role' in requested and requested['role'] not in User.ROLES: + return False, f"無效的角色:{requested['role']}" + if 'is_active' in requested and not isinstance(requested['is_active'], bool): + return False, "is_active 必須是布林值" + if requested.get('email'): + existing = self.db.query(User).filter( + User.email == requested['email'], + User.id != user_id, + ).first() + if existing: + return False, f"電子郵件 '{requested['email']}' 已被使用" + + changed_fields = sorted( + key for key, value in requested.items() + if getattr(user, key) != value + ) + if not changed_fields: + return True, None + try: - for key, value in kwargs.items(): - if key in allowed_fields: - # 驗證角色 - if key == 'role' and value not in User.ROLES: - return False, f"無效的角色:{value}" - # 檢查 email 唯一性 - if key == 'email' and value: - existing = self.db.query(User).filter( - User.email == value, - User.id != user_id - ).first() - if existing: - return False, f"電子郵件 '{value}' 已被使用" - - setattr(user, key, value) - + for key in changed_fields: + setattr(user, key, requested[key]) user.updated_at = datetime.now() + self._add_identity_audit( + user, + LoginHistory.STATUS_IDENTITY_CHANGED, + audit_context, + change=f"fields:{','.join(changed_fields)}", + ) self.db.commit() return True, None @@ -187,7 +250,7 @@ class UserService: self.db.rollback() return False, f"更新失敗:{str(e)}" - def change_password(self, user_id, old_password, new_password): + def change_password(self, user_id, old_password, new_password, audit_context=None): """ 變更密碼 @@ -220,6 +283,12 @@ class UserService: user.password_hash = hash_password(new_password) user.password_changed_at = datetime.now() user.updated_at = datetime.now() + self._add_identity_audit( + user, + LoginHistory.STATUS_PASSWORD_CHANGED, + audit_context, + change="self_service", + ) self.db.commit() return True, None @@ -227,7 +296,7 @@ class UserService: self.db.rollback() return False, f"變更密碼失敗:{str(e)}" - def reset_password(self, user_id, new_password, admin_id=None): + def reset_password(self, user_id, new_password, admin_id=None, audit_context=None): """ 重設密碼(管理員功能) @@ -252,6 +321,15 @@ class UserService: user.password_hash = hash_password(new_password) user.password_changed_at = datetime.now() user.updated_at = datetime.now() + event_context = dict(audit_context or {}) + if admin_id is not None: + event_context.setdefault('actor_id', admin_id) + self._add_identity_audit( + user, + LoginHistory.STATUS_PASSWORD_CHANGED, + event_context, + change="admin_reset", + ) self.db.commit() print(f"🔐 密碼重設 | User: {user.username} | Admin ID: {admin_id}") @@ -261,7 +339,7 @@ class UserService: self.db.rollback() return False, f"重設密碼失敗:{str(e)}" - def delete_user(self, user_id): + def delete_user(self, user_id, audit_context=None): """ 刪除用戶(實際是停用) @@ -287,6 +365,12 @@ class UserService: try: user.is_active = False user.updated_at = datetime.now() + self._add_identity_audit( + user, + LoginHistory.STATUS_USER_DISABLED, + audit_context, + change="active:false", + ) self.db.commit() return True, None @@ -350,12 +434,12 @@ class UserService: try: history = LoginHistory( user_id=user_id, - username_attempted=username_attempted, + username_attempted=(username_attempted or '')[:50] or None, login_time=datetime.now(), - ip_address=ip_address, + ip_address=(ip_address or '')[:45] or None, user_agent=user_agent[:256] if user_agent else None, # 限制長度 - status=status, - failure_reason=failure_reason + status=(status or '')[:20], + failure_reason=(failure_reason or '')[:100] or None, ) self.db.add(history) self.db.commit() @@ -366,6 +450,132 @@ class UserService: print(f"⚠️ 記錄登入歷史失敗: {e}") return None + def get_lockout_status( + self, + username=None, + ip_address=None, + *, + max_attempts=5, + reset_seconds=1800, + lockout_seconds=300, + now=None, + ): + """Return a durable lockout decision backed by login_history.""" + current_time = now or datetime.now() + username = (username or '').strip().lower() + ip_address = (ip_address or '').strip() + scope = [] + if username: + scope.append(func.lower(LoginHistory.username_attempted) == username) + if ip_address: + scope.append(LoginHistory.ip_address == ip_address) + if not scope: + return False, 0, 0 + + scope_filter = or_(*scope) + since = current_time - timedelta(seconds=max(1, int(reset_seconds))) + + latest_success = self.db.query(func.max(LoginHistory.login_time)).filter( + scope_filter, + LoginHistory.status == LoginHistory.STATUS_SUCCESS, + ).scalar() + if latest_success and latest_success > since: + since = latest_success + + latest_lock = self.db.query(func.max(LoginHistory.login_time)).filter( + scope_filter, + LoginHistory.status == LoginHistory.STATUS_LOCKED, + LoginHistory.login_time >= since, + ).scalar() + if latest_lock: + lock_expires_at = latest_lock + timedelta(seconds=max(1, int(lockout_seconds))) + if current_time < lock_expires_at: + remaining = max(1, int((lock_expires_at - current_time).total_seconds())) + return True, remaining, int(max_attempts) + since = max(since, lock_expires_at) + + failures = self.db.query(LoginHistory).filter( + scope_filter, + LoginHistory.status == LoginHistory.STATUS_FAILED, + LoginHistory.login_time >= since, + ).order_by(LoginHistory.login_time.desc()).all() + failure_count = len(failures) + if failure_count < int(max_attempts): + return False, 0, failure_count + + locked_until = failures[0].login_time + timedelta(seconds=max(1, int(lockout_seconds))) + if current_time < locked_until: + remaining = max(1, int((locked_until - current_time).total_seconds())) + return True, remaining, failure_count + return False, 0, 0 + + def get_identity_inventory(self): + """Return non-secret identity and audit counts for runtime governance.""" + role_rows = self.db.query( + User.role, + User.is_active, + func.count(User.id), + ).group_by(User.role, User.is_active).all() + mutation_statuses = ( + LoginHistory.STATUS_IDENTITY_CHANGED, + LoginHistory.STATUS_PASSWORD_CHANGED, + LoginHistory.STATUS_USER_CREATED, + LoginHistory.STATUS_USER_DISABLED, + LoginHistory.STATUS_PERMISSION_CHANGED, + ) + mutation_rows = self.db.query( + LoginHistory.status, + func.count(LoginHistory.id), + ).filter( + LoginHistory.status.in_(mutation_statuses), + ).group_by(LoginHistory.status).all() + return { + 'users_total': self.db.query(func.count(User.id)).scalar() or 0, + 'active_admin_count': self.db.query(func.count(User.id)).filter( + User.role == User.ROLE_ADMIN, + User.is_active.is_(True), + ).scalar() or 0, + 'users_by_role': [ + { + 'role': role or 'unassigned', + 'is_active': bool(is_active), + 'count': count, + } + for role, is_active, count in role_rows + ], + 'login_history_events': self.db.query(func.count(LoginHistory.id)).scalar() or 0, + 'identity_mutation_events': sum(count for _, count in mutation_rows), + 'identity_mutation_events_by_status': { + status: count for status, count in mutation_rows + }, + } + + def get_auth_auto_cutover_state(self, *, min_successes=2): + """Return the durable admin-login gate used by automatic cutover.""" + threshold = max(2, min(int(min_successes), 10)) + event_count, distinct_admins = self.db.query( + func.count(LoginHistory.id), + func.count(func.distinct(LoginHistory.user_id)), + ).join( + User, + LoginHistory.user_id == User.id, + ).filter( + LoginHistory.status == LoginHistory.STATUS_SUCCESS, + LoginHistory.failure_reason == 'auth_source=database', + User.role == User.ROLE_ADMIN, + User.is_active.is_(True), + ).one() + event_count = int(event_count or 0) + distinct_admins = int(distinct_admins or 0) + return { + 'policy': 'durable_admin_login_receipt_threshold', + 'minimum_successes': threshold, + 'database_admin_successes': event_count, + 'distinct_database_admins': distinct_admins, + 'ready': event_count >= threshold and distinct_admins >= 1, + 'manual_review_required': False, + } + def get_login_history(self, user_id=None, limit=100): """ 取得登入歷史記錄 diff --git a/templates/ai_intelligence.html b/templates/ai_intelligence.html index 5d4ffad..d5cb2c1 100644 --- a/templates/ai_intelligence.html +++ b/templates/ai_intelligence.html @@ -88,6 +88,12 @@ padding: 7px 12px; } + .growth-command-status-pill.is-degraded { + border-color: rgba(176, 93, 39, 0.28); + background: rgba(255, 241, 230, 0.96); + color: #8b421f; + } + .growth-command-action-group { display: inline-flex; flex-wrap: wrap; @@ -150,6 +156,11 @@ padding: 13px; } + .growth-command-card.is-degraded { + border-color: rgba(176, 93, 39, 0.34); + background: rgba(255, 249, 244, 0.96); + } + .growth-command-card.is-clickable { cursor: pointer; transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; @@ -195,6 +206,16 @@ display: none; } + .growth-command-kpi-note.is-essential { + display: block; + min-height: 2.4em; + margin-top: 9px; + color: var(--momo-text-muted); + font-size: 0.68rem; + font-weight: 800; + line-height: 1.2; + } + .growth-command-spark { display: block; width: 100%; @@ -2864,7 +2885,7 @@