Dev login no longer honors injected roles; self-service simulate/convert blocks R4 disclosure grades like the UI and chat already do. Reuse of a convert idempotency key with a different body returns 409. CT6 redeem qty is derived from T+2 lots instead of a fixed 35000; CT7-05 and CT10-07 assertions follow. Update project memory and v1.1 post-fix test artifacts.
179 lines
5.1 KiB
Python
179 lines
5.1 KiB
Python
"""JWT 签发与校验(开发环境 HS256)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from datetime import UTC, datetime, timedelta
|
||
from typing import Any
|
||
|
||
from jose import JWTError, jwt
|
||
|
||
from app.config.settings import settings
|
||
from app.model.schemas import TokenType
|
||
from app.utils.exceptions import UnauthorizedError
|
||
|
||
ISSUER = settings.jwt_issuer
|
||
AUDIENCE = settings.jwt_audience
|
||
|
||
ROLE_PERMISSIONS: dict[str, list[str]] = {
|
||
"customer": [
|
||
"agent:customer:chat",
|
||
"profile:l1:read",
|
||
"profile:l1:write",
|
||
"core:holding:read:self",
|
||
],
|
||
"advisor": [
|
||
"agent:advisor:chat",
|
||
"profile:l1:read",
|
||
"profile:l2:read",
|
||
"profile:l2:write",
|
||
"profile:l3:read",
|
||
"core:holding:read:assigned",
|
||
"advisor:workspace",
|
||
"compliance:check",
|
||
"template:read",
|
||
"market_alert:read",
|
||
"market_alert:generate",
|
||
"market_alert:feedback",
|
||
"kyc:create",
|
||
"kyc:chat",
|
||
"kyc:complete",
|
||
"allocation:create",
|
||
"copy:track",
|
||
"dashboard:personal",
|
||
"guard:check",
|
||
],
|
||
"analyst": [
|
||
"agent:analyst:chat",
|
||
"profile:l1:read",
|
||
"profile:l2:read",
|
||
"profile:l3:read",
|
||
"core:holding:read:scoped",
|
||
"sql:execute:readonly",
|
||
"risk:alert:read",
|
||
],
|
||
"risk_officer": [
|
||
"agent:risk:chat",
|
||
"profile:l1:read",
|
||
"profile:l2:read",
|
||
"profile:l3:read",
|
||
"profile:l3:write",
|
||
"risk:alert:write",
|
||
"risk:suitability:write",
|
||
"core:holding:read:all",
|
||
],
|
||
"risk_manager": [
|
||
"agent:risk:chat",
|
||
"profile:l1:read",
|
||
"profile:l2:read",
|
||
"profile:l3:read",
|
||
"risk:alert:read",
|
||
"core:holding:read:all",
|
||
],
|
||
"compliance": [
|
||
"audit:read:all",
|
||
"agent:advisor:audit",
|
||
"compliance:hit:read",
|
||
"admin:all",
|
||
"compliance:check",
|
||
"compliance:rule:write",
|
||
"template:write",
|
||
"audit:read",
|
||
"dashboard:global",
|
||
"guard:check",
|
||
],
|
||
"ops": ["agent:advisor:stats", "audit:read:aggregated"],
|
||
"service_risk": [
|
||
"agent:risk:suitability_check",
|
||
"risk:suitability:write",
|
||
"profile:l1:read",
|
||
"profile:l2:read",
|
||
"audit:write",
|
||
],
|
||
}
|
||
|
||
DEFAULT_ROLES_BY_ACTOR: dict[str, list[str]] = {
|
||
"STAFF-10086": ["advisor"],
|
||
"STAFF-10087": ["advisor"],
|
||
"STAFF-10088": ["advisor"],
|
||
"STAFF-10089": ["advisor"],
|
||
"STAFF-10090": ["advisor"],
|
||
"STAFF-10091": ["advisor", "compliance"],
|
||
"STAFF-20001": ["analyst"],
|
||
"STAFF-20002": ["analyst"],
|
||
"STAFF-20003": ["analyst"],
|
||
"STAFF-30001": ["risk_officer", "risk_demo"],
|
||
"STAFF-30002": ["risk_officer", "risk_demo"],
|
||
"STAFF-31001": ["risk_manager"],
|
||
"STAFF-40001": ["compliance"],
|
||
"STAFF-40002": ["compliance"],
|
||
"STAFF-50001": ["ops"],
|
||
"CUST-9527": ["customer"],
|
||
"CUST-1001": ["customer"],
|
||
"CUST-1002": ["customer"],
|
||
"CUST-1010": ["customer"],
|
||
}
|
||
|
||
|
||
def infer_roles(actor_id: str, token_type: TokenType, roles: list[str] | None) -> list[str]:
|
||
# CT-001 P0:dev 登录不得信任调用方传入的 roles(防提权);仅白名单表定角色。
|
||
_ = roles
|
||
if token_type == "customer":
|
||
mapped = DEFAULT_ROLES_BY_ACTOR.get(actor_id)
|
||
return mapped if mapped else ["customer"]
|
||
return DEFAULT_ROLES_BY_ACTOR.get(actor_id, [])
|
||
|
||
|
||
def permissions_for_roles(roles: list[str]) -> list[str]:
|
||
perms: list[str] = []
|
||
seen: set[str] = set()
|
||
for role in roles:
|
||
for perm in ROLE_PERMISSIONS.get(role, []):
|
||
if perm not in seen:
|
||
seen.add(perm)
|
||
perms.append(perm)
|
||
return perms
|
||
|
||
|
||
def issue_token(
|
||
actor_id: str,
|
||
token_type: TokenType,
|
||
roles: list[str] | None = None,
|
||
) -> tuple[str, int]:
|
||
role_list = infer_roles(actor_id, token_type, roles)
|
||
now = datetime.now(UTC)
|
||
expires = now + timedelta(hours=settings.jwt_dev_expire_hours)
|
||
payload: dict[str, Any] = {
|
||
"iss": ISSUER,
|
||
"sub": actor_id,
|
||
"aud": AUDIENCE,
|
||
"exp": int(expires.timestamp()),
|
||
"iat": int(now.timestamp()),
|
||
"jti": str(uuid.uuid4()),
|
||
"token_type": token_type,
|
||
"roles": role_list,
|
||
"permissions": permissions_for_roles(role_list),
|
||
"tenant_id": "TENANT-001",
|
||
}
|
||
if token_type == "customer":
|
||
payload["customer_id"] = actor_id
|
||
if "advisor" in role_list and token_type == "staff":
|
||
payload["advisor_id"] = actor_id
|
||
|
||
token = jwt.encode(payload, settings.jwt_dev_secret, algorithm=settings.jwt_dev_algorithm)
|
||
return token, settings.jwt_dev_expire_hours * 3600
|
||
|
||
|
||
def decode_token(token: str) -> dict[str, Any]:
|
||
try:
|
||
payload = jwt.decode(
|
||
token,
|
||
settings.jwt_dev_secret,
|
||
algorithms=[settings.jwt_dev_algorithm],
|
||
audience=AUDIENCE,
|
||
issuer=ISSUER,
|
||
)
|
||
except JWTError as exc:
|
||
raise UnauthorizedError("Token 无效或已过期", error_code="AUTH_401_INVALID") from exc
|
||
return payload
|