546 lines
19 KiB
Python
546 lines
19 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Account creation CLI commands (add-web, add-secret)."""
|
||
import json
|
||
import os
|
||
import sys
|
||
import time
|
||
from typing import Optional
|
||
|
||
from db.accounts_repo import (
|
||
get_account_by_id,
|
||
insert_account,
|
||
insert_credential,
|
||
resolve_auth_strategy_for_platform,
|
||
)
|
||
from db.auth_strategy import (
|
||
ALL_AUTH_STRATEGIES,
|
||
AUTH_STRATEGY_DEVICE_BOUND,
|
||
)
|
||
from db.connection import get_conn, init_db
|
||
from db.credentials_repo import insert_credential_encrypted
|
||
from service._svc_common import (
|
||
PlatformCallerMeta,
|
||
_derive_session_persistent,
|
||
_err_json,
|
||
_insert_secret_holder_account,
|
||
_ok_json,
|
||
_resolve_or_auto_register,
|
||
_resolve_or_fail,
|
||
_say,
|
||
_say_err,
|
||
)
|
||
from service.crypto import CredentialDecryptError
|
||
from service.master_key import MasterKeyMissingError, is_master_key_available
|
||
from service.secret_store import (
|
||
mask_secret,
|
||
make_windows_credential_target,
|
||
write_secret,
|
||
)
|
||
from util.logging_config import get_skill_logger
|
||
from util.platforms import get_platform_spec, seed_platforms
|
||
from util.profile_shortcut import create_profile_browser_shortcut
|
||
from util.runtime_paths import get_default_profile_dir_for_web
|
||
|
||
|
||
def cmd_account_add_web(
|
||
platform_input: str,
|
||
login_id: Optional[str],
|
||
login_id_type: str,
|
||
label: Optional[str],
|
||
tenant_id: Optional[str],
|
||
environment: str,
|
||
role: str,
|
||
provider_code: Optional[str],
|
||
url: Optional[str],
|
||
extra_json_str: Optional[str],
|
||
profile_dir_override: Optional[str],
|
||
*,
|
||
auth_strategy: Optional[str] = None,
|
||
session_persistent: Optional[int] = None,
|
||
device_fingerprint: Optional[str] = None,
|
||
platform_meta: Optional[PlatformCallerMeta] = None,
|
||
) -> None:
|
||
"""account add-web — create a web/RPA account record."""
|
||
log = get_skill_logger()
|
||
log.info("account_add_web_attempt platform=%s login_id_type=%s env=%s role=%s",
|
||
platform_input, login_id_type, environment, role)
|
||
|
||
key = _resolve_or_auto_register(platform_input, platform_meta)
|
||
if not key:
|
||
return
|
||
|
||
init_db()
|
||
now = int(time.time())
|
||
conn = get_conn()
|
||
try:
|
||
seed_platforms(conn)
|
||
platform_spec = get_platform_spec(key, conn=conn)
|
||
finally:
|
||
conn.close()
|
||
|
||
safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {environment} {role}"
|
||
extra = {}
|
||
if extra_json_str:
|
||
try:
|
||
extra = json.loads(extra_json_str)
|
||
except json.JSONDecodeError:
|
||
_say(_err_json("ERROR:CLI_BAD_ARGS", "extra_json 不是合法的 JSON 字符串。"))
|
||
return
|
||
|
||
if profile_dir_override:
|
||
resolved_profile_dir = os.path.abspath(profile_dir_override)
|
||
else:
|
||
resolved_profile_dir = get_default_profile_dir_for_web(
|
||
platform_key=key,
|
||
label=safe_label,
|
||
login_id=login_id,
|
||
domain=platform_spec.get("domain", "generic"),
|
||
)
|
||
os.makedirs(resolved_profile_dir, exist_ok=True)
|
||
|
||
resolved_url = (url or "").strip() or platform_spec.get("default_url", "")
|
||
resolved_provider = provider_code or platform_spec.get("provider_code") or key
|
||
resolved_extra = json.dumps(extra, ensure_ascii=False)
|
||
|
||
conn = get_conn()
|
||
try:
|
||
seed_platforms(conn)
|
||
if auth_strategy is None:
|
||
resolved_auth = resolve_auth_strategy_for_platform(conn, key)
|
||
else:
|
||
if auth_strategy not in ALL_AUTH_STRATEGIES:
|
||
_say(_err_json(
|
||
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
|
||
f"不支持的 auth_strategy:{auth_strategy}",
|
||
))
|
||
return
|
||
resolved_auth = auth_strategy
|
||
|
||
if session_persistent is None:
|
||
sp = _derive_session_persistent(resolved_auth)
|
||
else:
|
||
if session_persistent not in (0, 1):
|
||
_say(_err_json(
|
||
"ERR_INVALID_SESSION_PERSISTENT",
|
||
"session_persistent 必须是 0 或 1。",
|
||
))
|
||
return
|
||
sp = session_persistent
|
||
|
||
if device_fingerprint is not None and resolved_auth != AUTH_STRATEGY_DEVICE_BOUND:
|
||
_say(_err_json(
|
||
"ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE",
|
||
"device_fingerprint 仅在 auth_strategy=device_bound 时可设置。",
|
||
))
|
||
return
|
||
|
||
new_id = insert_account(
|
||
conn=conn,
|
||
platform_key=key,
|
||
account_label=safe_label,
|
||
login_id=login_id or None,
|
||
login_id_type=login_id_type,
|
||
tenant_id=tenant_id or None,
|
||
environment=environment,
|
||
role=role,
|
||
provider_code=resolved_provider,
|
||
profile_dir=resolved_profile_dir,
|
||
url=resolved_url,
|
||
extra_json=resolved_extra,
|
||
now=now,
|
||
auth_strategy=resolved_auth,
|
||
session_persistent=sp,
|
||
device_fingerprint=device_fingerprint,
|
||
)
|
||
conn.commit()
|
||
except Exception:
|
||
conn.rollback()
|
||
log.exception("account_add_web_failure")
|
||
_say(_err_json("ERROR:DB_ERROR", "Database insert failed."))
|
||
return
|
||
finally:
|
||
conn.close()
|
||
|
||
shortcut_path = create_profile_browser_shortcut(resolved_profile_dir, safe_label, log)
|
||
|
||
log.info("account_add_web_success id=%s platform=%s profile_dir=%s", new_id, key, resolved_profile_dir)
|
||
ok_payload = {
|
||
"id": new_id,
|
||
"platform_key": key,
|
||
"account_label": safe_label,
|
||
"profile_dir": resolved_profile_dir,
|
||
"url": resolved_url,
|
||
"environment": environment,
|
||
"role": role,
|
||
"tenant_id": tenant_id or "",
|
||
"provider_code": resolved_provider,
|
||
"status": "active",
|
||
"session_status": "unknown",
|
||
"auth_strategy": resolved_auth,
|
||
"session_persistent": sp,
|
||
"device_fingerprint": device_fingerprint,
|
||
}
|
||
if shortcut_path:
|
||
ok_payload["profile_shortcut"] = shortcut_path
|
||
_say(_ok_json(ok_payload))
|
||
|
||
|
||
def cmd_account_add_secret(
|
||
platform_input: str,
|
||
credential_type: str,
|
||
label: Optional[str],
|
||
secret_storage: str,
|
||
secret_stdin: bool,
|
||
secret_ref: Optional[str],
|
||
account_id: Optional[int],
|
||
environment: Optional[str],
|
||
role: Optional[str],
|
||
extra_json_str: Optional[str],
|
||
) -> None:
|
||
"""account add-secret — create a credential record with masked secret."""
|
||
log = get_skill_logger()
|
||
log.info("account_add_secret_attempt platform=%s type=%s storage=%s",
|
||
platform_input, credential_type, secret_storage)
|
||
|
||
key = _resolve_or_fail(platform_input)
|
||
if not key:
|
||
return
|
||
|
||
storage_n = secret_storage.strip().lower()
|
||
if storage_n not in ("none", "env", "windows_credential", "local_encrypted"):
|
||
_say(_err_json("ERROR:SECRET_STORAGE_UNSUPPORTED",
|
||
f"不支持的存储方式:{secret_storage}。支持:none / env / windows_credential / local_encrypted"))
|
||
return
|
||
|
||
if storage_n == "none" and credential_type not in ("browser_profile", "note"):
|
||
_say(_err_json("ERROR:SECRET_STORAGE_UNSUPPORTED",
|
||
"storage=none 仅允许 credential_type=browser_profile 或 note。"))
|
||
return
|
||
if storage_n == "env" and not secret_ref:
|
||
_say(_err_json("ERROR:SECRET_REF_MISSING",
|
||
"env 存储时必须提供 --secret-ref 环境变量名。"))
|
||
return
|
||
|
||
init_db()
|
||
conn = get_conn()
|
||
try:
|
||
seed_platforms(conn)
|
||
platform_spec = get_platform_spec(key, conn=conn)
|
||
finally:
|
||
conn.close()
|
||
now = int(time.time())
|
||
safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {credential_type}"
|
||
|
||
if storage_n == "local_encrypted":
|
||
if not is_master_key_available():
|
||
_say(_err_json(
|
||
"ERR_MASTER_KEY_MISSING",
|
||
"master key 未初始化。先跑 `account init-master-key`。",
|
||
))
|
||
return
|
||
if not secret_stdin:
|
||
_say(_err_json(
|
||
"ERR_LOCAL_ENCRYPTED_REQUIRES_STDIN",
|
||
"local_encrypted 后端必须 --secret-stdin 输入密文,避免命令历史泄漏。",
|
||
))
|
||
return
|
||
if credential_type not in (
|
||
"password",
|
||
"api_key",
|
||
"api_token",
|
||
"bearer_token",
|
||
"refresh_token",
|
||
"oauth_client",
|
||
):
|
||
_say(_err_json(
|
||
"ERR_LOCAL_ENCRYPTED_TYPE_NOT_ALLOWED",
|
||
f"local_encrypted 不接受 credential_type={credential_type}(仅 secret 类)。",
|
||
))
|
||
return
|
||
if secret_ref:
|
||
_say_err("[add-secret] WARNING: secret_ref ignored for local_encrypted (secret comes from stdin).")
|
||
|
||
extra: dict = {}
|
||
if extra_json_str:
|
||
try:
|
||
extra = json.loads(extra_json_str)
|
||
except json.JSONDecodeError:
|
||
extra = {}
|
||
resolved_env = environment or "production"
|
||
resolved_role = role or "api"
|
||
extra["environment"] = resolved_env
|
||
extra["role"] = resolved_role
|
||
resolved_extra = json.dumps(extra, ensure_ascii=False)
|
||
resolved_url = (platform_spec.get("default_url") or "").strip() or None
|
||
resolved_provider = platform_spec.get("provider_code") or key
|
||
|
||
plaintext = sys.stdin.readline().rstrip("\r\n")
|
||
if not plaintext:
|
||
_say(_err_json("ERR_LOCAL_ENCRYPTED_EMPTY_STDIN", "stdin 没读到密码内容。"))
|
||
return
|
||
secret_mask_out = mask_secret(plaintext)
|
||
|
||
conn = get_conn()
|
||
cred_id: Optional[int] = None
|
||
holder_id: Optional[int] = None
|
||
try:
|
||
seed_platforms(conn)
|
||
holder_id = account_id
|
||
if holder_id is not None:
|
||
acc = get_account_by_id(holder_id)
|
||
if not acc:
|
||
_say(_err_json(
|
||
"ERROR:ACCOUNT_NOT_FOUND",
|
||
f"account_id={holder_id} 不存在。",
|
||
))
|
||
return
|
||
if holder_id is None:
|
||
holder_id = _insert_secret_holder_account(
|
||
conn,
|
||
platform_key=key,
|
||
account_label=safe_label,
|
||
environment=resolved_env,
|
||
role=resolved_role,
|
||
provider_code=resolved_provider,
|
||
url=resolved_url,
|
||
now=now,
|
||
)
|
||
cred_id = insert_credential_encrypted(
|
||
conn,
|
||
account_id=holder_id,
|
||
platform_key=key,
|
||
credential_label=safe_label,
|
||
credential_type=credential_type,
|
||
plaintext=plaintext,
|
||
expires_at=None,
|
||
extra_json=resolved_extra,
|
||
now=now,
|
||
)
|
||
conn.commit()
|
||
except MasterKeyMissingError as e:
|
||
conn.rollback()
|
||
_say(_err_json("ERR_MASTER_KEY_MISSING", str(e)))
|
||
return
|
||
except CredentialDecryptError as e:
|
||
conn.rollback()
|
||
_say(_err_json("ERR_CREDENTIAL_DECRYPT_FAILED", str(e)))
|
||
return
|
||
except Exception:
|
||
conn.rollback()
|
||
log.exception("account_add_secret_failure")
|
||
_say(_err_json("ERROR:DB_ERROR", "Database insert failed."))
|
||
return
|
||
finally:
|
||
conn.close()
|
||
|
||
log.info(
|
||
"account_add_secret_encrypted_success cred_id=%s account_id=%s",
|
||
cred_id,
|
||
holder_id,
|
||
)
|
||
_say(_ok_json({
|
||
"credential_id": cred_id,
|
||
"account_id": holder_id,
|
||
"platform_key": key,
|
||
"credential_type": credential_type,
|
||
"secret_storage": "local_encrypted",
|
||
"secret_mask": secret_mask_out,
|
||
}))
|
||
return
|
||
|
||
if storage_n == "env":
|
||
secret_ref_value = secret_ref.strip()
|
||
secret_mask_value = mask_secret(f"env:{secret_ref_value}")
|
||
extra = {}
|
||
if extra_json_str:
|
||
try:
|
||
extra = json.loads(extra_json_str)
|
||
except json.JSONDecodeError:
|
||
extra = {}
|
||
resolved_env = environment or "production"
|
||
resolved_role = role or "api"
|
||
extra["environment"] = resolved_env
|
||
extra["role"] = resolved_role
|
||
extra_json_final = json.dumps(extra, ensure_ascii=False)
|
||
|
||
resolved_url = (platform_spec.get("default_url") or "").strip() or None
|
||
resolved_provider = platform_spec.get("provider_code") or key
|
||
|
||
conn = get_conn()
|
||
try:
|
||
seed_platforms(conn)
|
||
holder_id = account_id
|
||
if holder_id is None:
|
||
holder_id = _insert_secret_holder_account(
|
||
conn,
|
||
platform_key=key,
|
||
account_label=safe_label,
|
||
environment=resolved_env,
|
||
role=resolved_role,
|
||
provider_code=resolved_provider,
|
||
url=resolved_url,
|
||
now=now,
|
||
)
|
||
cred_id = insert_credential(
|
||
conn=conn,
|
||
account_id=holder_id,
|
||
platform_key=key,
|
||
credential_label=safe_label,
|
||
credential_type=credential_type,
|
||
secret_storage=storage_n,
|
||
secret_ref=secret_ref_value,
|
||
secret_mask=secret_mask_value,
|
||
expires_at=None,
|
||
extra_json=extra_json_final,
|
||
now=now,
|
||
)
|
||
conn.commit()
|
||
except Exception:
|
||
conn.rollback()
|
||
log.exception("account_add_secret_failure")
|
||
_say(_err_json("ERROR:DB_ERROR", "Database insert failed."))
|
||
return
|
||
finally:
|
||
conn.close()
|
||
|
||
log.info("account_add_secret_success id=%s platform=%s storage=env ref=%s",
|
||
cred_id, key, secret_ref_value)
|
||
_say(_ok_json({
|
||
"account_id": holder_id,
|
||
"credential_id": cred_id,
|
||
"platform_key": key,
|
||
"credential_label": safe_label,
|
||
"credential_type": credential_type,
|
||
"secret_storage": storage_n,
|
||
"secret_ref": secret_ref_value,
|
||
"secret_mask": secret_mask_value,
|
||
"warning": "env 存储方式:请确保环境变量在运行时可用。",
|
||
}))
|
||
return
|
||
|
||
if storage_n == "windows_credential":
|
||
if not secret_stdin:
|
||
_say(_err_json("ERROR:CLI_BAD_ARGS",
|
||
"windows_credential 需要 --secret-stdin 从 stdin 读取 secret。"))
|
||
return
|
||
secret_value = sys.stdin.readline().strip()
|
||
if not secret_value:
|
||
_say(_err_json("ERROR:CLI_BAD_ARGS", "stdin 中未读到 secret 值。"))
|
||
return
|
||
|
||
secret_mask_value = mask_secret(secret_value)
|
||
cred_label_for_target = safe_label
|
||
target = make_windows_credential_target(key, credential_type, cred_label_for_target)
|
||
|
||
try:
|
||
write_secret("windows_credential", target, secret_value)
|
||
except (OSError, ValueError) as e:
|
||
log.error("secret_write_failed storage=windows_credential error=%s", str(e))
|
||
_say(_err_json("ERROR:SECRET_WRITE_FAILED", f"写 Windows Credential Manager 失败:{e}"))
|
||
return
|
||
|
||
extra = {}
|
||
if extra_json_str:
|
||
try:
|
||
extra = json.loads(extra_json_str)
|
||
except json.JSONDecodeError:
|
||
extra = {}
|
||
resolved_env = environment or "production"
|
||
resolved_role = role or "api"
|
||
extra["environment"] = resolved_env
|
||
extra["role"] = resolved_role
|
||
extra_json_final = json.dumps(extra, ensure_ascii=False)
|
||
|
||
resolved_url = (platform_spec.get("default_url") or "").strip() or None
|
||
resolved_provider = platform_spec.get("provider_code") or key
|
||
|
||
conn = get_conn()
|
||
try:
|
||
seed_platforms(conn)
|
||
holder_id = account_id
|
||
if holder_id is None:
|
||
holder_id = _insert_secret_holder_account(
|
||
conn,
|
||
platform_key=key,
|
||
account_label=safe_label,
|
||
environment=resolved_env,
|
||
role=resolved_role,
|
||
provider_code=resolved_provider,
|
||
url=resolved_url,
|
||
now=now,
|
||
)
|
||
cred_id = insert_credential(
|
||
conn=conn,
|
||
account_id=holder_id,
|
||
platform_key=key,
|
||
credential_label=safe_label,
|
||
credential_type=credential_type,
|
||
secret_storage=storage_n,
|
||
secret_ref=target,
|
||
secret_mask=secret_mask_value,
|
||
expires_at=None,
|
||
extra_json=extra_json_final,
|
||
now=now,
|
||
)
|
||
conn.commit()
|
||
except Exception:
|
||
conn.rollback()
|
||
log.exception("account_add_secret_failure")
|
||
_say(_err_json("ERROR:DB_ERROR", "Database insert failed."))
|
||
return
|
||
finally:
|
||
conn.close()
|
||
|
||
log.info("account_add_secret_success id=%s platform=%s storage=windows_credential",
|
||
cred_id, key)
|
||
secret_value = ""
|
||
|
||
_say(_ok_json({
|
||
"account_id": holder_id,
|
||
"credential_id": cred_id,
|
||
"platform_key": key,
|
||
"credential_label": safe_label,
|
||
"credential_type": credential_type,
|
||
"secret_storage": storage_n,
|
||
"secret_ref": target,
|
||
"secret_mask": secret_mask_value,
|
||
}))
|
||
return
|
||
|
||
conn = get_conn()
|
||
try:
|
||
seed_platforms(conn)
|
||
cred_id = insert_credential(
|
||
conn=conn,
|
||
account_id=account_id,
|
||
platform_key=key,
|
||
credential_label=safe_label,
|
||
credential_type=credential_type,
|
||
secret_storage=storage_n,
|
||
secret_ref=None,
|
||
secret_mask="",
|
||
expires_at=None,
|
||
extra_json=extra_json_str or "{}",
|
||
now=now,
|
||
)
|
||
conn.commit()
|
||
except Exception:
|
||
conn.rollback()
|
||
log.exception("account_add_secret_failure")
|
||
_say(_err_json("ERROR:DB_ERROR", "Database insert failed."))
|
||
return
|
||
finally:
|
||
conn.close()
|
||
|
||
log.info("account_add_secret_success id=%s platform=%s storage=none", cred_id, key)
|
||
_say(_ok_json({
|
||
"account_id": account_id,
|
||
"credential_id": cred_id,
|
||
"platform_key": key,
|
||
"credential_label": safe_label,
|
||
"credential_type": credential_type,
|
||
"secret_storage": storage_n,
|
||
"secret_ref": "",
|
||
"secret_mask": "",
|
||
}))
|