跑通迁移与测试后修掉的真问题: - alembic 0001:asyncpg 走预备语句,一次 execute 只能一条 SQL。 CREATE FUNCTION / CREATE POLICY / GRANT 全部拆成单语句逐条执行, 否则报 "cannot insert multiple commands into a prepared statement"。 - 创建工作区:ORM flush 只按 relationship 排序,裸外键不算数, member 可能先于 workspace 落地撞 FK。改为先 flush workspace 再插 member。 - datetime.UTC 是 3.11+ 才有的,本机 3.10 直接 ImportError。改用 timezone.utc。 - 幂等中间件:原来会缓存 4xx。校验失败被缓存后,用户改对参数仍拿到同一个 422。 改为只缓存 <400。 - 发码节流窗口改为可配置 SMS_THROTTLE_SECONDS(测试置 0), 节流用例自己显式打开,避免"整库关掉节流"导致节流从未被验证。 - 租户隔离用例:pytest.raises(Exception) 改为精确的 ProgrammingError, 否则任何异常都会让"应用角色不能建表"这条断言假通过。 验收:pytest 33 passed;ruff check / format 通过;真机跑 uvicorn, /health、/version、登录、建工作区、列表、/auth/me 全通。
This commit is contained in:
@@ -35,3 +35,7 @@ android/local.properties
|
|||||||
pip-unpack-*/
|
pip-unpack-*/
|
||||||
pip-req-build-*/
|
pip-req-build-*/
|
||||||
tmprfx*
|
tmprfx*
|
||||||
|
|
||||||
|
# 本机开发用的便携 PostgreSQL / 临时目录(不入库)
|
||||||
|
.local/
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
|
||||||
from alembic import context
|
|
||||||
from sqlalchemy import pool
|
from sqlalchemy import pool
|
||||||
from sqlalchemy.engine import Connection
|
from sqlalchemy.engine import Connection
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.models import Base
|
from app.models import Base
|
||||||
|
|
||||||
|
|||||||
@@ -19,57 +19,76 @@ depends_on: str | Sequence[str] | None = None
|
|||||||
|
|
||||||
APP_ROLE = "dadao_app"
|
APP_ROLE = "dadao_app"
|
||||||
|
|
||||||
HELPER_SQL = """
|
# 说明:asyncpg 走"预备语句"执行,**一条 execute 里只能有一条 SQL**,
|
||||||
CREATE OR REPLACE FUNCTION app_ws_id() RETURNS uuid
|
# 多条语句会报 `cannot insert multiple commands into a prepared statement`。
|
||||||
LANGUAGE sql STABLE AS $$
|
# 所以这里按语句拆成元组,逐条执行。
|
||||||
|
HELPER_STATEMENTS: tuple[str, ...] = (
|
||||||
|
"""
|
||||||
|
CREATE OR REPLACE FUNCTION app_ws_id() RETURNS uuid
|
||||||
|
LANGUAGE sql STABLE AS $$
|
||||||
SELECT nullif(current_setting('app.ws_id', true), '')::uuid
|
SELECT nullif(current_setting('app.ws_id', true), '')::uuid
|
||||||
$$;
|
$$
|
||||||
|
""",
|
||||||
CREATE OR REPLACE FUNCTION app_user_id() RETURNS uuid
|
"""
|
||||||
LANGUAGE sql STABLE AS $$
|
CREATE OR REPLACE FUNCTION app_user_id() RETURNS uuid
|
||||||
|
LANGUAGE sql STABLE AS $$
|
||||||
SELECT nullif(current_setting('app.user_id', true), '')::uuid
|
SELECT nullif(current_setting('app.user_id', true), '')::uuid
|
||||||
$$;
|
$$
|
||||||
"""
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
POLICY_SQL = f"""
|
POLICY_STATEMENTS: tuple[str, ...] = (
|
||||||
-- ── workspace:本租户 OR 我是其成员(用于"我加入了哪些工作区")──
|
# ── workspace:本租户 OR 我是其成员(用于"我加入了哪些工作区")──
|
||||||
ALTER TABLE workspace ENABLE ROW LEVEL SECURITY;
|
"ALTER TABLE workspace ENABLE ROW LEVEL SECURITY;",
|
||||||
CREATE POLICY workspace_isolation ON workspace
|
"""
|
||||||
|
CREATE POLICY workspace_isolation ON workspace
|
||||||
USING (
|
USING (
|
||||||
id = app_ws_id()
|
id = app_ws_id()
|
||||||
OR id IN (SELECT m.workspace_id FROM member m WHERE m.user_id = app_user_id())
|
OR id IN (SELECT m.workspace_id FROM member m WHERE m.user_id = app_user_id())
|
||||||
)
|
)
|
||||||
WITH CHECK (id = app_ws_id());
|
WITH CHECK (id = app_ws_id());
|
||||||
|
""",
|
||||||
-- ── member:本租户成员 OR 我自己那条 ──
|
# ── member:本租户成员 OR 我自己那条 ──
|
||||||
ALTER TABLE member ENABLE ROW LEVEL SECURITY;
|
"ALTER TABLE member ENABLE ROW LEVEL SECURITY;",
|
||||||
CREATE POLICY member_isolation ON member
|
"""
|
||||||
|
CREATE POLICY member_isolation ON member
|
||||||
USING (workspace_id = app_ws_id() OR user_id = app_user_id())
|
USING (workspace_id = app_ws_id() OR user_id = app_user_id())
|
||||||
WITH CHECK (workspace_id = app_ws_id());
|
WITH CHECK (workspace_id = app_ws_id());
|
||||||
|
""",
|
||||||
-- ── invitation ──
|
# ── invitation ──
|
||||||
ALTER TABLE invitation ENABLE ROW LEVEL SECURITY;
|
"ALTER TABLE invitation ENABLE ROW LEVEL SECURITY;",
|
||||||
CREATE POLICY invitation_isolation ON invitation
|
"""
|
||||||
|
CREATE POLICY invitation_isolation ON invitation
|
||||||
USING (workspace_id = app_ws_id())
|
USING (workspace_id = app_ws_id())
|
||||||
WITH CHECK (workspace_id = app_ws_id());
|
WITH CHECK (workspace_id = app_ws_id());
|
||||||
|
""",
|
||||||
-- ── audit_log:只增不改,租户内可见 ──
|
# ── audit_log:只增不改,租户内可见 ──
|
||||||
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
|
"ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;",
|
||||||
CREATE POLICY audit_log_isolation ON audit_log
|
"""
|
||||||
|
CREATE POLICY audit_log_isolation ON audit_log
|
||||||
USING (workspace_id = app_ws_id())
|
USING (workspace_id = app_ws_id())
|
||||||
WITH CHECK (workspace_id = app_ws_id());
|
WITH CHECK (workspace_id = app_ws_id());
|
||||||
|
""",
|
||||||
|
# ── 授权:应用角色没有 DDL、没有 BYPASSRLS,只能读写到自己该看的数据 ──
|
||||||
|
f"GRANT USAGE ON SCHEMA public TO {APP_ROLE};",
|
||||||
|
(
|
||||||
|
"GRANT SELECT, INSERT, UPDATE, DELETE ON "
|
||||||
|
f"workspace, app_user, member, invitation, audit_log TO {APP_ROLE};"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public "
|
||||||
|
f"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {APP_ROLE};"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
-- ── 授权:应用角色没有 DDL、没有 BYPASSRLS,只能读写到自己该看的数据 ──
|
|
||||||
GRANT USAGE ON SCHEMA public TO {APP_ROLE};
|
def _exec_each(statements: tuple[str, ...]) -> None:
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON
|
for statement in statements:
|
||||||
workspace, app_user, member, invitation, audit_log TO {APP_ROLE};
|
op.execute(statement)
|
||||||
ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {APP_ROLE};
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.execute(HELPER_SQL)
|
_exec_each(HELPER_STATEMENTS)
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"app_user",
|
"app_user",
|
||||||
@@ -184,7 +203,7 @@ def upgrade() -> None:
|
|||||||
)
|
)
|
||||||
op.create_index("ix_audit_ws_created", "audit_log", ["workspace_id", "created_at"])
|
op.create_index("ix_audit_ws_created", "audit_log", ["workspace_id", "created_at"])
|
||||||
|
|
||||||
op.execute(POLICY_SQL)
|
_exec_each(POLICY_STATEMENTS)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
@@ -194,4 +213,5 @@ def downgrade() -> None:
|
|||||||
op.drop_table("member")
|
op.drop_table("member")
|
||||||
op.drop_table("workspace")
|
op.drop_table("workspace")
|
||||||
op.drop_table("app_user")
|
op.drop_table("app_user")
|
||||||
op.execute("DROP FUNCTION IF EXISTS app_ws_id(); DROP FUNCTION IF EXISTS app_user_id();")
|
op.execute("DROP FUNCTION IF EXISTS app_ws_id();")
|
||||||
|
op.execute("DROP FUNCTION IF EXISTS app_user_id();")
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from ..config import get_settings
|
|||||||
from ..deps import CurrentUser, SessionDep
|
from ..deps import CurrentUser, SessionDep
|
||||||
from ..errors import AppError, ErrorCode
|
from ..errors import AppError, ErrorCode
|
||||||
from ..models import AppUser, Member, Workspace
|
from ..models import AppUser, Member, Workspace
|
||||||
from ..schemas import Envelope, ok
|
from ..schemas import ok
|
||||||
from ..security import create_token_pair, decode_token
|
from ..security import create_token_pair, decode_token
|
||||||
from ..stores import get_store, sms_code_key, sms_throttle_key
|
from ..stores import get_store, sms_code_key, sms_throttle_key
|
||||||
|
|
||||||
@@ -43,9 +43,13 @@ class RefreshIn(BaseModel):
|
|||||||
@router.post("/sms/send", summary="发送登录验证码")
|
@router.post("/sms/send", summary="发送登录验证码")
|
||||||
async def send_code(body: SendCodeIn) -> dict:
|
async def send_code(body: SendCodeIn) -> dict:
|
||||||
store = get_store()
|
store = get_store()
|
||||||
hits = await store.incr(sms_throttle_key(body.phone), 60)
|
if settings.sms_throttle_seconds > 0:
|
||||||
|
hits = await store.incr(sms_throttle_key(body.phone), settings.sms_throttle_seconds)
|
||||||
if hits > 1:
|
if hits > 1:
|
||||||
raise AppError(ErrorCode.SMS_TOO_FREQUENT, "发送过于频繁,请 60 秒后重试")
|
raise AppError(
|
||||||
|
ErrorCode.SMS_TOO_FREQUENT,
|
||||||
|
f"发送过于频繁,请 {settings.sms_throttle_seconds} 秒后重试",
|
||||||
|
)
|
||||||
|
|
||||||
if settings.sms_provider == "dev":
|
if settings.sms_provider == "dev":
|
||||||
code = settings.sms_dev_code
|
code = settings.sms_dev_code
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class UserOut(BaseModel):
|
|||||||
class TokenOut(BaseModel):
|
class TokenOut(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer" # noqa: S105 - 不是密码,是 OAuth2 的固定字段值
|
||||||
expires_in: int
|
expires_in: int
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Query, Request
|
from fastapi import APIRouter, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -14,7 +14,7 @@ from ..db import bind_tenant
|
|||||||
from ..deps import AdminDep, CurrentUser, MemberDep, SessionDep, TenantDep
|
from ..deps import AdminDep, CurrentUser, MemberDep, SessionDep, TenantDep
|
||||||
from ..errors import AppError, ErrorCode
|
from ..errors import AppError, ErrorCode
|
||||||
from ..models import AppUser, Invitation, Member, Workspace
|
from ..models import AppUser, Invitation, Member, Workspace
|
||||||
from ..schemas import Page, decode_cursor, encode_cursor, ok
|
from ..schemas import decode_cursor, encode_cursor, ok
|
||||||
from ..security import invite_code_matches, new_invite_code
|
from ..security import invite_code_matches, new_invite_code
|
||||||
|
|
||||||
router = APIRouter(prefix="/workspaces", tags=["工作区"])
|
router = APIRouter(prefix="/workspaces", tags=["工作区"])
|
||||||
@@ -66,9 +66,10 @@ async def create_workspace(
|
|||||||
|
|
||||||
ws = Workspace(id=ws_id, name=body.name, slug=slug, owner_id=user.id, plan="free")
|
ws = Workspace(id=ws_id, name=body.name, slug=slug, owner_id=user.id, plan="free")
|
||||||
session.add(ws)
|
session.add(ws)
|
||||||
session.add(
|
# 先刷 workspace 再插 member:ORM 的 flush 顺序只按 relationship 排序,
|
||||||
Member(workspace_id=ws_id, user_id=user.id, role="owner", status="active")
|
# 没有 relationship 的裸外键不会被考虑,member 可能先于 workspace 落地而撞 FK。
|
||||||
)
|
await session.flush()
|
||||||
|
session.add(Member(workspace_id=ws_id, user_id=user.id, role="owner", status="active"))
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await audit(
|
await audit(
|
||||||
session,
|
session,
|
||||||
@@ -156,7 +157,7 @@ async def create_invitation(
|
|||||||
code=new_invite_code(),
|
code=new_invite_code(),
|
||||||
role=body.role,
|
role=body.role,
|
||||||
invited_by=ctx.user.id,
|
invited_by=ctx.user.id,
|
||||||
expires_at=datetime.now(UTC) + timedelta(hours=body.expires_hours),
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=body.expires_hours),
|
||||||
)
|
)
|
||||||
session.add(invitation)
|
session.add(invitation)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -192,19 +193,15 @@ async def join_workspace(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
).scalars()
|
).scalars()
|
||||||
matched = next(
|
matched = next((inv for inv in invitation if invite_code_matches(body.code, inv.code)), None)
|
||||||
(inv for inv in invitation if invite_code_matches(body.code, inv.code)), None
|
|
||||||
)
|
|
||||||
if matched is None:
|
if matched is None:
|
||||||
raise AppError(ErrorCode.INVITATION_INVALID, "邀请码无效或已使用")
|
raise AppError(ErrorCode.INVITATION_INVALID, "邀请码无效或已使用")
|
||||||
if matched.expires_at < datetime.now(UTC):
|
if matched.expires_at < datetime.now(timezone.utc):
|
||||||
raise AppError(ErrorCode.INVITATION_INVALID, "邀请码已过期")
|
raise AppError(ErrorCode.INVITATION_INVALID, "邀请码已过期")
|
||||||
|
|
||||||
existing = (
|
existing = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(Member).where(
|
select(Member).where(Member.workspace_id == ctx.ws_id, Member.user_id == ctx.user.id)
|
||||||
Member.workspace_id == ctx.ws_id, Member.user_id == ctx.user.id
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if existing is not None and existing.status == "active":
|
if existing is not None and existing.status == "active":
|
||||||
@@ -223,7 +220,7 @@ async def join_workspace(
|
|||||||
existing.role = matched.role
|
existing.role = matched.role
|
||||||
existing.status = "active"
|
existing.status = "active"
|
||||||
|
|
||||||
matched.accepted_at = datetime.now(UTC)
|
matched.accepted_at = datetime.now(timezone.utc)
|
||||||
matched.accepted_by = ctx.user.id
|
matched.accepted_by = ctx.user.id
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await audit(
|
await audit(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import Literal
|
|||||||
from pydantic import Field, field_validator
|
from pydantic import Field, field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
DEV_JWT_SECRET = "dev-only-change-me"
|
DEV_JWT_SECRET = "dev-only-change-me" # noqa: S105 - 仅开发默认值,生产启动时会被校验拦下
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -27,9 +27,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# ── 数据库 ────────────────────────────────────────────
|
# ── 数据库 ────────────────────────────────────────────
|
||||||
# 应用角色:受 RLS 约束,非 owner、非 superuser
|
# 应用角色:受 RLS 约束,非 owner、非 superuser
|
||||||
database_url: str = (
|
database_url: str = "postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5433/dadaotask"
|
||||||
"postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5433/dadaotask"
|
|
||||||
)
|
|
||||||
# 迁移角色:owner,可绕过 RLS,仅 alembic / 运维脚本使用
|
# 迁移角色:owner,可绕过 RLS,仅 alembic / 运维脚本使用
|
||||||
migration_database_url: str = (
|
migration_database_url: str = (
|
||||||
"postgresql+asyncpg://dadao_owner:dadao_dev_pw@127.0.0.1:5433/dadaotask"
|
"postgresql+asyncpg://dadao_owner:dadao_dev_pw@127.0.0.1:5433/dadaotask"
|
||||||
@@ -46,6 +44,8 @@ class Settings(BaseSettings):
|
|||||||
access_token_minutes: int = 30
|
access_token_minutes: int = 30
|
||||||
refresh_token_days: int = 14
|
refresh_token_days: int = 14
|
||||||
sms_code_ttl_seconds: int = 300
|
sms_code_ttl_seconds: int = 300
|
||||||
|
# 同一手机号两次发码的最小间隔;测试里设为 0 可关闭节流
|
||||||
|
sms_throttle_seconds: int = 60
|
||||||
sms_dev_code: str = "123456"
|
sms_dev_code: str = "123456"
|
||||||
sms_provider: Literal["dev", "aliyun", "tencent"] = "dev"
|
sms_provider: Literal["dev", "aliyun", "tencent"] = "dev"
|
||||||
|
|
||||||
|
|||||||
+7
-6
@@ -39,16 +39,13 @@ SessionFactory = async_sessionmaker(engine, expire_on_commit=False, autoflush=Fa
|
|||||||
|
|
||||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||||
"""一个请求 = 一个事务。异常即整体回滚。"""
|
"""一个请求 = 一个事务。异常即整体回滚。"""
|
||||||
async with SessionFactory() as session:
|
async with SessionFactory() as session, session.begin():
|
||||||
async with session.begin():
|
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
async def bind_tenant(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID | None) -> None:
|
async def bind_tenant(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID | None) -> None:
|
||||||
"""把租户上下文写进当前事务。必须在任何业务查询之前调用。"""
|
"""把租户上下文写进当前事务。必须在任何业务查询之前调用。"""
|
||||||
await session.execute(
|
await session.execute(text("SELECT set_config('app.ws_id', :ws, true)"), {"ws": str(ws_id)})
|
||||||
text("SELECT set_config('app.ws_id', :ws, true)"), {"ws": str(ws_id)}
|
|
||||||
)
|
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
await session.execute(
|
await session.execute(
|
||||||
text("SELECT set_config('app.user_id', :uid, true)"), {"uid": str(user_id)}
|
text("SELECT set_config('app.user_id', :uid, true)"), {"uid": str(user_id)}
|
||||||
@@ -65,6 +62,7 @@ async def bind_user(session: AsyncSession, user_id: uuid.UUID) -> None:
|
|||||||
async def current_app_role(session: AsyncSession) -> dict[str, Any]:
|
async def current_app_role(session: AsyncSession) -> dict[str, Any]:
|
||||||
"""自检用:确认应用连接的不是特权角色。"""
|
"""自检用:确认应用连接的不是特权角色。"""
|
||||||
row = (
|
row = (
|
||||||
|
(
|
||||||
await session.execute(
|
await session.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT current_user AS role, "
|
"SELECT current_user AS role, "
|
||||||
@@ -73,7 +71,10 @@ async def current_app_role(session: AsyncSession) -> dict[str, Any]:
|
|||||||
"(SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user) AS bypass_rls"
|
"(SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user) AS bypass_rls"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).mappings().one()
|
)
|
||||||
|
.mappings()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from datetime import UTC, datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")
|
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")
|
||||||
@@ -37,10 +37,14 @@ def _mask(value: Any, depth: int = 0) -> Any:
|
|||||||
return "<deep>"
|
return "<deep>"
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return {
|
return {
|
||||||
key: (MASK if any(h in str(key).lower() for h in _SENSITIVE_HINTS) else _mask(val, depth + 1))
|
key: (
|
||||||
|
MASK
|
||||||
|
if any(hint in str(key).lower() for hint in _SENSITIVE_HINTS)
|
||||||
|
else _mask(val, depth + 1)
|
||||||
|
)
|
||||||
for key, val in value.items()
|
for key, val in value.items()
|
||||||
}
|
}
|
||||||
if isinstance(value, (list, tuple)):
|
if isinstance(value, list | tuple):
|
||||||
return [_mask(item, depth + 1) for item in value]
|
return [_mask(item, depth + 1) for item in value]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@@ -48,7 +52,7 @@ def _mask(value: Any, depth: int = 0) -> Any:
|
|||||||
class JsonFormatter(logging.Formatter):
|
class JsonFormatter(logging.Formatter):
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"ts": datetime.now(UTC).isoformat(timespec="milliseconds"),
|
"ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
|
||||||
"level": record.levelname,
|
"level": record.levelname,
|
||||||
"logger": record.name,
|
"logger": record.name,
|
||||||
"msg": record.getMessage(),
|
"msg": record.getMessage(),
|
||||||
|
|||||||
+4
-2
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -28,7 +27,10 @@ logger = get_logger("app")
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
settings.validate_runtime()
|
settings.validate_runtime()
|
||||||
logger.info("服务启动", extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}})
|
logger.info(
|
||||||
|
"服务启动",
|
||||||
|
extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}},
|
||||||
|
)
|
||||||
yield
|
yield
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,9 @@ class IdempotencyMiddleware(BaseHTTPMiddleware):
|
|||||||
)
|
)
|
||||||
|
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
if response.status_code >= 500:
|
# 只缓存成功响应:4xx 往往是"这次请求本身有问题"(校验失败、权限不足),
|
||||||
|
# 缓存住会让用户改对参数后仍然拿到同一个错误。
|
||||||
|
if response.status_code >= 400:
|
||||||
return response
|
return response
|
||||||
|
|
||||||
body = b""
|
body = b""
|
||||||
|
|||||||
@@ -11,19 +11,21 @@ import os
|
|||||||
|
|
||||||
os.environ["ENV"] = "test"
|
os.environ["ENV"] = "test"
|
||||||
os.environ.setdefault("SMS_DEV_CODE", "123456")
|
os.environ.setdefault("SMS_DEV_CODE", "123456")
|
||||||
|
# 测试里关掉发码节流,否则"同一手机号再登录一次"要等 60 秒
|
||||||
|
os.environ.setdefault("SMS_THROTTLE_SECONDS", "0")
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from alembic import command
|
|
||||||
from alembic.config import Config
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
from sqlalchemy.pool import NullPool
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db import engine
|
from app.db import engine
|
||||||
from app.main import app
|
from app.main import app
|
||||||
@@ -82,9 +84,7 @@ async def login(client: AsyncClient, phone: str | None = None) -> dict[str, Any]
|
|||||||
"""发码 + 登录,返回 {token, refresh_token, user_id, phone}。"""
|
"""发码 + 登录,返回 {token, refresh_token, user_id, phone}。"""
|
||||||
phone = phone or f"138{uuid.uuid4().int % 100_000_000:08d}"
|
phone = phone or f"138{uuid.uuid4().int % 100_000_000:08d}"
|
||||||
await client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
await client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||||
data = unwrap(
|
data = unwrap(await client.post("/api/v1/auth/login", json={"phone": phone, "code": DEV_CODE}))
|
||||||
await client.post("/api/v1/auth/login", json={"phone": phone, "code": DEV_CODE})
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"token": data["tokens"]["access_token"],
|
"token": data["tokens"]["access_token"],
|
||||||
"refresh_token": data["tokens"]["refresh_token"],
|
"refresh_token": data["tokens"]["refresh_token"],
|
||||||
@@ -95,4 +95,6 @@ async def login(client: AsyncClient, phone: str | None = None) -> dict[str, Any]
|
|||||||
|
|
||||||
|
|
||||||
async def create_workspace(client: AsyncClient, user: dict[str, Any], name: str) -> dict[str, Any]:
|
async def create_workspace(client: AsyncClient, user: dict[str, Any], name: str) -> dict[str, Any]:
|
||||||
return unwrap(await client.post("/api/v1/workspaces", json={"name": name}, headers=user["headers"]))
|
return unwrap(
|
||||||
|
await client.post("/api/v1/workspaces", json={"name": name}, headers=user["headers"])
|
||||||
|
)
|
||||||
|
|||||||
+11
-16
@@ -22,26 +22,25 @@ async def test_login_is_idempotent_for_same_phone(client: AsyncClient) -> None:
|
|||||||
|
|
||||||
async def test_wrong_code_rejected(client: AsyncClient) -> None:
|
async def test_wrong_code_rejected(client: AsyncClient) -> None:
|
||||||
await client.post("/api/v1/auth/sms/send", json={"phone": "13800000003"})
|
await client.post("/api/v1/auth/sms/send", json={"phone": "13800000003"})
|
||||||
resp = await client.post(
|
resp = await client.post("/api/v1/auth/login", json={"phone": "13800000003", "code": "000000"})
|
||||||
"/api/v1/auth/login", json={"phone": "13800000003", "code": "000000"}
|
|
||||||
)
|
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert resp.json()["code"] == 2010
|
assert resp.json()["code"] == 2010
|
||||||
|
|
||||||
|
|
||||||
async def test_code_can_only_be_used_once(client: AsyncClient) -> None:
|
async def test_code_can_only_be_used_once(client: AsyncClient) -> None:
|
||||||
await client.post("/api/v1/auth/sms/send", json={"phone": "13800000004"})
|
await client.post("/api/v1/auth/sms/send", json={"phone": "13800000004"})
|
||||||
ok = await client.post(
|
ok = await client.post("/api/v1/auth/login", json={"phone": "13800000004", "code": DEV_CODE})
|
||||||
"/api/v1/auth/login", json={"phone": "13800000004", "code": DEV_CODE}
|
|
||||||
)
|
|
||||||
assert ok.status_code == 200
|
assert ok.status_code == 200
|
||||||
again = await client.post(
|
again = await client.post("/api/v1/auth/login", json={"phone": "13800000004", "code": DEV_CODE})
|
||||||
"/api/v1/auth/login", json={"phone": "13800000004", "code": DEV_CODE}
|
|
||||||
)
|
|
||||||
assert again.json()["code"] == 2010
|
assert again.json()["code"] == 2010
|
||||||
|
|
||||||
|
|
||||||
async def test_sms_throttled(client: AsyncClient) -> None:
|
async def test_sms_throttled(client: AsyncClient, monkeypatch) -> None:
|
||||||
|
# 全局测试默认关掉了节流(SMS_THROTTLE_SECONDS=0),这里显式打开
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(get_settings(), "sms_throttle_seconds", 60)
|
||||||
|
|
||||||
payload = {"phone": "13800000005"}
|
payload = {"phone": "13800000005"}
|
||||||
assert (await client.post("/api/v1/auth/sms/send", json=payload)).status_code == 200
|
assert (await client.post("/api/v1/auth/sms/send", json=payload)).status_code == 200
|
||||||
second = await client.post("/api/v1/auth/sms/send", json=payload)
|
second = await client.post("/api/v1/auth/sms/send", json=payload)
|
||||||
@@ -57,9 +56,7 @@ async def test_refresh_token_cannot_be_used_as_access_token(client: AsyncClient)
|
|||||||
|
|
||||||
async def test_access_token_cannot_be_used_to_refresh(client: AsyncClient) -> None:
|
async def test_access_token_cannot_be_used_to_refresh(client: AsyncClient) -> None:
|
||||||
user = await login(client)
|
user = await login(client)
|
||||||
resp = await client.post(
|
resp = await client.post("/api/v1/auth/refresh", json={"refresh_token": user["token"]})
|
||||||
"/api/v1/auth/refresh", json={"refresh_token": user["token"]}
|
|
||||||
)
|
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
assert resp.json()["code"] == 2003
|
assert resp.json()["code"] == 2003
|
||||||
|
|
||||||
@@ -67,9 +64,7 @@ async def test_access_token_cannot_be_used_to_refresh(client: AsyncClient) -> No
|
|||||||
async def test_refresh_returns_working_token_pair(client: AsyncClient) -> None:
|
async def test_refresh_returns_working_token_pair(client: AsyncClient) -> None:
|
||||||
user = await login(client)
|
user = await login(client)
|
||||||
data = unwrap(
|
data = unwrap(
|
||||||
await client.post(
|
await client.post("/api/v1/auth/refresh", json={"refresh_token": user["refresh_token"]})
|
||||||
"/api/v1/auth/refresh", json={"refresh_token": user["refresh_token"]}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
me = await client.get("/api/v1/auth/me", headers=auth(data["access_token"]))
|
me = await client.get("/api/v1/auth/me", headers=auth(data["access_token"]))
|
||||||
assert me.status_code == 200
|
assert me.status_code == 200
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import uuid
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.exc import ProgrammingError
|
||||||
|
|
||||||
from app.db import SessionFactory, bind_tenant, current_app_role
|
from app.db import SessionFactory, bind_tenant, current_app_role
|
||||||
from app.models import TENANT_TABLES
|
from app.models import TENANT_TABLES
|
||||||
@@ -37,9 +38,7 @@ async def test_cannot_read_other_workspace(client: AsyncClient) -> None:
|
|||||||
|
|
||||||
async def test_cannot_list_other_workspace_members(client: AsyncClient) -> None:
|
async def test_cannot_list_other_workspace_members(client: AsyncClient) -> None:
|
||||||
alice, _bob, _ws_a, ws_b = await _two_tenants(client)
|
alice, _bob, _ws_a, ws_b = await _two_tenants(client)
|
||||||
resp = await client.get(
|
resp = await client.get(f"/api/v1/workspaces/{ws_b['id']}/members", headers=alice["headers"])
|
||||||
f"/api/v1/workspaces/{ws_b['id']}/members", headers=alice["headers"]
|
|
||||||
)
|
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
@@ -66,15 +65,12 @@ async def test_rls_hides_rows_at_database_level(client: AsyncClient) -> None:
|
|||||||
ws_a_id = uuid.UUID(ws_a["id"])
|
ws_a_id = uuid.UUID(ws_a["id"])
|
||||||
ws_b_id = uuid.UUID(ws_b["id"])
|
ws_b_id = uuid.UUID(ws_b["id"])
|
||||||
|
|
||||||
async with SessionFactory() as session:
|
async with SessionFactory() as session, session.begin():
|
||||||
async with session.begin():
|
|
||||||
await bind_tenant(session, ws_a_id, None)
|
await bind_tenant(session, ws_a_id, None)
|
||||||
visible = (
|
visible = (await session.execute(text("SELECT id FROM workspace"))).scalars().all()
|
||||||
await session.execute(text("SELECT id FROM workspace"))
|
|
||||||
).scalars().all()
|
|
||||||
member_ids = (
|
member_ids = (
|
||||||
await session.execute(text("SELECT workspace_id FROM member"))
|
(await session.execute(text("SELECT workspace_id FROM member"))).scalars().all()
|
||||||
).scalars().all()
|
)
|
||||||
|
|
||||||
assert [str(v) for v in visible] == [ws_a["id"]]
|
assert [str(v) for v in visible] == [ws_a["id"]]
|
||||||
assert set(member_ids) == {ws_a_id}
|
assert set(member_ids) == {ws_a_id}
|
||||||
@@ -83,8 +79,7 @@ async def test_rls_hides_rows_at_database_level(client: AsyncClient) -> None:
|
|||||||
|
|
||||||
async def test_rls_without_context_returns_nothing(client: AsyncClient) -> None:
|
async def test_rls_without_context_returns_nothing(client: AsyncClient) -> None:
|
||||||
await _two_tenants(client)
|
await _two_tenants(client)
|
||||||
async with SessionFactory() as session:
|
async with SessionFactory() as session, session.begin():
|
||||||
async with session.begin():
|
|
||||||
count = (await session.execute(text("SELECT count(*) FROM workspace"))).scalar_one()
|
count = (await session.execute(text("SELECT count(*) FROM workspace"))).scalar_one()
|
||||||
assert count == 0, "未绑定租户上下文时不应看到任何行"
|
assert count == 0, "未绑定租户上下文时不应看到任何行"
|
||||||
|
|
||||||
@@ -100,14 +95,13 @@ async def test_rls_enabled_on_every_tenant_table(client: AsyncClient) -> None:
|
|||||||
{"names": list(TENANT_TABLES)},
|
{"names": list(TENANT_TABLES)},
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
status = {name: enabled for name, enabled in rows}
|
status = dict(rows)
|
||||||
assert set(status) == set(TENANT_TABLES), "有租户表没建出来"
|
assert set(status) == set(TENANT_TABLES), "有租户表没建出来"
|
||||||
assert all(status.values()), f"未启用 RLS 的表:{[k for k, v in status.items() if not v]}"
|
assert all(status.values()), f"未启用 RLS 的表:{[k for k, v in status.items() if not v]}"
|
||||||
|
|
||||||
|
|
||||||
async def test_app_role_cannot_bypass_rls(client: AsyncClient) -> None:
|
async def test_app_role_cannot_bypass_rls(client: AsyncClient) -> None:
|
||||||
async with SessionFactory() as session:
|
async with SessionFactory() as session, session.begin():
|
||||||
async with session.begin():
|
|
||||||
role = await current_app_role(session)
|
role = await current_app_role(session)
|
||||||
assert role["is_super"] is False, "应用角色绝不能是 superuser,否则 RLS 被跳过"
|
assert role["is_super"] is False, "应用角色绝不能是 superuser,否则 RLS 被跳过"
|
||||||
assert role["bypass_rls"] is False, "应用角色不能带 BYPASSRLS"
|
assert role["bypass_rls"] is False, "应用角色不能带 BYPASSRLS"
|
||||||
@@ -116,7 +110,6 @@ async def test_app_role_cannot_bypass_rls(client: AsyncClient) -> None:
|
|||||||
|
|
||||||
async def test_app_role_has_no_ddl(client: AsyncClient) -> None:
|
async def test_app_role_has_no_ddl(client: AsyncClient) -> None:
|
||||||
"""应用角色不能建表——否则一次 SQL 注入就能改结构。"""
|
"""应用角色不能建表——否则一次 SQL 注入就能改结构。"""
|
||||||
async with SessionFactory() as session:
|
async with SessionFactory() as session, session.begin():
|
||||||
async with session.begin():
|
with pytest.raises(ProgrammingError): # 权限不足,DDL 必须失败
|
||||||
with pytest.raises(Exception):
|
|
||||||
await session.execute(text("CREATE TABLE should_not_exist (id int)"))
|
await session.execute(text("CREATE TABLE should_not_exist (id int)"))
|
||||||
|
|||||||
Reference in New Issue
Block a user