diff --git a/aps/data/demo_accounts.json b/aps/data/demo_accounts.json new file mode 100644 index 0000000..7a58c1c --- /dev/null +++ b/aps/data/demo_accounts.json @@ -0,0 +1,16 @@ +{ + "accounts": [ + { + "accountId": "E1001", + "displayName": "张工", + "role": "planner", + "passwordHash": "pbkdf2_sha256$600000$50cvkvTb7+UoL6dMiQoU/A==$H+zw+0IJCtEin55kdaVu9aAGfzatK5QbMJGFJMhFxXo=" + }, + { + "accountId": "E1002", + "displayName": "李工", + "role": "viewer", + "passwordHash": "pbkdf2_sha256$600000$ugsHPwbfG8iRjAnGDVSaHQ==$HvECTwm+Z0KZFTVEk/f3xR6CmrYn5w/EQSzfrYBzsfg=" + } + ] +} diff --git a/aps/src/aps/api/app.py b/aps/src/aps/api/app.py index 0f123d7..71d566e 100644 --- a/aps/src/aps/api/app.py +++ b/aps/src/aps/api/app.py @@ -15,15 +15,17 @@ from __future__ import annotations import logging +import os import uuid from collections.abc import Callable from typing import Any -from fastapi import FastAPI +from fastapi import FastAPI, Header, HTTPException from pydantic import BaseModel, Field from aps import __version__ from aps.domain import changeover, inventory, master_query, masterdata, orders +from aps.identity import store as identity_store from aps.world import World, load_world logger = logging.getLogger("aps.api") @@ -43,6 +45,13 @@ class InvokeRequest(BaseModel): identity: dict[str, Any] | None = None +class LoginRequest(BaseModel): + """账号密码登录(ADR-0012 的 password-exchange):密码只在这一次调用里出现。""" + + accountId: str + password: str + + class CapabilitySpec(BaseModel): """能力声明,字段与平台 provider-sdk 的 Capability 一致。""" @@ -309,14 +318,63 @@ async def health() -> dict[str, Any]: } +def _bearer(authorization: str | None) -> str | None: + if not authorization: + return None + scheme, _, token = authorization.partition(" ") + return token.strip() if scheme.lower() == "bearer" and token.strip() else None + + +def require_auth() -> bool: + """能力调用是否必须带登录态;测试或纯内网演示可用 APS_REQUIRE_AUTH=0 关掉。""" + return os.environ.get("APS_REQUIRE_AUTH", "1") not in {"0", "false", "False"} + + +@app.post("/api/auth/login") +async def login(body: LoginRequest) -> dict[str, Any]: + session = identity_store.login(body.accountId, body.password) + if session is None: + raise HTTPException(status_code=401, detail="账号或密码不正确") + return { + "token": session.token, + "accountId": session.account_id, + "displayName": session.display_name, + "expiresAt": session.expires_at, + } + + +@app.get("/api/auth/whoami") +async def whoami(authorization: str | None = Header(default=None)) -> dict[str, Any]: + session = identity_store.resolve(_bearer(authorization)) + if session is None: + raise HTTPException(status_code=401, detail="登录态已失效,请重新绑定") + return {"accountId": session.account_id, "displayName": session.display_name, "expiresAt": session.expires_at} + + +@app.post("/api/auth/logout") +async def logout(authorization: str | None = Header(default=None)) -> dict[str, Any]: + return {"loggedOut": identity_store.logout(_bearer(authorization))} + + @app.get("/api/manifest") async def get_manifest() -> dict[str, Any]: return manifest() @app.post("/api/capabilities/{capability_id}") -async def invoke(capability_id: str, body: InvokeRequest) -> dict[str, Any]: +async def invoke( + capability_id: str, + body: InvokeRequest, + authorization: str | None = Header(default=None), +) -> dict[str, Any]: execution_id = body.executionId or f"exec-aps-{uuid.uuid4()}" + if require_auth(): + # 登录态归 APS 判:平台放行不代表 APS 放行(D9) + session = identity_store.resolve(_bearer(authorization)) + if session is None: + raise HTTPException(status_code=401, detail="登录态已失效,请重新绑定") + if body.identity and body.identity.get("accountId") not in (None, session.account_id): + raise HTTPException(status_code=403, detail="平台传来的账号与登录态不一致") entry = CAPABILITIES.get(capability_id) if entry is None: return { diff --git a/aps/src/aps/identity.py b/aps/src/aps/identity.py new file mode 100644 index 0000000..c57c572 --- /dev/null +++ b/aps/src/aps/identity.py @@ -0,0 +1,126 @@ +"""账号与登录态(A5.7 / ADR-0012):账号密码一次性换 token。 + +三条规则: + 1. **密码不落盘**:只在 `/api/auth/login` 这一次调用里出现,PBKDF2 校验完立即丢弃, + 文件里存的是 `pbkdf2_sha256$iterations$salt$hash`,日志里也不会出现密码。 + 2. **token 是登录态**:随机串 + 过期时间,存在内存里(B2 拆库后换成 APS 自己的表)。 + 3. **调用要带 token**:能力调用按业务系统的规矩校验登录态;没带或过期就是 401, + 平台侧据此把绑定标记为"需要重新绑定",而不是继续拿旧凭据重试。 + +演示账号见 `data/demo_accounts.json`(E1001 / E1002,密码都是 `aps-demo-pass`)。 +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import secrets +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +DEFAULT_ACCOUNTS_PATH = Path(__file__).resolve().parents[2] / "data" / "demo_accounts.json" +DEFAULT_TOKEN_TTL_SECONDS = 8 * 60 * 60 + + +@dataclass(frozen=True) +class Account: + account_id: str + display_name: str + role: str + password_hash: str + + +@dataclass(frozen=True) +class Session: + token: str + account_id: str + display_name: str + expires_at: int + + +def _decode(value: str) -> bytes: + return base64.b64decode(value.encode("ascii")) + + +def verify_password(password: str, encoded: str) -> bool: + """按存储格式校验密码;格式不对或算法不认识一律不通过(不抛异常,避免把细节泄给调用方)。""" + try: + algorithm, iterations, salt, expected = encoded.split("$") + if algorithm != "pbkdf2_sha256": + return False + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), _decode(salt), int(iterations)) + except (ValueError, TypeError): + return False + return hmac.compare_digest(digest, _decode(expected)) + + +class IdentityStore: + """账号与 token 的持有者。进程内单例,测试可另建。""" + + def __init__( + self, + accounts_path: Path | str | None = None, + token_ttl_seconds: int = DEFAULT_TOKEN_TTL_SECONDS, + ) -> None: + self._accounts_path = Path(accounts_path or os.environ.get("APS_ACCOUNTS_PATH") or DEFAULT_ACCOUNTS_PATH) + self._token_ttl = token_ttl_seconds + self._sessions: dict[str, Session] = {} + self._accounts = self._load_accounts() + + def _load_accounts(self) -> dict[str, Account]: + try: + raw = json.loads(self._accounts_path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + accounts: dict[str, Account] = {} + for item in raw.get("accounts", []): + account = Account( + account_id=str(item["accountId"]), + display_name=str(item.get("displayName") or item["accountId"]), + role=str(item.get("role") or "viewer"), + password_hash=str(item["passwordHash"]), + ) + accounts[account.account_id] = account + return accounts + + def login(self, account_id: str, password: str) -> Session | None: + """账号密码换 token;密码用完即弃,不进任何持久化与日志。""" + account = self._accounts.get(account_id) + if account is None or not verify_password(password, account.password_hash): + return None + session = Session( + token=f"aps_{secrets.token_urlsafe(24)}", + account_id=account.account_id, + display_name=account.display_name, + expires_at=int(time.time() * 1000) + self._token_ttl * 1000, + ) + self._sessions[session.token] = session + return session + + def resolve(self, token: str | None) -> Session | None: + if not token: + return None + session = self._sessions.get(token) + if session is None: + return None + if session.expires_at <= int(time.time() * 1000): + self._sessions.pop(token, None) + return None + return session + + def logout(self, token: str | None) -> bool: + return self._sessions.pop(token or "", None) is not None + + def describe_account(self, account_id: str) -> dict[str, Any] | None: + account = self._accounts.get(account_id) + if account is None: + return None + return {"accountId": account.account_id, "displayName": account.display_name, "role": account.role} + + +store = IdentityStore() diff --git a/aps/tests/test_api.py b/aps/tests/test_api.py index 0c3b55a..66cdf17 100644 --- a/aps/tests/test_api.py +++ b/aps/tests/test_api.py @@ -9,9 +9,14 @@ from __future__ import annotations from fastapi.testclient import TestClient from aps.api.app import CAPABILITIES, app, manifest +from aps.identity import store as identity_store from aps.world import load_world +# 能力调用要带登录态(A5.7):测试先按真实流程换一个 token client = TestClient(app) +_session = identity_store.login("E1001", "aps-demo-pass") +assert _session is not None, "演示账号应能登录" +client.headers.update({"authorization": f"Bearer {_session.token}"}) def test_health() -> None: diff --git a/aps/tests/test_auth.py b/aps/tests/test_auth.py new file mode 100644 index 0000000..a91c6b0 --- /dev/null +++ b/aps/tests/test_auth.py @@ -0,0 +1,100 @@ +"""A5.7 / ADR-0012:账号与登录态。 + +要证明三件事:密码不落盘、能力调用必须带登录态、登录态对不上就是拒绝。 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +from aps.api.app import app +from aps.identity import verify_password + +client = TestClient(app) +ACCOUNTS_FILE = Path(__file__).resolve().parents[1] / "data" / "demo_accounts.json" +DEMO_PASSWORD = "aps-demo-pass" + + +def login(account_id: str = "E1001", password: str = DEMO_PASSWORD) -> dict: + response = client.post("/api/auth/login", json={"accountId": account_id, "password": password}) + return {"status": response.status_code, "body": response.json()} + + +def auth_header(token: str) -> dict[str, str]: + return {"authorization": f"Bearer {token}"} + + +def test_password_is_never_stored_in_plaintext() -> None: + """凭据文件里只有 PBKDF2 哈希;明文密码绝不落盘。""" + raw = ACCOUNTS_FILE.read_text(encoding="utf-8") + + assert DEMO_PASSWORD not in raw, "演示密码不该出现在账号文件里" + stored = json.loads(raw)["accounts"][0]["passwordHash"] + assert stored.startswith("pbkdf2_sha256$"), stored + assert verify_password(DEMO_PASSWORD, stored) + assert not verify_password("wrong-password", stored) + + +def test_login_returns_token_and_whoami_accepts_it() -> None: + result = login() + assert result["status"] == 200 + body = result["body"] + assert body["accountId"] == "E1001" + assert body["token"].startswith("aps_") + assert body["expiresAt"] > 0 + + who = client.get("/api/auth/whoami", headers=auth_header(body["token"])) + assert who.status_code == 200 + assert who.json()["displayName"] == body["displayName"] + + +def test_wrong_password_is_rejected() -> None: + result = login(password="not-the-password") + + assert result["status"] == 401 + assert "token" not in result["body"] + + +def test_whoami_rejects_unknown_token() -> None: + assert client.get("/api/auth/whoami", headers=auth_header("aps_not-a-real-token")).status_code == 401 + + +def test_capability_call_requires_login_state() -> None: + """登录态归 APS 判:没带 token 的能力调用一律 401(D9)。""" + response = client.post("/api/capabilities/aps.orders.query", json={"params": {}}) + + assert response.status_code == 401 + assert "重新绑定" in response.json()["detail"] + + +def test_capability_call_succeeds_with_token() -> None: + token = login()["body"]["token"] + response = client.post( + "/api/capabilities/aps.orders.query", + json={"params": {"limit": 2}}, + headers=auth_header(token), + ) + + assert response.status_code == 200 + assert response.json()["status"] == "succeeded" + + +def test_account_mismatch_is_forbidden() -> None: + """平台传来的账号与登录态不是同一个人:拒绝,别拿别人的登录态干活。""" + token = login()["body"]["token"] + response = client.post( + "/api/capabilities/aps.orders.query", + json={"params": {}, "identity": {"accountId": "E1002"}}, + headers=auth_header(token), + ) + + assert response.status_code == 403 + + +def test_logout_invalidates_token() -> None: + token = login()["body"]["token"] + assert client.post("/api/auth/logout", headers=auth_header(token)).json()["loggedOut"] is True + assert client.get("/api/auth/whoami", headers=auth_header(token)).status_code == 401 diff --git a/contracts/fixtures/http.json b/contracts/fixtures/http.json index d40f467..cf8f980 100644 --- a/contracts/fixtures/http.json +++ b/contracts/fixtures/http.json @@ -20,5 +20,11 @@ "ExecutionTraceEntry": { "type": "ExecutionTraceEntry", "schema": "http.schema.json#/$defs/ExecutionTraceEntry", "value": { "seq": 3, "ts": 1757404800400, "event": "approval.decided", "actorUserId": "u-1001", "capabilityId": "aps.schedule.publish", "providerId": "aps", "paramsDigest": "sha256:1c4d", "decision": "approved" } }, "ExecutionApprovalRecord": { "type": "ExecutionApprovalRecord", "schema": "http.schema.json#/$defs/ExecutionApprovalRecord", "value": { "approvalId": "apr-1", "status": "succeeded", "decision": "approved", "decidedBy": "u-1001", "decidedAt": 1757404800400, "revision": 37, "expiresAt": 1757408400000 } }, "ExecutionTraceResponse": { "type": "ExecutionTraceResponse", "schema": "http.schema.json#/$defs/ExecutionTraceResponse", "value": { "executionId": "exec-1", "approval": { "approvalId": "apr-1", "status": "succeeded", "decision": "approved", "decidedBy": "u-1001", "revision": 37, "expiresAt": 1757408400000 }, "entries": [{ "seq": 2, "ts": 1757404800300, "event": "policy.requires_confirmation", "actorUserId": "u-1001", "capabilityId": "aps.schedule.publish", "providerId": "aps", "paramsDigest": "sha256:1c4d", "decision": "policy.write_requires_confirmation" }, { "seq": 3, "ts": 1757404800400, "event": "approval.decided", "actorUserId": "u-1001", "capabilityId": "aps.schedule.publish", "decision": "approved" }, { "seq": 4, "ts": 1757404800500, "event": "execution.finished", "actorUserId": "u-1001", "capabilityId": "aps.schedule.publish", "decision": "succeeded" }], "chainOk": true, "receipt": { "executionId": "exec-1", "status": "succeeded" } } }, + "BindingSummary": { "type": "BindingSummary", "schema": "http.schema.json#/$defs/BindingSummary", "value": { "providerId": "aps", "accountId": "E1001", "displayName": "张工", "status": "active", "authKind": "password-exchange", "updatedAt": 1759104000000 } }, + "BindingStartResponse": { "type": "BindingStartResponse", "schema": "http.schema.json#/$defs/BindingStartResponse", "value": { "providerId": "aps", "authKind": "password-exchange" } }, + "BindingCompleteRequest": { "type": "BindingCompleteRequest", "schema": "http.schema.json#/$defs/BindingCompleteRequest", "value": { "accountId": "E1001", "password": "一次性密码,用完即弃" } }, + "BindingCompleteResponse": { "type": "BindingCompleteResponse", "schema": "http.schema.json#/$defs/BindingCompleteResponse", "value": { "binding": { "providerId": "aps", "accountId": "E1001", "displayName": "张工", "status": "active", "authKind": "password-exchange" } } }, + "BindingListResponse": { "type": "BindingListResponse", "schema": "http.schema.json#/$defs/BindingListResponse", "value": { "bindings": [{ "providerId": "aps", "accountId": "E1001", "displayName": "张工", "status": "active" }, { "providerId": "erp", "accountId": null, "displayName": null, "status": "unbound" }] } }, + "BindingDeleteResponse": { "type": "BindingDeleteResponse", "schema": "http.schema.json#/$defs/BindingDeleteResponse", "value": { "providerId": "aps", "removed": true } }, "ErrorEnvelope": { "type": "ErrorEnvelope", "schema": "error.schema.json", "value": { "error": { "code": "auth.expired", "message": "登录态已过期", "retryable": false } } } } \ No newline at end of file diff --git a/contracts/protocol.ts b/contracts/protocol.ts index d72ea9f..de37f65 100644 --- a/contracts/protocol.ts +++ b/contracts/protocol.ts @@ -23,11 +23,15 @@ export const ENDPOINTS = { /** 执行追溯(A4.5):按 executionId 追到人、参数、审批与回执 */ executionTrace: (executionId: string) => `/v1/executions/${executionId}/trace`, approval: (approvalId: string) => `/v1/approvals/${approvalId}`, + bindings: '/v1/bindings', + bindingStart: (providerId: string) => `/v1/bindings/${providerId}/start`, + bindingComplete: (providerId: string) => `/v1/bindings/${providerId}/complete`, + binding: (providerId: string) => `/v1/bindings/${providerId}`, } as const; /** 端点规格:给实现与文档比对用的机器可读表(方法、路径、请求体、响应体) */ export interface EndpointSpec { - method: 'GET' | 'POST'; + method: 'GET' | 'POST' | 'DELETE'; /** 路径;路径参数用 {name} 占位 */ path: string; /** 请求体类型名;没有请求体写 '—' */ @@ -51,6 +55,10 @@ export const ENDPOINT_SPECS: readonly EndpointSpec[] = [ { method: 'GET', path: '/v1/executions/{executionId}', request: '—', response: 'ExecutionReceipt', summary: '执行状态与回执' }, { method: 'GET', path: '/v1/executions/{executionId}/trace', request: '—', response: 'ExecutionTraceResponse', summary: '执行追溯:人、参数、审批与回执' }, { method: 'POST', path: '/v1/approvals/{approvalId}', request: 'ApprovalDecisionRequest', response: 'ApprovalDecisionResponse', summary: '确认卡响应' }, + { method: 'GET', path: '/v1/bindings', request: '—', response: 'BindingListResponse', summary: '我绑定的业务系统账号与登录态' }, + { method: 'POST', path: '/v1/bindings/{providerId}/start', request: '—', response: 'BindingStartResponse', summary: '发起账号绑定(按 Provider 声明的登录方式)' }, + { method: 'POST', path: '/v1/bindings/{providerId}/complete', request: 'BindingCompleteRequest', response: 'BindingCompleteResponse', summary: '完成绑定:换取凭据并加密保存' }, + { method: 'DELETE', path: '/v1/bindings/{providerId}', request: '—', response: 'BindingDeleteResponse', summary: '解绑并删除凭据' }, ]; /** 能力标识,形如 `aps.schedule.preview` */ @@ -212,6 +220,48 @@ export interface ApprovalDecisionResponse { receipt?: ExecutionReceipt; } +/** 绑定状态:unbound 表示这个系统还没绑(列表里会出现,界面提示去绑) */ +export interface BindingSummary { + providerId: string; + accountId: string | null; + displayName: string | null; + status: 'active' | 'expired' | 'disabled' | 'unbound'; + /** Provider 声明的登录方式(ADR-0012) */ + authKind?: 'oauth' | 'password-exchange' | 'apikey'; + updatedAt?: number; +} + +/** 发起绑定:oauth 会带 authorizationUrl 让客户端跳转;密码类直接进 complete */ +export interface BindingStartResponse { + providerId: string; + authKind: 'oauth' | 'password-exchange' | 'apikey'; + authorizationUrl?: string; +} + +/** + * 完成绑定。password 只在这一条请求里出现一次,服务端不落盘、不写日志; + * oauth 则用 code + state。 + */ +export interface BindingCompleteRequest { + accountId: string; + password?: string; + code?: string; + state?: string; +} + +export interface BindingCompleteResponse { + binding: BindingSummary; +} + +export interface BindingListResponse { + bindings: BindingSummary[]; +} + +export interface BindingDeleteResponse { + providerId: string; + removed: boolean; +} + export interface ErrorEnvelope { error: { code: string; diff --git a/contracts/schemas/http.schema.json b/contracts/schemas/http.schema.json index fdb726d..2437d8c 100644 --- a/contracts/schemas/http.schema.json +++ b/contracts/schemas/http.schema.json @@ -6,176 +6,534 @@ "$defs": { "HealthResponse": { "type": "object", - "required": ["status", "version", "protocolVersion"], + "required": [ + "status", + "version", + "protocolVersion" + ], "properties": { - "status": { "const": "ok" }, - "version": { "type": "string" }, - "protocolVersion": { "type": "integer" } + "status": { + "const": "ok" + }, + "version": { + "type": "string" + }, + "protocolVersion": { + "type": "integer" + } }, "additionalProperties": false }, "MeResponse": { "type": "object", - "required": ["userId", "tenantId", "displayName", "clientKind"], + "required": [ + "userId", + "tenantId", + "displayName", + "clientKind" + ], "properties": { - "userId": { "type": "string" }, - "tenantId": { "type": "string" }, - "displayName": { "type": "string" }, - "clientKind": { "type": "string", "enum": ["desktop", "browser"] } + "userId": { + "type": "string" + }, + "tenantId": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "clientKind": { + "type": "string", + "enum": [ + "desktop", + "browser" + ] + } }, "additionalProperties": false }, "ProjectSummary": { "type": "object", - "required": ["projectId", "name", "status", "writable"], + "required": [ + "projectId", + "name", + "status", + "writable" + ], "properties": { - "projectId": { "type": "string" }, - "name": { "type": "string" }, - "status": { "type": "string", "enum": ["active", "archived"] }, - "writable": { "type": "boolean", "description": "项目内可写;个人会话只读(ADR-0003)" } + "projectId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "archived" + ] + }, + "writable": { + "type": "boolean", + "description": "项目内可写;个人会话只读(ADR-0003)" + } }, "additionalProperties": false }, "ProjectListResponse": { "type": "object", - "required": ["projects"], + "required": [ + "projects" + ], "properties": { - "projects": { "type": "array", "items": { "$ref": "#/$defs/ProjectSummary" } } + "projects": { + "type": "array", + "items": { + "$ref": "#/$defs/ProjectSummary" + } + } }, "additionalProperties": false }, "CapabilitiesResponse": { "type": "object", - "required": ["capabilities"], + "required": [ + "capabilities" + ], "properties": { - "capabilities": { "type": "array", "items": { "$ref": "capability.schema.json#/$defs/CapabilitySummary" } } + "capabilities": { + "type": "array", + "items": { + "$ref": "capability.schema.json#/$defs/CapabilitySummary" + } + } }, "additionalProperties": false }, "ChatMessage": { "type": "object", - "required": ["messageId", "role", "ts", "text"], + "required": [ + "messageId", + "role", + "ts", + "text" + ], "properties": { - "messageId": { "type": "string" }, - "role": { "type": "string", "enum": ["user", "assistant", "system"] }, - "ts": { "type": "integer" }, - "text": { "type": "string" } + "messageId": { + "type": "string" + }, + "role": { + "type": "string", + "enum": [ + "user", + "assistant", + "system" + ] + }, + "ts": { + "type": "integer" + }, + "text": { + "type": "string" + } }, "additionalProperties": false }, "CreateSessionRequest": { "type": "object", - "required": ["scope"], + "required": [ + "scope" + ], "properties": { - "projectId": { "type": "string" }, - "scope": { "type": "string", "enum": ["personal", "project"] }, - "title": { "type": "string" } + "projectId": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "personal", + "project" + ] + }, + "title": { + "type": "string" + } }, "additionalProperties": false }, "CreateSessionResponse": { "type": "object", - "required": ["session"], + "required": [ + "session" + ], "properties": { - "session": { "$ref": "session.schema.json#/$defs/SessionSummary" } + "session": { + "$ref": "session.schema.json#/$defs/SessionSummary" + } }, "additionalProperties": false }, "SessionListResponse": { "type": "object", - "required": ["sessions"], + "required": [ + "sessions" + ], "properties": { - "sessions": { "type": "array", "items": { "$ref": "session.schema.json#/$defs/SessionSummary" } } + "sessions": { + "type": "array", + "items": { + "$ref": "session.schema.json#/$defs/SessionSummary" + } + } }, "additionalProperties": false }, "SessionDetailResponse": { "type": "object", - "required": ["session", "messages"], + "required": [ + "session", + "messages" + ], "properties": { - "session": { "$ref": "session.schema.json#/$defs/SessionSummary" }, - "messages": { "type": "array", "items": { "$ref": "#/$defs/ChatMessage" } } + "session": { + "$ref": "session.schema.json#/$defs/SessionSummary" + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/$defs/ChatMessage" + } + } }, "additionalProperties": false }, "SendMessageRequest": { "type": "object", - "required": ["text", "idempotencyKey"], + "required": [ + "text", + "idempotencyKey" + ], "properties": { - "text": { "type": "string", "minLength": 1 }, - "idempotencyKey": { "type": "string", "minLength": 8 } + "text": { + "type": "string", + "minLength": 1 + }, + "idempotencyKey": { + "type": "string", + "minLength": 8 + } }, "additionalProperties": false }, "SendMessageResponse": { "type": "object", - "required": ["accepted", "messageId", "runId"], + "required": [ + "accepted", + "messageId", + "runId" + ], "properties": { - "accepted": { "const": true }, - "messageId": { "type": "string" }, - "runId": { "type": "string" } + "accepted": { + "const": true + }, + "messageId": { + "type": "string" + }, + "runId": { + "type": "string" + } }, "additionalProperties": false }, "CancelResponse": { "type": "object", - "required": ["state"], + "required": [ + "state" + ], "properties": { - "state": { "type": "string", "enum": ["cancelling", "idle"] } + "state": { + "type": "string", + "enum": [ + "cancelling", + "idle" + ] + } }, "additionalProperties": false }, "ExecutionTraceEntry": { "type": "object", - "required": ["seq", "ts", "event", "actorUserId"], + "required": [ + "seq", + "ts", + "event", + "actorUserId" + ], "properties": { - "seq": { "type": "integer" }, - "ts": { "type": "integer" }, - "event": { "type": "string" }, - "actorUserId": { "type": "string" }, - "capabilityId": { "type": "string" }, - "providerId": { "type": "string" }, - "paramsDigest": { "type": "string" }, - "decision": { "type": "string" }, - "detail": { "type": "object" } + "seq": { + "type": "integer" + }, + "ts": { + "type": "integer" + }, + "event": { + "type": "string" + }, + "actorUserId": { + "type": "string" + }, + "capabilityId": { + "type": "string" + }, + "providerId": { + "type": "string" + }, + "paramsDigest": { + "type": "string" + }, + "decision": { + "type": "string" + }, + "detail": { + "type": "object" + } }, "additionalProperties": false }, "ExecutionApprovalRecord": { "type": "object", - "required": ["approvalId", "status", "revision", "expiresAt"], + "required": [ + "approvalId", + "status", + "revision", + "expiresAt" + ], "properties": { - "approvalId": { "type": "string" }, - "status": { "type": "string" }, - "decision": { "type": "string", "enum": ["approved", "rejected", "expired"] }, - "decidedBy": { "type": "string" }, - "decidedAt": { "type": "integer" }, - "revision": { "type": "integer" }, - "expiresAt": { "type": "integer" } + "approvalId": { + "type": "string" + }, + "status": { + "type": "string" + }, + "decision": { + "type": "string", + "enum": [ + "approved", + "rejected", + "expired" + ] + }, + "decidedBy": { + "type": "string" + }, + "decidedAt": { + "type": "integer" + }, + "revision": { + "type": "integer" + }, + "expiresAt": { + "type": "integer" + } }, "additionalProperties": false }, "ExecutionTraceResponse": { "type": "object", - "required": ["executionId", "approval", "entries", "chainOk"], + "required": [ + "executionId", + "approval", + "entries", + "chainOk" + ], "properties": { - "executionId": { "type": "string" }, - "approval": { "anyOf": [{ "$ref": "#/$defs/ExecutionApprovalRecord" }, { "type": "null" }] }, - "entries": { "type": "array", "items": { "$ref": "#/$defs/ExecutionTraceEntry" } }, - "chainOk": { "type": "boolean" }, - "receipt": { "$ref": "receipt.schema.json#/$defs/ExecutionReceipt" } + "executionId": { + "type": "string" + }, + "approval": { + "anyOf": [ + { + "$ref": "#/$defs/ExecutionApprovalRecord" + }, + { + "type": "null" + } + ] + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/$defs/ExecutionTraceEntry" + } + }, + "chainOk": { + "type": "boolean" + }, + "receipt": { + "$ref": "receipt.schema.json#/$defs/ExecutionReceipt" + } }, "additionalProperties": false }, "ApprovalDecisionResponse": { "type": "object", - "required": ["approvalId", "decision"], + "required": [ + "approvalId", + "decision" + ], "properties": { - "approvalId": { "type": "string" }, - "decision": { "type": "string", "enum": ["approved", "rejected", "expired"] }, - "receipt": { "$ref": "receipt.schema.json#/$defs/ExecutionReceipt" } + "approvalId": { + "type": "string" + }, + "decision": { + "type": "string", + "enum": [ + "approved", + "rejected", + "expired" + ] + }, + "receipt": { + "$ref": "receipt.schema.json#/$defs/ExecutionReceipt" + } + }, + "additionalProperties": false + }, + "BindingSummary": { + "type": "object", + "required": [ + "providerId", + "accountId", + "displayName", + "status" + ], + "properties": { + "providerId": { + "type": "string" + }, + "accountId": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string", + "enum": [ + "active", + "expired", + "disabled", + "unbound" + ] + }, + "authKind": { + "type": "string", + "enum": [ + "oauth", + "password-exchange", + "apikey" + ] + }, + "updatedAt": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "BindingStartResponse": { + "type": "object", + "required": [ + "providerId", + "authKind" + ], + "properties": { + "providerId": { + "type": "string" + }, + "authKind": { + "type": "string", + "enum": [ + "oauth", + "password-exchange", + "apikey" + ] + }, + "authorizationUrl": { + "type": "string" + } + }, + "additionalProperties": false + }, + "BindingCompleteRequest": { + "type": "object", + "required": [ + "accountId" + ], + "properties": { + "accountId": { + "type": "string" + }, + "password": { + "type": "string" + }, + "code": { + "type": "string" + }, + "state": { + "type": "string" + } + }, + "additionalProperties": false + }, + "BindingCompleteResponse": { + "type": "object", + "required": [ + "binding" + ], + "properties": { + "binding": { + "$ref": "#/$defs/BindingSummary" + } + }, + "additionalProperties": false + }, + "BindingListResponse": { + "type": "object", + "required": [ + "bindings" + ], + "properties": { + "bindings": { + "type": "array", + "items": { + "$ref": "#/$defs/BindingSummary" + } + } + }, + "additionalProperties": false + }, + "BindingDeleteResponse": { + "type": "object", + "required": [ + "providerId", + "removed" + ], + "properties": { + "providerId": { + "type": "string" + }, + "removed": { + "type": "boolean" + } }, "additionalProperties": false } } -} \ No newline at end of file +} diff --git a/docs/spec/protocol.md b/docs/spec/protocol.md index 1009c02..f2a1352 100644 --- a/docs/spec/protocol.md +++ b/docs/spec/protocol.md @@ -44,6 +44,10 @@ | GET | `/v1/executions/{executionId}` | — | `ExecutionReceipt` | 执行状态与回执 | | GET | `/v1/executions/{executionId}/trace` | — | `ExecutionTraceResponse` | 执行追溯:人、参数、审批与回执(A4.5) | | POST | `/v1/approvals/{approvalId}` | `ApprovalDecisionRequest` | `ApprovalDecisionResponse` | 确认卡响应 | +| GET | `/v1/bindings` | — | `BindingListResponse` | 我绑定的业务系统账号与登录态 | +| POST | `/v1/bindings/{providerId}/start` | — | `BindingStartResponse` | 发起账号绑定(按 Provider 声明的登录方式) | +| POST | `/v1/bindings/{providerId}/complete` | `BindingCompleteRequest` | `BindingCompleteResponse` | 完成绑定:换取凭据并加密保存 | +| DELETE | `/v1/bindings/{providerId}` | — | `BindingDeleteResponse` | 解绑并删除凭据 | ## 5 事件流(SSE) @@ -84,6 +88,15 @@ 4. 服务端校验版本与有效期 → 推 `approval.resolved` → 执行 → 推 `execution.updated` 5. 版本过期或已执行 → 返回 `expired`,客户端提示重新确认;确认人是会话发起人本人 +## 7.1 账号绑定(ADR-0012) + +1. 设置页用 `GET /v1/bindings` 看每个系统的登录态:`active` / `expired` / `unbound` +2. 点绑定 → `POST /v1/bindings/{providerId}/start`:`oauth` 跳 `authorizationUrl`,`password-exchange` 直接进下一步 +3. 提交 → `POST /v1/bindings/{providerId}/complete`:服务端向业务系统换 token,**密码用完即弃,不落盘、不写日志** +4. 凭据以密文存在平台凭据代理,按 execution 签发一次性句柄;业务系统 token 不下发给客户端 +5. 业务系统拒绝(401/403)→ 绑定标成 `expired`,后续调用在策略层直接拒(`policy.identity_expired`),界面提示重新绑定 +6. 解绑 → `DELETE /v1/bindings/{providerId}`:删凭据与账号映射;审计与执行历史保留 + ## 8 错误模型 | 位置 | 形状 | 说明 | diff --git a/providers/aps/index.ts b/providers/aps/index.ts index f3aaf1a..8768456 100644 --- a/providers/aps/index.ts +++ b/providers/aps/index.ts @@ -10,8 +10,11 @@ import { ProviderError, isErrorKind, + type AuthExchangeRequest, + type AuthExchangeResult, type Capability, type CapabilityCall, + type CredentialCheck, type ExecutionContext, type HealthStatus, type Provider, @@ -49,6 +52,17 @@ function asString(value: unknown, fallback: string): string { return typeof value === 'string' && value.trim() !== '' ? value : fallback; } +/** 登录方式声明:只认平台定义的三种,别的当没声明 */ +function toAuthSpec(raw: unknown): { kind: 'oauth' | 'password-exchange' | 'apikey'; authorizationUrl?: string } | undefined { + const item = (raw ?? {}) as Record; + const kind = item.kind; + if (kind !== 'oauth' && kind !== 'password-exchange' && kind !== 'apikey') return undefined; + return { + kind, + ...(typeof item.authorizationUrl === 'string' ? { authorizationUrl: item.authorizationUrl } : {}), + }; +} + /** APS 的能力声明与平台 provider-sdk 的 Capability 同形,这里只做字段校验与兜底 */ function toCapability(raw: unknown): Capability { const item = (raw ?? {}) as Record; @@ -105,6 +119,8 @@ export function createApsProvider(options: ApsProviderOptions): Provider { version: asString(body.version, '0.0.0'), platformVersion: typeof body.platformVersion === 'number' ? body.platformVersion : 1, requiresIdentity: body.requiresIdentity !== false, + // 登录方式要透传:平台按它决定绑定流程(ADR-0012) + ...(toAuthSpec(body.auth) === undefined ? {} : { auth: toAuthSpec(body.auth) as NonNullable }), capabilities: raw.map(toCapability), }; manifestCache = described; @@ -126,11 +142,14 @@ export function createApsProvider(options: ApsProviderOptions): Provider { ...(context.identity === undefined ? {} : { identity: { accountId: context.identity.accountId } }), ...(context.idempotencyKey === undefined ? {} : { idempotencyKey: context.idempotencyKey }), }; + // 登录态只从一次性句柄里取(A4.3),不进参数、不进日志 + const secret = context.credentials === undefined ? undefined : await context.credentials.read(); + const authHeaders = secret?.token === undefined ? headers() : { ...headers(), authorization: `Bearer ${secret.token}` }; let response: Response; try { response = await doFetch(`${baseUrl}/api/capabilities/${encodeURIComponent(call.capabilityId)}`, { method: 'POST', - headers: headers(), + headers: authHeaders, body: JSON.stringify(payload), signal: AbortSignal.timeout(Math.max(1_000, Math.min(timeoutMs, context.deadlineAt - Date.now()))), }); @@ -138,7 +157,10 @@ export function createApsProvider(options: ApsProviderOptions): Provider { throw new ProviderError('unavailable', `调用 APS 失败:${error instanceof Error ? error.message : '未知错误'}`); } if (!response.ok) { - // 5xx 与服务不可达归 unavailable;4xx 是平台把请求拼错了,归 invalid_argument + if (response.status === 401 || response.status === 403) { + // 业务系统说"你没登录/没权限":平台不覆盖这个结论,交给策略层把绑定标成待重绑 + throw new ProviderError('forbidden', `APS 拒绝了这次调用(HTTP ${response.status}):登录态已失效或权限不足,请重新绑定`); + } const kind = response.status >= 500 ? 'unavailable' : 'invalid_argument'; throw new ProviderError(kind, `APS 返回 HTTP ${response.status}`); } @@ -173,7 +195,56 @@ export function createApsProvider(options: ApsProviderOptions): Provider { } } - return { describe, invoke, health }; + async function exchange(request: AuthExchangeRequest): Promise { + if (request.password === undefined) { + throw new ProviderError('invalid_argument', 'APS 走账号密码换取 token,绑定时要给一次密码'); + } + let response: Response; + try { + response = await doFetch(`${baseUrl}/api/auth/login`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ accountId: request.accountId, password: request.password }), + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (error) { + throw new ProviderError('unavailable', `连不上 APS:${error instanceof Error ? error.message : '未知错误'}`); + } + if (response.status === 401) { + throw new ProviderError('forbidden', 'APS 拒绝了这组账号密码'); + } + if (!response.ok) { + throw new ProviderError(response.status >= 500 ? 'unavailable' : 'invalid_argument', `APS 登录返回 HTTP ${response.status}`); + } + const body = (await response.json()) as { token?: unknown; accountId?: unknown; displayName?: unknown; expiresAt?: unknown }; + if (typeof body.token !== 'string' || body.token === '') { + throw new ProviderError('unknown_result', 'APS 登录成功但没返回 token'); + } + return { + accountId: asString(body.accountId, request.accountId), + ...(typeof body.displayName === 'string' ? { displayName: body.displayName } : {}), + secret: { token: body.token }, + ...(typeof body.expiresAt === 'number' ? { expiresAt: body.expiresAt } : {}), + }; + } + + async function checkCredential(secret: Record): Promise { + if (secret.token === undefined) return { ok: false, reason: '凭据里没有 token' }; + try { + const response = await doFetch(`${baseUrl}/api/auth/whoami`, { + headers: { ...headers(), authorization: `Bearer ${secret.token}` }, + signal: AbortSignal.timeout(3_000), + }); + if (response.status === 401 || response.status === 403) return { ok: false, reason: '登录态已失效,请重新绑定' }; + if (!response.ok) return { ok: false, reason: `APS 返回 HTTP ${response.status}` }; + return { ok: true }; + } catch (error) { + // 连不上不等于登录态失效:报 ok=false 会误伤,这里给出原因让上层决定 + return { ok: false, reason: error instanceof Error ? error.message : '凭据体检失败' }; + } + } + + return { describe, invoke, health, exchange, checkCredential }; } /** 供平台按配置加载:模块导出统一的 createProvider,参数来自配置 */ diff --git a/scripts/aps-provider.test.mjs b/scripts/aps-provider.test.mjs index bb7f98c..fc3e311 100644 --- a/scripts/aps-provider.test.mjs +++ b/scripts/aps-provider.test.mjs @@ -226,8 +226,9 @@ test('A5 验收:平台经 Provider 读到 APS 的真实数据', async (t) => { const provider = createApsProvider({ baseUrl: aps.baseUrl }); const manifest = await server.service.registerProvider(provider); assert.ok(manifest.capabilities.length >= 9, `APS 应暴露只读能力,实际 ${manifest.capabilities.length}`); - // 按人绑定后策略才放行(A4.1 + ADR-0012) - server.service.controlPlane.identity.bind({ userId: subject.userId, tenantId: subject.tenantId, providerId: 'aps', accountId: 'E1001' }); + // 真实走一遍绑定(A5.7):向 APS 换 token → 加密存 → 策略放行 + const bound = await server.service.bindingComplete(subject, 'aps', { accountId: 'E1001', password: 'aps-demo-pass' }); + assert.equal(bound.binding.status, 'active'); const receipt = await server.service.invoke({ subject, diff --git a/scripts/bindings.test.mjs b/scripts/bindings.test.mjs new file mode 100644 index 0000000..f2c50d0 --- /dev/null +++ b/scripts/bindings.test.mjs @@ -0,0 +1,228 @@ +/** + * 账号绑定与登录态用例(任务 A5.7 / ADR-0012)。 + * + * 用一个「会过期的业务系统」当靶子:登录换 token、token 能被作废、作废后必须 401。 + * 要证明的四件事:密码不落盘、token 只以密文存在、失效后调用被拒且不再拿旧凭据去撞、重新绑定能恢复。 + */ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +const subject = { userId: 'u-bind', tenantId: 't-bind', displayName: '张工', clientKind: 'browser' }; +const headers = (who = subject) => ({ 'content-type': 'application/json', 'x-aps-user': who.userId, 'x-aps-tenant': who.tenantId }); + +/** 业务系统桩:账号密码换 token,token 可被作废,能力调用校验 Bearer */ +async function startStubSystem() { + const validTokens = new Set(); + const calls = []; + const server = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost'); + const json = (status, body) => res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body)); + const bearer = (req.headers.authorization ?? '').replace(/^Bearer\s+/i, ''); + if (url.pathname === '/__invalidate') { + validTokens.clear(); + json(200, { invalidated: true }); + return; + } + if (url.pathname === '/api/health') return json(200, { status: 'ok' }); + if (url.pathname === '/api/manifest') { + return json(200, { + id: 'aps', + displayName: '桩 APS', + version: '1.0.0', + platformVersion: 1, + requiresIdentity: true, + auth: { kind: 'password-exchange' }, + capabilities: [ + { + id: 'aps.orders.query', + title: '订单查询', + description: '查销售订单与交期状态', + effect: 'read', + mode: 'sync', + whenNotToUse: '要排产用 aps.schedule.preview', + synonyms: ['订单'], + inputSchema: { type: 'object' }, + outputSchema: { type: 'object' }, + idempotency: 'none', + errors: ['invalid_argument', 'forbidden'], + }, + ], + }); + } + if (url.pathname === '/api/auth/login') { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); + if (body.accountId !== 'E1001' || body.password !== 'demo-pass') return json(401, { detail: '账号或密码不正确' }); + const token = `tok-${validTokens.size + 1}`; + validTokens.add(token); + json(200, { token, accountId: 'E1001', displayName: '张工', expiresAt: Date.now() + 60_000 }); + }); + return; + } + if (url.pathname === '/api/auth/whoami') { + return validTokens.has(bearer) ? json(200, { accountId: 'E1001' }) : json(401, { detail: '登录态已失效' }); + } + const match = url.pathname.match(/^\/api\/capabilities\/(.+)$/); + if (match) { + calls.push({ capabilityId: decodeURIComponent(match[1]), token: bearer }); + if (!validTokens.has(bearer)) return json(401, { detail: '登录态已失效,请重新绑定' }); + return json(200, { executionId: 'exec-stub', status: 'succeeded', result: { total: 7 } }); + } + json(404, { detail: 'not found' }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { + baseUrl: `http://127.0.0.1:${server.address().port}`, + calls, + invalidate: async () => { + await fetch(`http://127.0.0.1:${server.address().port}/__invalidate`, { method: 'POST' }); + }, + close: () => new Promise((resolve) => server.close(resolve)), + }; +} + +async function startPlatform(apsUrl, databasePath = ':memory:') { + delete process.env.APS_DEV_PROVIDER; + process.env.APS_PROVIDER_APS_URL = apsUrl; + const { startServer } = await import('../server/dist/index.js'); + return startServer({ host: '127.0.0.1', port: 0, version: 'test', engine: 'fake', databasePath }); +} + +test('A5.7 绑定→调用→登录态失效→重绑:全程密码不落盘、凭据只走一次性句柄', async () => { + const dir = mkdtempSync(join(tmpdir(), 'aps-bind-')); + process.env.APS_CREDENTIAL_KEY_FILE = join(dir, 'secret.key'); + const stub = await startStubSystem(); + const dbPath = join(dir, 'aps.sqlite'); + const server = await startPlatform(stub.baseUrl, dbPath); + try { + // 1) 未绑定:列表里是 unbound,调用被策略层拒 + const before = await (await fetch(`${server.url}/v1/bindings`, { headers: headers() })).json(); + assert.equal(before.bindings.find((b) => b.providerId === 'aps').status, 'unbound'); + const denied = await server.service.invoke({ subject, sessionId: 's', projectId: 'p-default', capabilityId: 'aps.orders.query', params: {} }); + assert.equal(denied.errorKind, 'forbidden'); + assert.equal(stub.calls.length, 0, '没绑定就不该碰业务系统'); + + // 2) 发起绑定 → 3) 完成绑定(密码只出现在这一条请求里) + const start = await (await fetch(`${server.url}/v1/bindings/aps/start`, { method: 'POST', headers: headers(), body: '{}' })).json(); + assert.equal(start.authKind, 'password-exchange'); + const completeResponse = await fetch(`${server.url}/v1/bindings/aps/complete`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ accountId: 'E1001', password: 'demo-pass' }), + }); + assert.equal(completeResponse.status, 200); + const bound = (await completeResponse.json()).binding; + assert.equal(bound.status, 'active'); + assert.equal(bound.accountId, 'E1001'); + assert.equal(bound.displayName, '张工'); + + // 密码与 token 都不能落库 + const raw = readFileSync(dbPath, 'utf8'); + assert.ok(!raw.includes('demo-pass'), '密码绝不能落库'); + assert.ok(!raw.includes('tok-1'), 'token 只能以密文存在'); + + // 4) 绑定后调用成功,而且带上了 token + const ok = await server.service.invoke({ subject, sessionId: 's', projectId: 'p-default', capabilityId: 'aps.orders.query', params: {} }); + assert.equal(ok.status, 'succeeded'); + assert.equal(ok.result.total, 7); + assert.equal(stub.calls.at(-1).token.startsWith('tok-'), true, '业务系统收到的应该是绑定时换来的 token'); + + // 5) 业务系统作废登录态:第一次调用被业务系统拒,之后绑定变 expired,不再拿旧凭据去撞 + await stub.invalidate(); + const firstAfterInvalidate = await server.service.invoke({ subject, sessionId: 's', projectId: 'p-default', capabilityId: 'aps.orders.query', params: {} }); + assert.equal(firstAfterInvalidate.status, 'failed'); + assert.equal(firstAfterInvalidate.errorKind, 'forbidden'); + const bindingAfter = await (await fetch(`${server.url}/v1/bindings`, { headers: headers() })).json(); + assert.equal(bindingAfter.bindings.find((b) => b.providerId === 'aps').status, 'expired'); + + const callsBefore = stub.calls.length; + const blocked = await server.service.invoke({ subject, sessionId: 's', projectId: 'p-default', capabilityId: 'aps.orders.query', params: {} }); + assert.equal(blocked.status, 'failed'); + assert.match(String(blocked.result.message), /重新绑定/); + assert.equal(stub.calls.length, callsBefore, '过期后不该再打业务系统'); + + const trace = server.service.controlPlane.audit.trace(blocked.executionId); + assert.equal(trace[0].decision, 'policy.identity_expired'); + + // 6) 重新绑定后恢复 + await fetch(`${server.url}/v1/bindings/aps/complete`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ accountId: 'E1001', password: 'demo-pass' }), + }); + const recovered = await server.service.invoke({ subject, sessionId: 's', projectId: 'p-default', capabilityId: 'aps.orders.query', params: {} }); + assert.equal(recovered.status, 'succeeded', '重新绑定后应恢复'); + + // 7) 解绑:凭据与映射都删掉,调用回到未绑定状态 + const removed = await (await fetch(`${server.url}/v1/bindings/aps`, { method: 'DELETE', headers: headers() })).json(); + assert.equal(removed.removed, true); + const afterUnbind = await server.service.invoke({ subject, sessionId: 's', projectId: 'p-default', capabilityId: 'aps.orders.query', params: {} }); + assert.equal(afterUnbind.status, 'failed'); + assert.match(String(afterUnbind.result.message), /绑定/); + } finally { + await server.close(); + await stub.close(); + delete process.env.APS_PROVIDER_APS_URL; + delete process.env.APS_CREDENTIAL_KEY_FILE; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('A5.7 解绑 APS 不影响另一个系统', async () => { + const dir = mkdtempSync(join(tmpdir(), 'aps-bind2-')); + process.env.APS_CREDENTIAL_KEY_FILE = join(dir, 'secret.key'); + const stub = await startStubSystem(); + const server = await startPlatform(stub.baseUrl); + try { + const { createFakeProvider } = await import('@aps/provider-sdk/testing'); + const other = createFakeProvider('erp'); + await server.service.registerProvider(other); + server.service.controlPlane.identity.bind({ userId: subject.userId, tenantId: subject.tenantId, providerId: 'erp', accountId: 'R-1' }); + + await fetch(`${server.url}/v1/bindings/aps/complete`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ accountId: 'E1001', password: 'demo-pass' }), + }); + await fetch(`${server.url}/v1/bindings/aps`, { method: 'DELETE', headers: headers() }); + + const otherCall = await server.service.invoke({ subject, sessionId: 's', projectId: 'p-default', capabilityId: 'erp.echo', params: { text: 'hi' } }); + assert.equal(otherCall.status, 'succeeded', '解绑 APS 不该影响 ERP 的登录态'); + } finally { + await server.close(); + await stub.close(); + delete process.env.APS_PROVIDER_APS_URL; + delete process.env.APS_CREDENTIAL_KEY_FILE; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('A5.7 绑定失败不建绑定:账号密码不对就是拒绝', async () => { + const dir = mkdtempSync(join(tmpdir(), 'aps-bind3-')); + process.env.APS_CREDENTIAL_KEY_FILE = join(dir, 'secret.key'); + const stub = await startStubSystem(); + const server = await startPlatform(stub.baseUrl); + try { + const response = await fetch(`${server.url}/v1/bindings/aps/complete`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ accountId: 'E1001', password: 'wrong' }), + }); + + assert.equal(response.status, 403); + const list = await (await fetch(`${server.url}/v1/bindings`, { headers: headers() })).json(); + assert.equal(list.bindings.find((b) => b.providerId === 'aps').status, 'unbound'); + } finally { + await server.close(); + await stub.close(); + delete process.env.APS_PROVIDER_APS_URL; + delete process.env.APS_CREDENTIAL_KEY_FILE; + rmSync(dir, { recursive: true, force: true }); + } +}); \ No newline at end of file diff --git a/scripts/check-contracts.test.mjs b/scripts/check-contracts.test.mjs index a5ec1f1..23a6a0e 100644 --- a/scripts/check-contracts.test.mjs +++ b/scripts/check-contracts.test.mjs @@ -104,7 +104,7 @@ test('每个 HTTP payload 类型都有样例', async () => { test('协议文档端点表与 ENDPOINT_SPECS 一致', async () => { const protocol = await readText('contracts/protocol.ts'); - const specs = [...protocol.matchAll(/method:\s*'(GET|POST)',\s*path:\s*'([^']+)',\s*request:\s*'([^']*)',\s*response:\s*'([^']*)'/g)].map((m) => ({ + const specs = [...protocol.matchAll(/method:\s*'(GET|POST|PUT|PATCH|DELETE)',\s*path:\s*'([^']+)',\s*request:\s*'([^']*)',\s*response:\s*'([^']*)'/g)].map((m) => ({ method: m[1], path: m[2], request: m[3], @@ -113,7 +113,7 @@ test('协议文档端点表与 ENDPOINT_SPECS 一致', async () => { assert.ok(specs.length >= 12, `ENDPOINT_SPECS 条目太少:${specs.length}`); const doc = await readText('docs/spec/protocol.md'); - const rows = [...doc.matchAll(/^\|\s*(GET|POST)\s*\|\s*`([^`]+)`\s*\|([^|]*)\|([^|]*)\|/gm)].map((m) => ({ + const rows = [...doc.matchAll(/^\|\s*(GET|POST|PUT|PATCH|DELETE)\s*\|\s*`([^`]+)`\s*\|([^|]*)\|([^|]*)\|/gm)].map((m) => ({ method: m[1], path: m[2], request: m[3].trim(), diff --git a/server/app/service.ts b/server/app/service.ts index 810f62f..2d52ae5 100644 --- a/server/app/service.ts +++ b/server/app/service.ts @@ -6,7 +6,7 @@ */ import { randomBytes } from 'node:crypto'; -import { PROTOCOL_VERSION, type CapabilitiesResponse, type CancelResponse, type CreateSessionRequest, type CreateSessionResponse, type HealthResponse, type MeResponse, type PlatformEvent, type ProjectListResponse, type SendMessageRequest, type SendMessageResponse, type SessionDetailResponse, type SessionListResponse, type ApprovalDecisionRequest, type ApprovalDecisionResponse, type ExecutionTraceResponse } from '@aps/contracts'; +import { PROTOCOL_VERSION, type CapabilitiesResponse, type CancelResponse, type CreateSessionRequest, type CreateSessionResponse, type HealthResponse, type MeResponse, type PlatformEvent, type ProjectListResponse, type SendMessageRequest, type SendMessageResponse, type SessionDetailResponse, type SessionListResponse, type ApprovalDecisionRequest, type ApprovalDecisionResponse, type ExecutionTraceResponse, type BindingCompleteRequest, type BindingCompleteResponse, type BindingDeleteResponse, type BindingListResponse, type BindingStartResponse, type BindingSummary } from '@aps/contracts'; import { CapabilityRegistry } from '../core/registry/registry.js'; import { createCore, type Core, type InvokeRequest } from '../core/execution/invoke.js'; @@ -31,7 +31,7 @@ import { runTurn } from './turn-runner.js'; import { buildContext, type ContextBudget } from './context-builder.js'; import type { ContextRepository } from '../core/storage/context.js'; import { PLATFORM_SYSTEM_PROMPT } from './system-prompt.js'; -import type { Provider, ProviderManifest } from '@aps/provider-sdk'; +import { ProviderError, type Provider, type ProviderAuthSpec, type ProviderManifest } from '@aps/provider-sdk'; import { AppError, badRequest, conflict } from './errors.js'; import { ProjectStore } from './projects.js'; import { newId, SessionStore, type SessionRecord } from './sessions.js'; @@ -127,6 +127,106 @@ export class ApplicationService { return action.receipt; } + /** 我绑定了哪些业务系统账号(含未绑的:界面据此提示去绑) */ + bindings(subject: Subject): BindingListResponse { + return { bindings: this.registry.providerIds().map((providerId) => this.bindingSummary(subject, providerId)) }; + } + + /** 发起绑定:按 Provider 声明的登录方式返回下一步(oauth 给跳转地址,密码类直接进 complete) */ + bindingStart(providerId: string): BindingStartResponse { + const { auth } = this.requireAuthProvider(providerId); + return { + providerId, + authKind: auth.kind, + ...(auth.authorizationUrl === undefined ? {} : { authorizationUrl: auth.authorizationUrl }), + }; + } + + /** + * 完成绑定:去业务系统换凭据 → 体检 → 加密存进凭据代理 → 记下账号映射。 + * 密码只在这一条请求的内存里存在,落库的只有换回来的 token 密文。 + */ + async bindingComplete(subject: Subject, providerId: string, body: BindingCompleteRequest): Promise { + const manifest = this.requireAuthProvider(providerId); + const provider = this.registry.provider(providerId); + if (typeof body.accountId !== 'string' || body.accountId.trim() === '') throw badRequest('accountId 必填'); + if (provider?.exchange === undefined) { + throw new AppError('not_implemented', `${providerId} 声明了 ${manifest.auth.kind} 登录方式但没实现 exchange()`, { status: 501 }); + } + let result; + try { + result = await provider.exchange({ + accountId: body.accountId.trim(), + ...(body.password === undefined ? {} : { password: body.password }), + ...(body.code === undefined ? {} : { code: body.code }), + ...(body.state === undefined ? {} : { state: body.state }), + }); + } catch (error) { + // 绑定失败要给人看的原因:账号密码不对是 403,连不上是 503,其余按 400 + const kind = error instanceof ProviderError ? error.kind : 'unknown_result'; + const status = kind === 'forbidden' ? 403 : kind === 'unavailable' ? 503 : 400; + throw new AppError(kind === 'forbidden' ? 'forbidden' : 'invalid_request', error instanceof Error ? error.message : '绑定失败', { status }); + } + if (provider.checkCredential !== undefined) { + const check = await provider.checkCredential(result.secret); + if (!check.ok) throw new AppError('forbidden', check.reason ?? '登录态没通过校验', { status: 403 }); + } + this.controlPlane.identity.bind({ + userId: subject.userId, + tenantId: subject.tenantId, + providerId, + accountId: result.accountId, + ...(result.displayName === undefined ? {} : { displayName: result.displayName }), + status: 'active', + }); + this.controlPlane.credentials.put(subject, providerId, result.secret); + this.controlPlane.audit.append({ + actorUserId: subject.userId, + tenantId: subject.tenantId, + event: 'binding.created', + providerId, + decision: 'active', + detail: { accountId: result.accountId, authKind: manifest.auth.kind }, + }); + return { binding: this.bindingSummary(subject, providerId) }; + } + + /** 解绑:删掉凭据与账号映射;执行历史与审计保留(ADR-0008) */ + unbind(subject: Subject, providerId: string): BindingDeleteResponse { + const provider = this.registry.provider(providerId); + if (provider === undefined) throw new AppError('not_found', `没有注册 ${providerId}`, { status: 404 }); + this.controlPlane.credentials.remove(subject, providerId); + const removed = this.controlPlane.identity.unbind(subject, providerId); + this.controlPlane.audit.append({ + actorUserId: subject.userId, + tenantId: subject.tenantId, + event: 'binding.removed', + providerId, + decision: removed ? 'removed' : 'not-bound', + }); + return { providerId, removed }; + } + + private bindingSummary(subject: Subject, providerId: string): BindingSummary { + const authKind = this.registry.manifest(providerId)?.auth?.kind; + const binding = this.controlPlane.identity.resolve(subject, providerId); + return { + providerId, + accountId: binding?.accountId ?? null, + displayName: binding?.displayName ?? null, + status: binding?.status ?? 'unbound', + ...(authKind === undefined ? {} : { authKind }), + ...(binding === undefined ? {} : { updatedAt: binding.updatedAt }), + }; + } + + private requireAuthProvider(providerId: string): ProviderManifest & { auth: ProviderAuthSpec } { + const manifest = this.registry.manifest(providerId); + if (manifest === undefined) throw new AppError('not_found', `没有注册 ${providerId}`, { status: 404 }); + if (manifest.auth === undefined) throw badRequest(`${providerId} 没有账号概念(未声明 auth)`); + return manifest as ProviderManifest & { auth: ProviderAuthSpec }; + } + /** 执行追溯(A4.5):按 executionId 追到人、参数摘要、审批与回执;只有本人能看 */ executionTrace(subject: Subject, executionId: string): ExecutionTraceResponse { const record = this.core.executions.get(executionId); diff --git a/server/core/audit/log.ts b/server/core/audit/log.ts index c2bdb2a..163a1c2 100644 --- a/server/core/audit/log.ts +++ b/server/core/audit/log.ts @@ -19,7 +19,10 @@ export type AuditEvent = | 'approval.expired' | 'execution.started' | 'execution.finished' - | 'provider.denied'; + | 'provider.denied' + | 'binding.created' + | 'binding.removed' + | 'identity.expired'; export interface AuditEntryInput { actorUserId: string; diff --git a/server/core/execution/invoke.ts b/server/core/execution/invoke.ts index 25d7ad8..389794e 100644 --- a/server/core/execution/invoke.ts +++ b/server/core/execution/invoke.ts @@ -82,6 +82,8 @@ interface RunInput { idempotencyKey?: string; timeoutMs: number; binding?: AccountBinding; + /** Provider 声明的登录方式;有值才谈得上"登录态失效" */ + authKind?: string; } export function createCore(options: CoreOptions): Core { @@ -165,6 +167,23 @@ export function createCore(options: CoreOptions): Core { } executions.complete(input.executionId, receipt); + // 业务系统说"没登录/没权限":把绑定标成过期,后续在策略层直接拒(ADR-0012) + if (receipt.status === 'failed' && receipt.errorKind === 'forbidden' && control && input.authKind !== undefined && input.binding !== undefined) { + const expired = control.identity.expire(input.subject, input.providerId); + if (expired !== undefined) { + control.audit.append({ + actorUserId: input.subject.userId, + tenantId: input.subject.tenantId, + event: 'identity.expired', + executionId: input.executionId, + sessionId: input.sessionId, + capabilityId: input.capability.id, + providerId: input.providerId, + decision: 'expired', + detail: { authKind: input.authKind, reason: '业务系统拒绝,需重新绑定' }, + }); + } + } control?.audit.append({ actorUserId: input.subject.userId, tenantId: input.subject.tenantId, @@ -213,6 +232,7 @@ export function createCore(options: CoreOptions): Core { params: request.params, ...(request.idempotencyKey === undefined ? {} : { idempotencyKey: request.idempotencyKey }), timeoutMs: request.timeoutMs ?? defaultTimeoutMs, + ...(resolved.authKind === undefined ? {} : { authKind: resolved.authKind }), }; if (!control) return run(base); @@ -381,6 +401,7 @@ export function createCore(options: CoreOptions): Core { ...(current.idempotencyKey === undefined ? {} : { idempotencyKey: current.idempotencyKey }), timeoutMs: defaultTimeoutMs, ...(binding === undefined ? {} : { binding }), + ...(resolved.authKind === undefined ? {} : { authKind: resolved.authKind }), }); control.approvals.complete(current.executionId, receipt); return { kind: 'ok', approvalId, decision: 'approved', receipt }; diff --git a/server/core/identity/context.ts b/server/core/identity/context.ts index 40e85d9..9dd9cb3 100644 --- a/server/core/identity/context.ts +++ b/server/core/identity/context.ts @@ -21,8 +21,8 @@ export interface AccountBinding { /** 该用户在业务系统里的账号标识(工号、登录名等),不含密码 */ accountId: string; displayName: string; - /** disabled = 已停用;停用后该系统的调用一律拒绝,其他系统不受影响 */ - status: 'active' | 'disabled'; + /** active = 可用;expired = 登录态失效需重新绑定;disabled = 已停用。后两者都拒绝,但提示不同 */ + status: 'active' | 'expired' | 'disabled'; createdAt: number; updatedAt: number; } @@ -53,7 +53,7 @@ const toBinding = (row: BindingRow): AccountBinding => ({ providerId: row.provider_id, accountId: row.account_id, displayName: row.display_name, - status: row.status === 'disabled' ? 'disabled' : 'active', + status: row.status === 'disabled' ? 'disabled' : row.status === 'expired' ? 'expired' : 'active', createdAt: row.created_at, updatedAt: row.updated_at, }); @@ -118,9 +118,21 @@ export class IdentityContext { /** 停用:只影响这一个系统,其他系统的登录态与绑定不动 */ disable(subject: IdentitySubject, providerId: string): AccountBinding | undefined { + return this.setStatus(subject, providerId, 'disabled'); + } + + /** + * 登录态失效:业务系统拒绝了这次调用(401/403),账号映射还在但凭据不能用了。 + * 标成 expired 之后策略层会直接拒——不再拿旧凭据反复去撞系统。 + */ + expire(subject: IdentitySubject, providerId: string): AccountBinding | undefined { + return this.setStatus(subject, providerId, 'expired'); + } + + private setStatus(subject: IdentitySubject, providerId: string, status: AccountBinding['status']): AccountBinding | undefined { const existing = this.resolve(subject, providerId); if (!existing) return undefined; - return this.bind({ ...existing, status: 'disabled' }); + return this.bind({ ...existing, status }); } resolve(subject: IdentitySubject, providerId: string): AccountBinding | undefined { diff --git a/server/core/policy/engine.ts b/server/core/policy/engine.ts index 7d3bce4..e6fdf3a 100644 --- a/server/core/policy/engine.ts +++ b/server/core/policy/engine.ts @@ -55,6 +55,9 @@ export function createRulePolicy(): PolicyEngine { if (input.requiresIdentity && !input.binding) { return DENY('policy.identity_unbound', `还没有绑定 ${input.providerId} 的系统账号,先绑定再调用`); } + if (input.binding?.status === 'expired') { + return DENY('policy.identity_expired', `${input.providerId} 的登录态已失效,请重新绑定`); + } if (input.binding?.status === 'disabled') { return DENY('policy.identity_disabled', `${input.providerId} 的系统账号已停用`); } diff --git a/server/core/registry/registry.ts b/server/core/registry/registry.ts index bef0c3d..ac879cb 100644 --- a/server/core/registry/registry.ts +++ b/server/core/registry/registry.ts @@ -17,6 +17,8 @@ export interface ResolvedCapability { providerId: string; /** 该系统是否需要平台用户绑定账号(A4.1);缺省 true */ requiresIdentity: boolean; + /** 登录方式声明(ADR-0012);没声明表示该系统没有账号概念 */ + authKind?: 'oauth' | 'password-exchange' | 'apikey'; } export class CapabilityRegistry { @@ -44,12 +46,23 @@ export class CapabilityRegistry { provider: entry.provider, providerId: entry.manifest.id, requiresIdentity: entry.manifest.requiresIdentity !== false, + ...(entry.manifest.auth === undefined ? {} : { authKind: entry.manifest.auth.kind }), }; } } return undefined; } + /** 某个 Provider 的 manifest(绑定流程要读它的 auth 声明) */ + manifest(providerId: string): ProviderManifest | undefined { + return this.providers.get(providerId)?.manifest; + } + + /** 某个 Provider 实例(绑定流程要用它的 exchange / checkCredential) */ + provider(providerId: string): Provider | undefined { + return this.providers.get(providerId)?.provider; + } + providerIds(): string[] { return [...this.providers.keys()]; } diff --git a/server/entry/http.ts b/server/entry/http.ts index 2221554..e116adf 100644 --- a/server/entry/http.ts +++ b/server/entry/http.ts @@ -9,7 +9,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import type { Subject } from '../app/subject.js'; -import { ENDPOINT_SPECS, type ApprovalDecisionRequest, type CreateSessionRequest, type EndpointSpec, type PlatformEvent, type SendMessageRequest } from '@aps/contracts'; +import { ENDPOINT_SPECS, type ApprovalDecisionRequest, type BindingCompleteRequest, type CreateSessionRequest, type EndpointSpec, type PlatformEvent, type SendMessageRequest } from '@aps/contracts'; import { AppError } from '../app/errors.js'; import type { ApplicationService } from '../app/service.js'; @@ -109,6 +109,19 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, service: const executionId = params.get('executionId') ?? ''; return sendJson(res, 200, service.executionTrace(subject, executionId)); } + case 'GET /v1/bindings': + return sendJson(res, 200, service.bindings(subject)); + case 'POST /v1/bindings/{providerId}/start': + // 发起绑定:按 Provider 声明的登录方式给出下一步(oauth 返回跳转地址) + return sendJson(res, 200, service.bindingStart(params.get('providerId') ?? '')); + case 'POST /v1/bindings/{providerId}/complete': { + // 完成绑定:密码只在这一条请求里出现,服务端换完凭据即丢弃(ADR-0012) + const providerId = params.get('providerId') ?? ''; + const body = (await readJson(req)) as BindingCompleteRequest; + return sendJson(res, 200, await service.bindingComplete(subject, providerId, body)); + } + case 'DELETE /v1/bindings/{providerId}': + return sendJson(res, 200, service.unbind(subject, params.get('providerId') ?? '')); case 'POST /v1/approvals/{approvalId}': { // 确认卡响应(协议第 7 节):只有会话发起人能确认;版本或有效期不符会返回 expired const approvalId = params.get('approvalId') ?? ''; @@ -216,7 +229,7 @@ function setCorsHeaders(req: IncomingMessage, res: ServerResponse): void { res.setHeader('vary', 'origin'); res.setHeader('access-control-allow-credentials', 'true'); } - res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS'); + res.setHeader('access-control-allow-methods', 'GET,POST,DELETE,OPTIONS'); res.setHeader('access-control-allow-headers', 'content-type,last-event-id,x-aps-client,authorization'); } diff --git a/server/provider-sdk/index.ts b/server/provider-sdk/index.ts index cd250a4..c3c780f 100644 --- a/server/provider-sdk/index.ts +++ b/server/provider-sdk/index.ts @@ -46,6 +46,11 @@ export interface ProviderManifest { * 缺省 true:真实系统默认要绑定;只有无账号概念的自建/联调 Provider 才写 false。 */ requiresIdentity?: boolean; + /** + * 登录方式声明(ADR-0012):平台按它走同一条绑定流程,不自己实现业务系统的登录页。 + * 不声明 = 该系统没有账号概念(与 requiresIdentity: false 等价)。 + */ + auth?: ProviderAuthSpec; capabilities: Capability[]; } @@ -102,6 +107,43 @@ export interface HealthStatus { checkedAt: number; } +/** + * 登录方式(ADR-0012): + * - `oauth`:跳转业务系统授权登录,平台收回调 + * - `password-exchange`:平台收一次账号密码,换成 token,密码用完即弃 + * - `apikey`:管理员配置的长期密钥,挂在租户或实例上(不按人) + */ +export interface ProviderAuthSpec { + kind: 'oauth' | 'password-exchange' | 'apikey'; + /** oauth 用:授权入口与回调参数由 Provider 声明 */ + authorizationUrl?: string; + scopes?: string[]; +} + +/** 绑定请求:密码只在这一次调用的内存里存在,Provider 不得持久化 */ +export interface AuthExchangeRequest { + accountId: string; + password?: string; + /** oauth 回调带回来的授权码 */ + code?: string; + state?: string; +} + +/** 交换结果:平台把 secret 加密存进凭据代理,之后按 execution 签发一次性句柄 */ +export interface AuthExchangeResult { + accountId: string; + displayName?: string; + secret: Record; + /** 凭据到期时间(epoch ms);不填表示不过期 */ + expiresAt?: number; +} + +/** 凭据体检结果:给"登录态失效"一个明确信号,而不是等调用失败才发现 */ +export interface CredentialCheck { + ok: boolean; + reason?: string; +} + export interface Provider { describe(): Promise; invoke(context: ExecutionContext, call: CapabilityCall): Promise; @@ -111,6 +153,16 @@ export interface Provider { * 不实现时平台用能力描述兜底(只影响确认卡信息量,不影响策略判定)。 */ preview?(context: ExecutionContext, call: CapabilityCall): Promise; + /** + * 可选:账号绑定时的凭据交换(ADR-0012)。 + * 声明了 auth 的 Provider 必须实现它,否则平台无法完成绑定。 + */ + exchange?(request: AuthExchangeRequest): Promise; + /** + * 可选:凭据体检。平台在绑定完成时与登录态疑似失效时调用它, + * 返回 ok=false 就把绑定标记为"需要重新绑定",后续调用在策略层直接被拒。 + */ + checkCredential?(secret: Record): Promise; } /** 可选的执行状态查询:异步能力(mode=async)应实现 */