update: auth

update auth api
This commit is contained in:
tao.chen
2026-08-07 14:41:20 +08:00
parent 984894d797
commit 2ee36b48ec
4 changed files with 88 additions and 68 deletions
+16 -16
View File
@@ -163,17 +163,17 @@ async def login(
if default_workspace_id is None:
default_workspace_id = workspace.workspace_id
# Pick a default role_code for the user payload: prefer admin if
# the user has it in any workspace, otherwise use the first one
# returned. This is only for UI greeting; access control checks
# run on a per-request basis via the chosen workspace_id.
# Workspace role is always inherited from the user's platform role
# (``Users.platform_role_id``); the iteration over rows above was
# a legacy way to find the "highest" workspace role and is no
# longer correct now that admin/developer are platform-only.
user_role_code: str | None = None
for workspace, role, _ in rows:
if role.role_code == "admin":
user_role_code = "admin"
break
if user_role_code is None:
user_role_code = rows[0][1].role_code
if user.platform_role_id is not None:
platform_role_row = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
if platform_role_row is not None:
user_role_code = platform_role_row.role_code
token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS)
_set_session_cookie(request, response, token)
@@ -256,12 +256,12 @@ async def me(
workspaces = [_workspace_payload(ws, role) for ws, role, _ in rows]
default_workspace_id = workspaces[0]["workspace_id"] if workspaces else None
user_role_code: str | None = None
for _ws, role, _ in rows:
if role.role_code == "admin":
user_role_code = "admin"
break
if user_role_code is None and rows:
user_role_code = rows[0][1].role_code
if user.platform_role_id is not None:
platform_role_row = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
if platform_role_row is not None:
user_role_code = platform_role_row.role_code
is_system_admin = await resolve_is_system_admin(session, user)
permissions = await load_user_permissions(session, user)
+49 -38
View File
@@ -109,16 +109,22 @@ class WorkspaceUpdate(BaseModel):
class MemberCreate(BaseModel):
"""Add a user to a workspace. Role is inherited from the user's
platform role (Users.platform_role_id) — not set here."""
model_config = ConfigDict(extra="forbid")
user_id: str = Field(min_length=26, max_length=26)
role_code: Literal["admin", "developer"]
class MemberUpdate(BaseModel):
"""Update a workspace membership's status. Role cannot be changed
via this endpoint — workspace role is always inherited from the
user's platform role. To change a member's role, PATCH
/platform/employees/{user_id} instead."""
model_config = ConfigDict(extra="forbid")
role_code: Literal["admin", "developer"] | None = None
member_status: Literal["active", "disabled", "locked"] | None = None
@@ -129,6 +135,7 @@ class PlatformEmployeeCreate(BaseModel):
display_name: str = Field(min_length=1, max_length=100)
email: str | None = Field(default=None, max_length=255)
password: str = Field(min_length=8, max_length=72)
role_code: Literal["admin", "developer"] | None = None
class PlatformEmployeeUpdate(BaseModel):
@@ -387,6 +394,10 @@ async def create_platform_employee(
if duplicate is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "用户名或邮箱已存在")
new_role: Roles | None = None
if payload.role_code is not None:
new_role = await _load_role_by_code(session, payload.role_code)
user = Users(
user_id=new_ulid(),
username=username,
@@ -394,12 +405,15 @@ async def create_platform_employee(
email=payload.email.strip() if payload.email else None,
password_hash=hash_password(payload.password),
status="active",
platform_role_id=None,
platform_role_id=new_role.role_id if new_role is not None else None,
)
session.add(user)
await session.flush()
await session.refresh(user)
return _envelope(context.request_id, platform_employee_payload(user, None))
return _envelope(
context.request_id,
platform_employee_payload(user, new_role),
)
@router.patch("/employees/{user_id}")
@@ -724,7 +738,12 @@ async def add_member(
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Add a user to a workspace. The new row starts with member_status='active'."""
"""Add a user to a workspace. The new row starts with member_status='active'.
The role is inherited from the target user's ``platform_role_id``;
the request body does NOT take a ``role_code``. To change a member's
role, PATCH ``/api/v1/platform/employees/{user_id}`` instead.
"""
await _load_workspace(session, workspace_id)
user = await session.get(Users, payload.user_id)
if user is None or user.is_deleted != 0:
@@ -734,7 +753,20 @@ async def add_member(
status.HTTP_409_CONFLICT,
f"用户状态为 {user.status},无法加入 workspace",
)
role = await _load_role_by_code(session, payload.role_code)
if user.platform_role_id is None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"目标用户尚未分配平台角色,无法加入 workspace;"
"请先 PATCH /api/v1/platform/employees/{user_id} 设置 role_code",
)
role = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
if role is None:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"用户的平台角色行不存在",
)
duplicate = await session.scalar(
select(WorkspaceMembers.user_id).where(
WorkspaceMembers.workspace_id == workspace_id,
@@ -747,12 +779,6 @@ async def add_member(
status.HTTP_409_CONFLICT,
"用户已是该 workspace 成员",
)
# 不变量: 给用户授予 workspace admin 时同步设置 platform_role_id,
# 否则前端 role_code === "admin" 与后端 is_system_admin 会给出
# 不同的结论。降级路径不在此处处理(用户可能在其他 workspace
# 仍是 admin), 升级路径必须在此处理。
if role.role_code == "admin" and user.platform_role_id is None:
user.platform_role_id = role.role_id
membership = WorkspaceMembers(
workspace_id=workspace_id,
user_id=payload.user_id,
@@ -773,7 +799,16 @@ async def update_member(
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Update a member's role and/or status. Last-admin guard applies."""
"""Update a workspace membership's status. Role is not editable here.
Workspace role is always inherited from the user's platform role
(``Users.platform_role_id``). To change role, PATCH
``/api/v1/platform/employees/{user_id}`` instead.
Last-admin guard still applies to ``member_status`` changes: setting
the only active admin to ``disabled``/``locked`` would leave the
workspace without admin coverage.
"""
await _load_workspace(session, workspace_id)
row = (
await session.execute(
@@ -794,30 +829,6 @@ async def update_member(
raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
user, role, membership = row
next_role = role
if payload.role_code is not None and payload.role_code != role.role_code:
if (
role.role_code == "admin"
and payload.role_code != "admin"
and membership.member_status == "active"
):
remaining = await _count_active_admins(
session, workspace_id, exclude_user_id=user_id,
)
if remaining == 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
"workspace 必须保留至少一个 admin",
)
next_role = await _load_role_by_code(session, payload.role_code)
membership.role_id = next_role.role_id
# 不变量: 升级到 workspace admin 时强制覆盖 platform_role_id。
# (用户可能之前是 developer, platform_role_id 指向 developer role,
# 现在变成 admin 必须提升到 admin role。) 降级路径不动 (用户
# 可能在其他 workspace 仍是 admin, 端点看不到全局)。
if next_role.role_code == "admin":
user.platform_role_id = next_role.role_id
if payload.member_status is not None and payload.member_status != membership.member_status:
if (
role.role_code == "admin"
@@ -835,7 +846,7 @@ async def update_member(
await session.flush()
await session.refresh(membership)
return _envelope(context.request_id, member_payload(user, next_role, membership))
return _envelope(context.request_id, member_payload(user, role, membership))
@router.delete("/workspaces/{workspace_id}/members/{user_id}")