# -*- coding: utf-8 -*- """ Accounts, Credentials, Leases, and Platforms CRUD (no stdout). All functions operate on a connection (passed or opened locally). Time fields are INTEGER Unix seconds (UTC). """ import json import time from datetime import datetime from typing import Any, Optional from db.auth_strategy import ALL_AUTH_STRATEGIES, AUTH_STRATEGY_PASSWORD_AUTO from db.connection import get_conn, init_db from db.platform_defaults import PLATFORM_DEFAULT_AUTH_STRATEGY from util.logging_config import get_skill_logger # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _now_unix() -> int: return int(time.time()) def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]: """Convert Unix timestamp to local ISO8601 string, or None.""" if ts is None: return None try: return datetime.fromtimestamp(int(ts)).isoformat(timespec="seconds") except (ValueError, OSError, OverflowError): return None _UNSET = object() def resolve_auth_strategy_for_platform(conn, platform_key: str) -> str: """Pick auth_strategy from platforms.default_auth_strategy, built-in map, or password_auto.""" cur = conn.cursor() cur.execute( "SELECT default_auth_strategy FROM platforms WHERE platform_key = ?", (platform_key,), ) row = cur.fetchone() if row and row[0]: s = str(row[0]) if s in ALL_AUTH_STRATEGIES: return s mapped = PLATFORM_DEFAULT_AUTH_STRATEGY.get(platform_key) if mapped and mapped in ALL_AUTH_STRATEGIES: return mapped return AUTH_STRATEGY_PASSWORD_AUTO def _acc_row_to_dict(row) -> dict[str, Any]: """Convert an accounts table row tuple to a dict matching the v2 schema.""" dev_fp = row[20] return { "id": row[0], "platform_key": row[1], "account_label": row[2], "login_id": row[3] or "", "login_id_type": row[4], "tenant_id": row[5] or "", "environment": row[6], "role": row[7], "provider_code": row[8] or "", "profile_dir": (row[9] or "").strip(), "url": row[10] or "", "status": row[11], "session_status": row[12], "last_used_at": _unix_to_iso_local(row[13]), "last_login_check_at": _unix_to_iso_local(row[14]), "last_error_code": row[15] or "", "last_error_message": row[16] or "", "extra_json": _safe_json_loads(row[17]), "auth_strategy": row[18], "session_persistent": int(row[19]) if row[19] is not None else 1, "device_fingerprint": dev_fp if dev_fp is not None else None, "created_at": _unix_to_iso_local(row[21]), "updated_at": _unix_to_iso_local(row[22]), } # ============================================================================ # Platforms # ============================================================================ def platform_exists(conn, platform_key: str) -> bool: cur = conn.cursor() cur.execute("SELECT 1 FROM platforms WHERE platform_key = ?", (platform_key,)) return cur.fetchone() is not None def list_platforms_from_db(conn, domain: Optional[str] = None) -> list[dict]: cur = conn.cursor() if domain: cur.execute( "SELECT platform_key, display_name, domain, provider_code, " "default_url, aliases_json, capabilities_json, enabled, " "default_auth_strategy, created_at, updated_at FROM platforms WHERE domain = ? ORDER BY platform_key", (domain,), ) else: cur.execute( "SELECT platform_key, display_name, domain, provider_code, " "default_url, aliases_json, capabilities_json, enabled, " "default_auth_strategy, created_at, updated_at FROM platforms ORDER BY platform_key" ) rows = cur.fetchall() result = [] for r in rows: result.append({ "platform_key": r[0], "display_name": r[1], "domain": r[2], "provider_code": r[3] or "", "default_url": r[4] or "", "aliases": _safe_json_loads(r[5], []), "capabilities": _safe_json_loads(r[6], {}), "enabled": bool(r[7]), "default_auth_strategy": r[8], "created_at": _unix_to_iso_local(r[9]), "updated_at": _unix_to_iso_local(r[10]), }) return result def get_platform_from_db(conn, platform_key: str) -> Optional[dict]: cur = conn.cursor() cur.execute( "SELECT platform_key, display_name, domain, provider_code, " "default_url, aliases_json, capabilities_json, enabled, " "default_auth_strategy, created_at, updated_at FROM platforms WHERE platform_key = ?", (platform_key,), ) r = cur.fetchone() if not r: return None return { "platform_key": r[0], "display_name": r[1], "domain": r[2], "provider_code": r[3] or "", "default_url": r[4] or "", "aliases": _safe_json_loads(r[5], []), "capabilities": _safe_json_loads(r[6], {}), "enabled": bool(r[7]), "default_auth_strategy": r[8], "created_at": _unix_to_iso_local(r[9]), "updated_at": _unix_to_iso_local(r[10]), } # ============================================================================ # Accounts — CRUD # ============================================================================ def insert_account( conn, platform_key: str, account_label: str, login_id: Optional[str], login_id_type: str, tenant_id: Optional[str], environment: str, role: str, provider_code: Optional[str], profile_dir: Optional[str], url: Optional[str], extra_json: Optional[str], now: int, auth_strategy: str, session_persistent: int = 1, device_fingerprint: Optional[str] = None, ) -> int: if auth_strategy not in ALL_AUTH_STRATEGIES: raise ValueError(f"invalid auth_strategy: {auth_strategy!r}") if session_persistent not in (0, 1): raise ValueError("session_persistent must be 0 or 1") cur = conn.cursor() cur.execute( """ INSERT INTO accounts (platform_key, account_label, login_id, login_id_type, tenant_id, environment, role, provider_code, profile_dir, url, status, session_status, extra_json, auth_strategy, session_persistent, device_fingerprint, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'unknown', ?, ?, ?, ?, ?, ?) """, ( platform_key, account_label, login_id, login_id_type, tenant_id, environment, role, provider_code, profile_dir, url, extra_json or "{}", auth_strategy, session_persistent, device_fingerprint, now, now, ), ) return int(cur.lastrowid) def get_account_by_id(account_id) -> Optional[dict]: """Get a single account by id. Returns dict or None.""" conn = get_conn() try: conn.row_factory = None cur = conn.cursor() cur.execute( """ SELECT id, platform_key, account_label, login_id, login_id_type, tenant_id, environment, role, provider_code, profile_dir, url, status, session_status, last_used_at, last_login_check_at, last_error_code, last_error_message, extra_json, auth_strategy, session_persistent, device_fingerprint, created_at, updated_at FROM accounts WHERE id = ? """, (int(account_id),), ) row = cur.fetchone() if not row: return None return _acc_row_to_dict(row) finally: conn.close() def find_accounts( platform_key: Optional[str] = None, environment: Optional[str] = None, tenant_id: Optional[str] = None, role: Optional[str] = None, status: Optional[str] = None, provider_code: Optional[str] = None, limit: int = 200, ) -> list[dict]: """Find accounts by optional filters. Returns list of dicts.""" conn = get_conn() try: conn.row_factory = None cur = conn.cursor() conditions = [] params = [] if platform_key: conditions.append("platform_key = ?") params.append(platform_key) if environment: conditions.append("environment = ?") params.append(environment) if tenant_id: conditions.append("tenant_id = ?") params.append(tenant_id) if role: conditions.append("role = ?") params.append(role) if status: conditions.append("status = ?") params.append(status) if provider_code: conditions.append("provider_code = ?") params.append(provider_code) where = "" if conditions: where = "WHERE " + " AND ".join(conditions) sql = ( "SELECT id, platform_key, account_label, login_id, login_id_type, " "tenant_id, environment, role, provider_code, profile_dir, url, " "status, session_status, last_used_at, last_login_check_at, " "last_error_code, last_error_message, extra_json, " "auth_strategy, session_persistent, device_fingerprint, " "created_at, updated_at " f"FROM accounts {where} ORDER BY updated_at DESC, id DESC LIMIT ?" ) params.append(int(limit)) cur.execute(sql, params) return [_acc_row_to_dict(row) for row in cur.fetchall()] finally: conn.close() def find_account_ids( platform_key: str, environment: Optional[str] = None, tenant_id: Optional[str] = None, role: Optional[str] = None, ) -> list[int]: """Return account IDs matching filters, excluding those with active leases.""" conn = get_conn() try: conn.row_factory = None cur = conn.cursor() conditions = ["a.status = 'active'", "a.platform_key = ?"] params = [platform_key] if environment: conditions.append("a.environment = ?") params.append(environment) if tenant_id: conditions.append("a.tenant_id = ?") params.append(tenant_id) if role: conditions.append("a.role = ?") params.append(role) # Exclude accounts with active non-expired leases now = _now_unix() conditions.append( "a.id NOT IN (SELECT account_id FROM account_leases " "WHERE status = 'active' AND expires_at > ?)" ) params.append(now) where = " AND ".join(conditions) cur.execute( f"SELECT a.id FROM accounts a WHERE {where} " "ORDER BY CASE WHEN a.session_status = 'expired' THEN 1 ELSE 0 END ASC, " "a.last_used_at IS NULL DESC, a.updated_at DESC, a.id DESC", params, ) return [int(row[0]) for row in cur.fetchall()] finally: conn.close() def update_account_last_used(account_id: int) -> None: conn = get_conn() try: cur = conn.cursor() cur.execute( "UPDATE accounts SET last_used_at = ?, updated_at = ? WHERE id = ?", (_now_unix(), _now_unix(), account_id), ) conn.commit() finally: conn.close() def update_account_session_status(account_id: int, session_status: str) -> None: conn = get_conn() try: cur = conn.cursor() cur.execute( "UPDATE accounts SET session_status = ?, last_login_check_at = ?, updated_at = ? WHERE id = ?", (session_status, _now_unix(), _now_unix(), account_id), ) conn.commit() finally: conn.close() def update_account_error( account_id: int, error_code: str, error_message: str, ) -> None: conn = get_conn() try: cur = conn.cursor() cur.execute( "UPDATE accounts SET last_error_code = ?, last_error_message = ?, updated_at = ? WHERE id = ?", (error_code, error_message, _now_unix(), account_id), ) conn.commit() finally: conn.close() def update_account( conn, account_id: int, *, auth_strategy: Optional[str] = None, session_persistent: Optional[int] = None, device_fingerprint: Any = _UNSET, ) -> None: """Optional field updates for accounts v2 columns.""" sets: list[str] = [] params: list[Any] = [] if auth_strategy is not None: if auth_strategy not in ALL_AUTH_STRATEGIES: raise ValueError(f"invalid auth_strategy: {auth_strategy!r}") sets.append("auth_strategy = ?") params.append(auth_strategy) if session_persistent is not None: if session_persistent not in (0, 1): raise ValueError("session_persistent must be 0 or 1") sets.append("session_persistent = ?") params.append(session_persistent) if device_fingerprint is not _UNSET: sets.append("device_fingerprint = ?") params.append(device_fingerprint) if not sets: return sets.append("updated_at = ?") params.append(_now_unix()) params.append(int(account_id)) cur = conn.cursor() cur.execute( f"UPDATE accounts SET {', '.join(sets)} WHERE id = ?", tuple(params), ) def account_exists_with_login_id(conn, platform_key: str, login_id: str, environment: str, role: str) -> bool: cur = conn.cursor() cur.execute( "SELECT id FROM accounts WHERE platform_key = ? AND login_id = ? AND environment = ? AND role = ?", (platform_key, login_id, environment, role), ) return cur.fetchone() is not None # ============================================================================ # Credentials # ============================================================================ def insert_credential( conn, account_id: Optional[int], platform_key: str, credential_label: str, credential_type: str, secret_storage: str, secret_ref: Optional[str], secret_mask: Optional[str], expires_at: Optional[int], extra_json: Optional[str], now: int, secret_ciphertext: Optional[str] = None, ) -> int: cur = conn.cursor() cur.execute( """ INSERT INTO credentials (account_id, platform_key, credential_label, credential_type, secret_storage, secret_ref, secret_mask, secret_ciphertext, expires_at, status, extra_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?) """, ( account_id, platform_key, credential_label, credential_type, secret_storage, secret_ref, secret_mask, secret_ciphertext, expires_at, extra_json or "{}", now, now, ), ) return int(cur.lastrowid) def get_credential_by_id(credential_id: int) -> Optional[dict]: conn = get_conn() try: conn.row_factory = None cur = conn.cursor() cur.execute( "SELECT id, account_id, platform_key, credential_label, credential_type, " "secret_storage, secret_ref, secret_mask, expires_at, status, last_used_at, " "extra_json, created_at, updated_at FROM credentials WHERE id = ?", (credential_id,), ) row = cur.fetchone() if not row: return None return { "id": row[0], "account_id": row[1], "platform_key": row[2], "credential_label": row[3], "credential_type": row[4], "secret_storage": row[5], "secret_ref": row[6] or "", "secret_mask": row[7] or "", "expires_at": _unix_to_iso_local(row[8]), "status": row[9], "last_used_at": _unix_to_iso_local(row[10]), "created_at": _unix_to_iso_local(row[12]), "updated_at": _unix_to_iso_local(row[13]), } finally: conn.close() def find_credentials( platform_key: str, credential_type: Optional[str] = None, environment: Optional[str] = None, role: Optional[str] = None, status: str = "active", limit: int = 50, ) -> list[dict]: """Find credentials, optionally joining accounts for environment/role filter.""" conn = get_conn() try: conn.row_factory = None cur = conn.cursor() conditions = ["c.status = ?"] params = [status] conditions.append("c.platform_key = ?") params.append(platform_key) if credential_type: conditions.append("c.credential_type = ?") params.append(credential_type) # If environment/role filters provided, join accounts table if environment or role: conditions.append("c.account_id = a.id") if environment: conditions.append("a.environment = ?") params.append(environment) if role: conditions.append("a.role = ?") params.append(role) where = " AND ".join(conditions) cur.execute( f"SELECT c.id, c.account_id, c.platform_key, c.credential_label, " f"c.credential_type, c.secret_storage, c.secret_ref, c.secret_mask, " f"c.expires_at, c.status, c.last_used_at, c.extra_json, " f"c.created_at, c.updated_at " f"FROM credentials c, accounts a WHERE {where} " f"ORDER BY c.last_used_at IS NULL DESC, c.updated_at DESC, c.id DESC " f"LIMIT ?", params + [int(limit)], ) else: where = " AND ".join(conditions) cur.execute( f"SELECT c.id, c.account_id, c.platform_key, c.credential_label, " f"c.credential_type, c.secret_storage, c.secret_ref, c.secret_mask, " f"c.expires_at, c.status, c.last_used_at, c.extra_json, " f"c.created_at, c.updated_at " f"FROM credentials c WHERE {where} " f"ORDER BY c.last_used_at IS NULL DESC, c.updated_at DESC, c.id DESC " f"LIMIT ?", params + [int(limit)], ) rows = cur.fetchall() result = [] for row in rows: result.append({ "id": row[0], "account_id": row[1], "platform_key": row[2], "credential_label": row[3], "credential_type": row[4], "secret_storage": row[5], "secret_ref": row[6] or "", "secret_mask": row[7] or "", "expires_at": _unix_to_iso_local(row[8]), "status": row[9], "last_used_at": _unix_to_iso_local(row[10]), "created_at": _unix_to_iso_local(row[12]), "updated_at": _unix_to_iso_local(row[13]), }) return result finally: conn.close() def update_credential_last_used(credential_id: int) -> None: conn = get_conn() try: cur = conn.cursor() cur.execute( "UPDATE credentials SET last_used_at = ?, updated_at = ? WHERE id = ?", (_now_unix(), _now_unix(), credential_id), ) conn.commit() finally: conn.close() # ============================================================================ # Leases # ============================================================================ def acquire_lease( account_id: int, lease_token: str, holder: str, purpose: Optional[str], ttl_sec: int, ) -> dict: """ Acquire a lease for an account. Expires active leases first. Returns the lease record dict on success. Raises ValueError if a non-expired active lease already exists. """ log = get_skill_logger() log.info("lease_acquire_attempt account_id=%s holder=%s purpose=%s ttl=%s", account_id, holder, purpose, ttl_sec) conn = get_conn() try: conn.row_factory = None cur = conn.cursor() now = _now_unix() # Mark any expired active leases as expired cur.execute( "UPDATE account_leases SET status = 'expired', updated_at = ? " "WHERE account_id = ? AND status = 'active' AND expires_at <= ?", (now, account_id, now), ) if cur.rowcount > 0: log.info("lease_expired_cleanup account_id=%s count=%s", account_id, cur.rowcount) # Check for existing active non-expired lease cur.execute( "SELECT id, lease_token, holder FROM account_leases " "WHERE account_id = ? AND status = 'active' AND expires_at > ?", (account_id, now), ) existing = cur.fetchone() if existing: log.warning( "lease_conflict account_id=%s existing_holder=%s existing_token_prefix=%s", account_id, existing[2], existing[1][:8], ) raise ValueError( f"Account {account_id} already has an active lease " f"(holder={existing[2]}, token_prefix={existing[1][:8]}) (LEASE_CONFLICT)" ) expires_at = now + max(1, int(ttl_sec)) cur.execute( """ INSERT INTO account_leases (account_id, lease_token, holder, purpose, expires_at, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'active', ?, ?) """, (account_id, lease_token, holder, purpose, expires_at, now, now), ) conn.commit() lease_id = int(cur.lastrowid) log.info("lease_acquire_success account_id=%s lease_id=%s token_prefix=%s", account_id, lease_id, lease_token[:8]) return { "id": lease_id, "account_id": account_id, "lease_token": lease_token, "holder": holder, "purpose": purpose, "expires_at": _unix_to_iso_local(expires_at), "status": "active", "created_at": _unix_to_iso_local(now), } finally: conn.close() def release_lease(lease_token: str) -> bool: """Release a lease by token. Returns True if found and released.""" log = get_skill_logger() log.info("lease_release_attempt token_prefix=%s", lease_token[:8]) conn = get_conn() try: cur = conn.cursor() now = _now_unix() cur.execute( "UPDATE account_leases SET status = 'released', released_at = ?, updated_at = ? " "WHERE lease_token = ? AND status = 'active'", (now, now, lease_token), ) conn.commit() if cur.rowcount == 0: log.warning("lease_release_not_found token_prefix=%s", lease_token[:8]) return False log.info("lease_release_success token_prefix=%s", lease_token[:8]) return True finally: conn.close() def list_active_leases() -> list[dict]: """List all active (non-expired, non-released) leases.""" conn = get_conn() try: cur = conn.cursor() now = _now_unix() cur.execute( "SELECT id, account_id, lease_token, holder, purpose, expires_at, " "heartbeat_at, released_at, status, created_at, updated_at " "FROM account_leases WHERE status = 'active' AND expires_at > ? " "ORDER BY created_at DESC", (now,), ) rows = cur.fetchall() result = [] for r in rows: result.append({ "id": r[0], "account_id": r[1], "lease_token": r[2], "holder": r[3], "purpose": r[4] or "", "expires_at": _unix_to_iso_local(r[5]), "heartbeat_at": _unix_to_iso_local(r[6]), "released_at": _unix_to_iso_local(r[7]), "status": r[8], "created_at": _unix_to_iso_local(r[9]), "updated_at": _unix_to_iso_local(r[10]), }) return result finally: conn.close() def cleanup_expired_leases() -> int: """Mark all expired active leases as expired. Returns count cleaned.""" log = get_skill_logger() conn = get_conn() try: cur = conn.cursor() now = _now_unix() cur.execute( "UPDATE account_leases SET status = 'expired', updated_at = ? " "WHERE status = 'active' AND expires_at <= ?", (now, now), ) count = cur.rowcount conn.commit() if count > 0: log.info("lease_cleanup_expired count=%s", count) return count finally: conn.close() def get_active_lease_by_token(conn, lease_token: str) -> Optional[dict[str, Any]]: """Look up a lease by token: active and not expired. Returns dict with id, account_id, lease_token, holder, purpose, expires_at (Unix sec), status; or None if not found / inactive / expired. """ cur = conn.cursor() now = _now_unix() cur.execute( """ SELECT id, account_id, lease_token, holder, purpose, expires_at, status FROM account_leases WHERE lease_token = ? AND status = 'active' AND expires_at > ? """, (lease_token, now), ) row = cur.fetchone() if not row: return None return { "id": row[0], "account_id": row[1], "lease_token": row[2], "holder": row[3], "purpose": row[4], "expires_at": int(row[5]), "status": row[6], } def upsert_platform( conn, key: str, display_name: str, domain: str = "generic", provider_code: str | None = None, default_url: str = "", aliases_json: str = "[]", capabilities_json: str = '{"web": true, "api_key": false, "rpa": true}', default_auth_strategy: str | None = None, ) -> None: """幂等地注册或更新平台到 DB。""" now = int(time.time()) if provider_code is None: provider_code = key conn.execute( """ INSERT INTO platforms ( platform_key, display_name, domain, provider_code, default_url, aliases_json, capabilities_json, enabled, default_auth_strategy, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?) ON CONFLICT(platform_key) DO UPDATE SET display_name = excluded.display_name, domain = excluded.domain, provider_code = excluded.provider_code, default_url = excluded.default_url, aliases_json = excluded.aliases_json, capabilities_json = excluded.capabilities_json, enabled = 1, default_auth_strategy = COALESCE(excluded.default_auth_strategy, platforms.default_auth_strategy), updated_at = excluded.updated_at """, ( key, display_name, domain, provider_code, default_url, aliases_json, capabilities_json, default_auth_strategy, now, now, ), ) conn.commit() # ============================================================================ # Internal helpers # ============================================================================ def _safe_json_loads(val: Any, default: Any = None) -> Any: if val is None: return default try: return json.loads(val) if isinstance(val, str) else val except (json.JSONDecodeError, TypeError): return default