feat: harden data access and simplify user management
This commit is contained in:
1 parent
3dd5731751
commit
adb780bc82
51 files changed
+3239
-2121
No files matched your search
+2
-7
@@ -33,15 +33,10 @@ api_router.include_router(ocr_router, tags=["ocr"])
|
||||
api_router.include_router(data_platform_router, tags=["data-platform"])
|
||||
api_router.include_router(interface_center_router, tags=["interface-center"])
|
||||
|
||||
# Agent call logs
|
||||
from app.api.agent_call_logs import router as agent_call_logs_router # noqa: E402
|
||||
|
||||
api_router.include_router(agent_call_logs_router, tags=["agent-call-logs"])
|
||||
|
||||
# RBAC & accounts (P1)
|
||||
# User accounts
|
||||
from app.api.rbac import router as rbac_router # noqa: E402
|
||||
|
||||
api_router.include_router(rbac_router, tags=["rbac"])
|
||||
api_router.include_router(rbac_router, tags=["users"])
|
||||
|
||||
# Super Agent (P1)
|
||||
from app.api.super_agent import router as super_agent_router # noqa: E402
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Agent call log listing endpoint."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.auth import CurrentUser
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/agent-call-logs")
|
||||
async def list_logs(
|
||||
_user: CurrentUser = None,
|
||||
limit: int = Query(default=100, le=500),
|
||||
):
|
||||
s = settings.db_schema
|
||||
async with get_conn() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
f"SELECT * FROM {s}.agent_call_logs ORDER BY created_at DESC LIMIT %s",
|
||||
(limit,),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
+11
-11
@@ -5,7 +5,7 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, Query, Request, Response, UploadFile
|
||||
|
||||
from app.auth import AdminUser, CurrentUser, DataOperatorUser
|
||||
from app.auth import AdminUser, CurrentUser, DataViewerUser
|
||||
from app.data_platform.mysql_service import (
|
||||
IDENTIFIER_PATTERN,
|
||||
MAX_CSV_BYTES,
|
||||
@@ -37,7 +37,7 @@ router = APIRouter(prefix="/data-platform")
|
||||
|
||||
|
||||
@router.get("/databases")
|
||||
async def databases(_user: DataOperatorUser):
|
||||
async def databases(_user: DataViewerUser):
|
||||
return await list_databases()
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ async def remove_database(project_id: str, body: dict[str, Any], user: CurrentUs
|
||||
|
||||
|
||||
@router.get("/databases/{project_id}/tables")
|
||||
async def tables(project_id: str, _user: DataOperatorUser):
|
||||
async def tables(project_id: str, _user: DataViewerUser):
|
||||
return await list_tables(project_id)
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ async def security_audit_logs(
|
||||
|
||||
|
||||
@router.get("/databases/{project_id}/tables/{table_code}/inspection")
|
||||
async def table_inspection(project_id: str, table_code: str, _user: DataOperatorUser):
|
||||
async def table_inspection(project_id: str, table_code: str, _user: DataViewerUser):
|
||||
return await inspect_table(project_id, table_code)
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ async def remove_table(
|
||||
async def records(
|
||||
project_id: str,
|
||||
table_code: str,
|
||||
_user: DataOperatorUser,
|
||||
_user: DataViewerUser,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=5000),
|
||||
search: str | None = None,
|
||||
@@ -293,7 +293,7 @@ async def _read_csv_upload(file: UploadFile) -> tuple[bytes, str]:
|
||||
async def preview_records_import(
|
||||
project_id: str,
|
||||
table_code: str,
|
||||
user: DataOperatorUser,
|
||||
user: AdminUser,
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
content, file_name = await _read_csv_upload(file)
|
||||
@@ -304,7 +304,7 @@ async def preview_records_import(
|
||||
async def import_records(
|
||||
project_id: str,
|
||||
table_code: str,
|
||||
user: DataOperatorUser,
|
||||
user: AdminUser,
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
content, file_name = await _read_csv_upload(file)
|
||||
@@ -321,7 +321,7 @@ async def import_records(
|
||||
async def export_records(
|
||||
project_id: str,
|
||||
table_code: str,
|
||||
_user: DataOperatorUser,
|
||||
_user: DataViewerUser,
|
||||
search: str | None = None,
|
||||
):
|
||||
content, filename, total = await export_csv_records(
|
||||
@@ -344,7 +344,7 @@ async def add_record(
|
||||
project_id: str,
|
||||
table_code: str,
|
||||
body: dict[str, Any],
|
||||
user: DataOperatorUser,
|
||||
user: AdminUser,
|
||||
):
|
||||
return await create_record(project_id, table_code, body, user["username"])
|
||||
|
||||
@@ -355,7 +355,7 @@ async def edit_record(
|
||||
table_code: str,
|
||||
record_id: str,
|
||||
body: dict[str, Any],
|
||||
user: DataOperatorUser,
|
||||
user: AdminUser,
|
||||
):
|
||||
return await update_record(project_id, table_code, record_id, body, user["username"])
|
||||
|
||||
@@ -365,6 +365,6 @@ async def remove_record(
|
||||
project_id: str,
|
||||
table_code: str,
|
||||
record_id: str,
|
||||
user: DataOperatorUser,
|
||||
user: AdminUser,
|
||||
):
|
||||
return await delete_record(project_id, table_code, record_id, user["username"])
|
||||
@@ -19,12 +19,15 @@ from app.data_platform.interface_service import (
|
||||
interface_catalog,
|
||||
interface_summary,
|
||||
issue_api_credential,
|
||||
issue_dbeaver_access,
|
||||
list_api_call_logs,
|
||||
list_api_clients,
|
||||
list_api_credentials,
|
||||
list_api_policies,
|
||||
list_dbeaver_access_grants,
|
||||
resolve_api_policy,
|
||||
revoke_api_credential,
|
||||
revoke_dbeaver_access,
|
||||
update_api_client,
|
||||
update_api_policy,
|
||||
write_api_call_log,
|
||||
@@ -75,6 +78,30 @@ async def catalog(user: CurrentUser):
|
||||
return await interface_catalog()
|
||||
|
||||
|
||||
@router.get("/dbeaver-access")
|
||||
async def dbeaver_access_grants(user: CurrentUser):
|
||||
_require_admin(user)
|
||||
return await list_dbeaver_access_grants()
|
||||
|
||||
|
||||
@router.post("/dbeaver-access")
|
||||
async def add_dbeaver_access(body: dict[str, Any], user: CurrentUser):
|
||||
_require_admin(user)
|
||||
try:
|
||||
return await issue_dbeaver_access(body, user["username"])
|
||||
except ValueError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/dbeaver-access/{grant_id}/revoke")
|
||||
async def revoke_dbeaver_access_grant(grant_id: str, user: CurrentUser):
|
||||
_require_admin(user)
|
||||
try:
|
||||
return await revoke_dbeaver_access(grant_id, user["username"])
|
||||
except ValueError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.get("/clients")
|
||||
async def clients(user: CurrentUser):
|
||||
_require_admin(user)
|
||||
|
||||
+44
-108
@@ -1,135 +1,71 @@
|
||||
"""RBAC & Accounts (P1) — roles, capability matrix, users CRUD."""
|
||||
"""Simple user administration with two data-access levels."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.auth import AdminUser
|
||||
from app.contracts import (
|
||||
CapabilityCreate,
|
||||
RoleCapCell,
|
||||
RoleCreate,
|
||||
RoleUpdate,
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
)
|
||||
from app.db import (
|
||||
create_capability,
|
||||
create_role,
|
||||
create_user,
|
||||
delete_capability,
|
||||
delete_role,
|
||||
delete_user,
|
||||
get_permission_matrix,
|
||||
list_capabilities,
|
||||
list_roles,
|
||||
list_users,
|
||||
set_role_cap,
|
||||
update_role,
|
||||
update_user,
|
||||
)
|
||||
from app.contracts import DataPermission, UserCreate, UserUpdate
|
||||
from app.db import create_user, get_user_by_id, list_users, update_user
|
||||
from app.security import hash_password
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Roles ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/roles")
|
||||
async def _list_roles(_user: AdminUser):
|
||||
return await list_roles()
|
||||
_PERMISSION_ROLES: dict[DataPermission, str] = {
|
||||
"data_manage": "admin",
|
||||
"data_view": "operator",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/roles")
|
||||
async def _create_role(body: RoleCreate, _user: AdminUser):
|
||||
try:
|
||||
return await create_role(body.model_dump())
|
||||
except Exception as e: # unique violation etc.
|
||||
raise HTTPException(400, f"创建角色失败:{str(e)[:200]}")
|
||||
def _permission_from_roles(roles: list[str] | None) -> DataPermission:
|
||||
return "data_manage" if "admin" in set(roles or []) else "data_view"
|
||||
|
||||
|
||||
@router.patch("/roles/{role_key}")
|
||||
async def _update_role(role_key: str, body: RoleUpdate, _user: AdminUser):
|
||||
row = await update_role(role_key, body.model_dump(exclude_none=True))
|
||||
if not row:
|
||||
raise HTTPException(404, "角色不存在")
|
||||
return row
|
||||
def _public_user(row: dict[str, Any]) -> dict[str, Any]:
|
||||
result = dict(row)
|
||||
result["permission"] = _permission_from_roles(list(result.pop("roles", []) or []))
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/roles/{role_key}")
|
||||
async def _delete_role(role_key: str, _user: AdminUser):
|
||||
try:
|
||||
ok = await delete_role(role_key)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
if not ok:
|
||||
raise HTTPException(404, "角色不存在")
|
||||
return {"deleted": role_key}
|
||||
|
||||
|
||||
# ── Capabilities ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/capabilities")
|
||||
async def _list_caps(_user: AdminUser):
|
||||
return await list_capabilities()
|
||||
|
||||
|
||||
@router.post("/capabilities")
|
||||
async def _create_cap(body: CapabilityCreate, _user: AdminUser):
|
||||
try:
|
||||
return await create_capability(body.model_dump())
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"创建能力项失败:{str(e)[:200]}")
|
||||
|
||||
|
||||
@router.delete("/capabilities/{cap_key}")
|
||||
async def _delete_cap(cap_key: str, _user: AdminUser):
|
||||
await delete_capability(cap_key)
|
||||
return {"deleted": cap_key}
|
||||
|
||||
|
||||
# ── Permission matrix ────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/permission-matrix")
|
||||
async def _matrix(_user: AdminUser):
|
||||
return await get_permission_matrix()
|
||||
|
||||
|
||||
@router.put("/permission-matrix")
|
||||
async def _set_cell(body: RoleCapCell, _user: AdminUser):
|
||||
await set_role_cap(body.role_key, body.cap_key, body.value)
|
||||
return {"ok": True, "role_key": body.role_key, "cap_key": body.cap_key, "value": body.value}
|
||||
|
||||
|
||||
# ── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/users")
|
||||
async def _list_users(_user: AdminUser):
|
||||
return await list_users()
|
||||
return [_public_user(row) for row in await list_users()]
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def _create_user(body: UserCreate, _user: AdminUser):
|
||||
data = body.model_dump()
|
||||
roles = data.pop("roles", [])
|
||||
pw = data.pop("password")
|
||||
data["hashed_password"] = hash_password(pw)
|
||||
permission = data.pop("permission")
|
||||
password = data.pop("password")
|
||||
data["username"] = data["username"].strip()
|
||||
data["hashed_password"] = hash_password(password)
|
||||
try:
|
||||
return await create_user(data, roles)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"创建用户失败:{str(e)[:200]}")
|
||||
row = await create_user(data, [_PERMISSION_ROLES[permission]])
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"创建用户失败:{str(exc)[:200]}") from exc
|
||||
return _public_user(row)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}")
|
||||
async def _update_user(user_id: int, body: UserUpdate, _user: AdminUser):
|
||||
data = body.model_dump(exclude_none=True)
|
||||
roles = data.pop("roles", None)
|
||||
if "password" in data:
|
||||
data["hashed_password"] = hash_password(data.pop("password"))
|
||||
async def _update_user(user_id: int, body: UserUpdate, current_user: AdminUser):
|
||||
target = await get_user_by_id(user_id)
|
||||
if not target:
|
||||
raise HTTPException(404, "用户不存在")
|
||||
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
permission = data.pop("permission", None)
|
||||
is_current_user = target["username"] == current_user["username"]
|
||||
if is_current_user and data.get("status") == "disabled":
|
||||
raise HTTPException(400, "不能停用当前登录账号")
|
||||
if is_current_user and permission == "data_view":
|
||||
raise HTTPException(400, "不能移除当前账号的数据管理权限")
|
||||
|
||||
roles = [_PERMISSION_ROLES[permission]] if permission else None
|
||||
password = data.pop("password", None)
|
||||
if password:
|
||||
data["hashed_password"] = hash_password(password)
|
||||
row = await update_user(user_id, data, roles)
|
||||
if not row:
|
||||
raise HTTPException(404, "用户不存在")
|
||||
return row
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def _delete_user(user_id: int, _user: AdminUser):
|
||||
await delete_user(user_id)
|
||||
return {"deleted": user_id}
|
||||
return _public_user(row)
|
||||
Reference in new issue
Block a user