326 lines
11 KiB
Python
326 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Account query and pick CLI commands."""
|
||
import json
|
||
import uuid
|
||
from typing import Optional
|
||
|
||
from db.accounts_repo import (
|
||
acquire_lease,
|
||
find_account_ids,
|
||
find_accounts,
|
||
find_credentials,
|
||
get_account_by_id,
|
||
get_active_lease_by_token,
|
||
update_account_last_used,
|
||
)
|
||
from db.connection import get_conn, init_db
|
||
from db.credentials_repo import (
|
||
CredentialNotEncryptedError,
|
||
list_credentials_by_account,
|
||
read_credential_plaintext,
|
||
)
|
||
from service._svc_common import (
|
||
PlatformCallerMeta,
|
||
_err_json,
|
||
_resolve_or_auto_register,
|
||
_resolve_or_fail,
|
||
_say,
|
||
_say_err,
|
||
)
|
||
from service.crypto import CredentialDecryptError
|
||
from service.master_key import MasterKeyMissingError
|
||
from util.logging_config import get_skill_logger
|
||
from util.platforms import seed_platforms
|
||
from util.runtime_paths import _runtime_paths_debug_text
|
||
|
||
|
||
def cmd_account_get(account_id, include_credentials: bool = False) -> None:
|
||
"""Get a single account by id. Returns JSON."""
|
||
log = get_skill_logger()
|
||
log.info("cli_start subcommand=account_get id=%r", account_id)
|
||
init_db()
|
||
acc = get_account_by_id(account_id)
|
||
if not acc:
|
||
log.warning("account_get_not_found id=%r", account_id)
|
||
_say(f"ERROR:ACCOUNT_NOT_FOUND 账号 id={account_id} 不存在。")
|
||
_say_err(_runtime_paths_debug_text())
|
||
return
|
||
if include_credentials:
|
||
creds = find_credentials(
|
||
platform_key=acc["platform_key"],
|
||
limit=20,
|
||
)
|
||
linked_creds = [c for c in creds if c["account_id"] == acc["id"]]
|
||
if not linked_creds:
|
||
linked_creds = [c for c in creds if c["account_id"] is None]
|
||
acc["credentials"] = [
|
||
{
|
||
"id": c["id"],
|
||
"credential_label": c["credential_label"],
|
||
"credential_type": c["credential_type"],
|
||
"secret_storage": c["secret_storage"],
|
||
"secret_ref": c["secret_ref"],
|
||
"secret_mask": c["secret_mask"],
|
||
"status": c["status"],
|
||
}
|
||
for c in linked_creds
|
||
]
|
||
_say(json.dumps(acc, ensure_ascii=False))
|
||
|
||
|
||
def cmd_account_get_credential(
|
||
account_id: int,
|
||
*,
|
||
reveal: bool = False,
|
||
lease_token: Optional[str] = None,
|
||
) -> None:
|
||
"""account get-credential — metadata or plaintext when lease proves access."""
|
||
log = get_skill_logger()
|
||
log.info("cli_start subcommand=account_get_credential id=%s reveal=%s", account_id, reveal)
|
||
|
||
init_db()
|
||
acc = get_account_by_id(account_id)
|
||
if not acc:
|
||
log.warning("account_get_credential_not_found id=%s", account_id)
|
||
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。"))
|
||
return
|
||
|
||
conn = get_conn()
|
||
try:
|
||
creds_all = list_credentials_by_account(conn, account_id)
|
||
creds = [c for c in creds_all if (c.get("status") or "").strip().lower() == "active"]
|
||
if not creds:
|
||
_say(_err_json(
|
||
"ERR_CREDENTIAL_MISSING",
|
||
f"账号 id={account_id} 没有活跃凭据。",
|
||
))
|
||
return
|
||
cred = creds[0]
|
||
|
||
base_out = {
|
||
"success": True,
|
||
"account_id": account_id,
|
||
"credential_id": cred["id"],
|
||
"credential_type": cred["credential_type"],
|
||
"secret_storage": cred["secret_storage"],
|
||
"secret_mask": cred["secret_mask"],
|
||
}
|
||
|
||
if not reveal:
|
||
_say(json.dumps(base_out, ensure_ascii=False))
|
||
return
|
||
|
||
tok = (lease_token or "").strip()
|
||
if not tok:
|
||
log.warning("account_get_credential_reveal_missing_token id=%s", account_id)
|
||
_say(_err_json(
|
||
"ERR_LEASE_INVALID",
|
||
"reveal 必须带 --lease-token。",
|
||
))
|
||
return
|
||
|
||
lease = get_active_lease_by_token(conn, tok)
|
||
lease_prefix = tok[:8]
|
||
if lease is None:
|
||
log.warning(
|
||
"account_get_credential_reveal_bad_lease id=%s lease_prefix=%s",
|
||
account_id,
|
||
lease_prefix,
|
||
)
|
||
_say(_err_json(
|
||
"ERR_LEASE_INVALID",
|
||
"lease token 无效或已过期",
|
||
))
|
||
return
|
||
|
||
if int(lease["account_id"]) != int(account_id):
|
||
log.warning(
|
||
"account_get_credential_reveal_lease_mismatch id=%s lease_account=%s lease_prefix=%s",
|
||
account_id,
|
||
lease["account_id"],
|
||
lease_prefix,
|
||
)
|
||
_say(_err_json(
|
||
"ERR_LEASE_INVALID",
|
||
"lease token 不属于该 account",
|
||
))
|
||
return
|
||
|
||
try:
|
||
pt = read_credential_plaintext(conn, int(cred["id"]))
|
||
except MasterKeyMissingError as e:
|
||
log.warning("account_get_credential_master_missing id=%s", account_id)
|
||
_say(_err_json("ERR_MASTER_KEY_MISSING", str(e)))
|
||
return
|
||
except CredentialDecryptError as e:
|
||
log.warning(
|
||
"account_get_credential_decrypt_failed id=%s cred_id=%s lease_prefix=%s",
|
||
account_id,
|
||
cred["id"],
|
||
lease_prefix,
|
||
)
|
||
_say(_err_json("ERR_CREDENTIAL_DECRYPT_FAILED", str(e)))
|
||
return
|
||
except CredentialNotEncryptedError:
|
||
out = {**base_out, "plaintext": None, "note": "credential storage=none, no plaintext to reveal"}
|
||
log.info(
|
||
"account_get_credential_reveal_note_plain id=%s cred_id=%s lease_prefix=%s",
|
||
account_id,
|
||
cred["id"],
|
||
lease_prefix,
|
||
)
|
||
_say(json.dumps(out, ensure_ascii=False))
|
||
return
|
||
|
||
log.info(
|
||
"account_get_credential_reveal_success account_id=%s cred_id=%s lease_prefix=%s",
|
||
account_id,
|
||
cred["id"],
|
||
lease_prefix,
|
||
)
|
||
_say(json.dumps({**base_out, "plaintext": pt}, ensure_ascii=False))
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def cmd_get(account_id) -> None:
|
||
"""Legacy: get <id> — same as account get <id>."""
|
||
cmd_account_get(account_id, include_credentials=False)
|
||
|
||
|
||
def cmd_account_list(
|
||
platform_input: Optional[str] = None,
|
||
environment: Optional[str] = None,
|
||
tenant_id: Optional[str] = None,
|
||
role: Optional[str] = None,
|
||
limit: int = 200,
|
||
) -> None:
|
||
"""account list — output JSON array of accounts matching filters."""
|
||
log = get_skill_logger()
|
||
log.info("cli_start subcommand=account_list platform=%s env=%s tenant=%s role=%s",
|
||
platform_input, environment, tenant_id, role)
|
||
init_db()
|
||
key = None
|
||
if platform_input and platform_input not in ("all", "全部", ""):
|
||
key = _resolve_or_fail(platform_input)
|
||
if not key:
|
||
return
|
||
|
||
accounts = find_accounts(
|
||
platform_key=key,
|
||
environment=environment,
|
||
tenant_id=tenant_id,
|
||
role=role,
|
||
limit=limit,
|
||
)
|
||
_say(json.dumps(accounts, ensure_ascii=False))
|
||
|
||
|
||
def cmd_list_json(platform_input: str, limit: int = 200) -> None:
|
||
"""Legacy: list-json <platform|all> [--limit N]"""
|
||
log = get_skill_logger()
|
||
log.info("cli_start subcommand=list_json filter=%r limit=%s", platform_input, limit)
|
||
init_db()
|
||
raw = (platform_input or "all").strip()
|
||
if not raw or raw.lower() == "all" or raw == "全部":
|
||
key = None
|
||
else:
|
||
key = _resolve_or_fail(raw)
|
||
if not key:
|
||
return
|
||
lim = max(1, min(int(limit), 500))
|
||
accounts = find_accounts(platform_key=key, limit=lim)
|
||
_say(json.dumps(accounts, ensure_ascii=False))
|
||
|
||
|
||
def cmd_account_pick_web(
|
||
platform_input: str,
|
||
environment: Optional[str] = None,
|
||
tenant_id: Optional[str] = None,
|
||
role: Optional[str] = None,
|
||
do_lease: bool = False,
|
||
ttl_sec: int = 900,
|
||
holder: Optional[str] = None,
|
||
purpose: Optional[str] = None,
|
||
platform_meta: Optional[PlatformCallerMeta] = None,
|
||
) -> None:
|
||
"""Pick a web/RPA account for use. Optionally acquire a lease."""
|
||
log = get_skill_logger()
|
||
log.info("account_pick_web_attempt platform=%s env=%s tenant=%s role=%s lease=%s",
|
||
platform_input, environment, tenant_id, role, do_lease)
|
||
|
||
key = _resolve_or_auto_register(platform_input, platform_meta)
|
||
if not key:
|
||
return
|
||
|
||
init_db()
|
||
conn = get_conn()
|
||
try:
|
||
seed_platforms(conn)
|
||
finally:
|
||
conn.close()
|
||
|
||
candidate_ids = find_account_ids(
|
||
platform_key=key,
|
||
environment=environment,
|
||
tenant_id=tenant_id,
|
||
role=role,
|
||
)
|
||
if not candidate_ids:
|
||
_say(_err_json("ERROR:NO_ACCOUNT",
|
||
"没有符合条件的可用账号(status=active 且未被其他 lease 占用)。"))
|
||
return
|
||
|
||
picked_id = candidate_ids[0]
|
||
acc = get_account_by_id(picked_id)
|
||
if not acc:
|
||
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", "选中的账号突然不存在了。"))
|
||
return
|
||
|
||
update_account_last_used(picked_id)
|
||
|
||
result = {
|
||
"id": acc["id"],
|
||
"platform_key": acc["platform_key"],
|
||
"account_label": acc["account_label"],
|
||
"login_id": acc["login_id"],
|
||
"profile_dir": acc["profile_dir"],
|
||
"url": acc["url"],
|
||
"tenant_id": acc["tenant_id"],
|
||
"environment": acc["environment"],
|
||
"role": acc["role"],
|
||
"provider_code": acc["provider_code"],
|
||
"session_status": acc["session_status"],
|
||
"auth_strategy": acc["auth_strategy"],
|
||
"session_persistent": acc["session_persistent"],
|
||
"device_fingerprint": acc.get("device_fingerprint"),
|
||
}
|
||
|
||
lease_info = None
|
||
if do_lease:
|
||
lease_token = uuid.uuid4().hex[:32]
|
||
resolved_holder = holder or "unknown"
|
||
try:
|
||
lease_info = acquire_lease(
|
||
account_id=picked_id,
|
||
lease_token=lease_token,
|
||
holder=resolved_holder,
|
||
purpose=purpose,
|
||
ttl_sec=ttl_sec,
|
||
)
|
||
result["lease_token"] = lease_info["lease_token"]
|
||
result["lease_expires_at"] = lease_info["expires_at"]
|
||
except ValueError as e:
|
||
log.warning("account_pick_web_lease_conflict id=%s", picked_id)
|
||
_say(_err_json("ERROR:LEASE_CONFLICT", str(e)))
|
||
return
|
||
|
||
log.info("account_pick_web_success id=%s token_prefix=%s",
|
||
picked_id, lease_info["lease_token"][:8] if lease_info else "none")
|
||
_say(json.dumps(result, ensure_ascii=False))
|
||
|
||
|
||
def cmd_pick_web(platform_input: str) -> None:
|
||
"""Legacy: pick-web <platform> — same as account pick-web without lease."""
|
||
cmd_account_pick_web(platform_input, do_lease=False)
|