Files
Cloud-Tour-to-Libo/app/api/interface_center.py
T

489 lines
15 KiB
Python

"""Administrative Interface Center and restricted public Data APIs."""
from __future__ import annotations
import time
from typing import Any, Awaitable, Callable
import uuid
from fastapi import APIRouter, Header, HTTPException, Query, Request
from app.auth import CurrentUser
from app.config import settings
from app.data_platform.interface_service import (
authenticate_api_key,
client_catalog,
create_api_client,
create_api_policy,
delete_api_client,
delete_api_policy,
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,
)
from app.security_baseline import security_readiness
from app.data_platform.mysql_service import (
create_record,
delete_record,
inspect_table,
list_records,
update_record,
)
from app.rate_limit import FixedWindowLimiter
router = APIRouter(prefix="/interface-center")
public_router = APIRouter(prefix="/data")
_public_api_limiter = FixedWindowLimiter(
limit=settings.interface_api_rate_limit_per_minute,
window_seconds=60,
)
def _require_admin(user: dict[str, Any]) -> None:
if "admin" not in user.get("roles", []):
raise HTTPException(403, "只有系统管理员可以管理接口中心")
def _bad_request(exc: ValueError) -> HTTPException:
return HTTPException(400, str(exc))
@router.get("/summary")
async def summary(user: CurrentUser):
_require_admin(user)
return await interface_summary()
@router.get("/security-readiness")
async def readiness(user: CurrentUser):
_require_admin(user)
return security_readiness()
@router.get("/catalog")
async def catalog(user: CurrentUser):
_require_admin(user)
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)
return await list_api_clients()
@router.post("/clients")
async def add_client(body: dict[str, Any], user: CurrentUser):
_require_admin(user)
try:
return await create_api_client(body, user["username"])
except ValueError as exc:
raise _bad_request(exc) from exc
@router.patch("/clients/{client_id}")
async def edit_client(client_id: str, body: dict[str, Any], user: CurrentUser):
_require_admin(user)
try:
return await update_api_client(client_id, body)
except ValueError as exc:
raise _bad_request(exc) from exc
@router.delete("/clients/{client_id}")
async def remove_client(client_id: str, user: CurrentUser):
_require_admin(user)
try:
return await delete_api_client(client_id)
except ValueError as exc:
raise _bad_request(exc) from exc
@router.get("/credentials")
async def credentials(user: CurrentUser, client_id: str | None = None):
_require_admin(user)
return await list_api_credentials(client_id)
@router.post("/clients/{client_id}/credentials")
async def add_credential(client_id: str, body: dict[str, Any], user: CurrentUser):
_require_admin(user)
try:
return await issue_api_credential(client_id, body)
except ValueError as exc:
raise _bad_request(exc) from exc
@router.post("/credentials/{credential_id}/revoke")
async def revoke_credential(credential_id: str, user: CurrentUser):
_require_admin(user)
try:
return await revoke_api_credential(credential_id)
except ValueError as exc:
raise _bad_request(exc) from exc
@router.get("/policies")
async def policies(user: CurrentUser, client_id: str | None = None):
_require_admin(user)
return await list_api_policies(client_id)
@router.post("/policies")
async def add_policy(body: dict[str, Any], user: CurrentUser):
_require_admin(user)
try:
return await create_api_policy(body)
except ValueError as exc:
raise _bad_request(exc) from exc
@router.patch("/policies/{policy_id}")
async def edit_policy(policy_id: str, body: dict[str, Any], user: CurrentUser):
_require_admin(user)
try:
return await update_api_policy(policy_id, body)
except ValueError as exc:
raise _bad_request(exc) from exc
@router.delete("/policies/{policy_id}")
async def remove_policy(policy_id: str, user: CurrentUser):
_require_admin(user)
try:
return await delete_api_policy(policy_id)
except ValueError as exc:
raise _bad_request(exc) from exc
@router.get("/logs")
async def logs(user: CurrentUser, limit: int = Query(default=200, ge=1, le=1000)):
_require_admin(user)
return await list_api_call_logs(limit)
def _extract_api_key(authorization: str | None, x_api_key: str | None) -> str:
if authorization and authorization.lower().startswith("bearer "):
return authorization[7:].strip()
return str(x_api_key or "").strip()
async def _log_safely(**kwargs: Any) -> None:
try:
await write_api_call_log(**kwargs)
except Exception:
# Logging must never turn a successful, authorized data operation into
# an application error. Infrastructure monitoring handles log failures.
return
async def _public_operation(
*,
request: Request,
authorization: str | None,
x_api_key: str | None,
database_id: str | None,
table_code: str | None,
action: str,
operation: Callable[[dict[str, Any], dict[str, Any] | None], Awaitable[Any]],
resolve_policy: bool = True,
) -> Any:
started = time.perf_counter()
request_id = str(uuid.uuid4())
identity: dict[str, Any] | None = None
status_code = 200
error_message: str | None = None
try:
identity = await authenticate_api_key(_extract_api_key(authorization, x_api_key))
retry_after = await _public_api_limiter.consume(
f"credential:{identity['credential_id']}"
)
if retry_after:
raise HTTPException(
429,
"接口调用过于频繁,请稍后重试",
headers={"Retry-After": str(retry_after)},
)
policy = None
if resolve_policy:
if not database_id or not table_code:
raise HTTPException(400, "缺少数据库或数据表范围")
policy = await resolve_api_policy(
str(identity["client_id"]),
database_id,
table_code,
action,
)
result = await operation(identity, policy)
if isinstance(result, dict):
result.setdefault("request_id", request_id)
return result
except HTTPException as exc:
status_code = exc.status_code
error_message = str(exc.detail)
raise
except Exception as exc:
status_code = 500
error_message = str(exc)
raise
finally:
await _log_safely(
request_id=request_id,
identity=identity,
method=request.method,
path=request.url.path,
database_id=database_id,
table_code=table_code,
action=action,
status_code=status_code,
duration_ms=round((time.perf_counter() - started) * 1000, 2),
source_ip=request.client.host if request.client else None,
error_message=error_message,
)
@public_router.get("/catalog")
async def public_catalog(
request: Request,
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
):
async def operation(identity: dict[str, Any], _policy: dict[str, Any] | None):
return {
"client": {"id": identity["client_id"], "name": identity["client_name"]},
"databases": await client_catalog(str(identity["client_id"])),
}
return await _public_operation(
request=request,
authorization=authorization,
x_api_key=x_api_key,
database_id=None,
table_code=None,
action="metadata",
operation=operation,
resolve_policy=False,
)
@public_router.get("/databases/{database_id}/tables/{table_code}/schema")
async def public_table_schema(
database_id: str,
table_code: str,
request: Request,
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
):
async def operation(_identity: dict[str, Any], policy: dict[str, Any] | None):
inspection = await inspect_table(database_id, table_code)
readable = set((policy or {}).get("readable_fields") or [])
if "*" not in readable:
visible = {"id", "created_at", "updated_at", *readable}
inspection["columns"] = [
column
for column in inspection["columns"]
if column["name"] in visible
]
inspection["constraints"] = [
constraint
for constraint in inspection["constraints"]
if set(constraint.get("columns") or []).issubset(visible)
]
# Index definitions and relationships can contain restricted field
# names. Only expose them to whole-table metadata grants.
inspection["indexes"] = []
inspection["relationships"] = []
inspection["table"]["column_count"] = len(inspection["columns"])
inspection["table"]["index_count"] = 0
inspection["table"]["constraint_count"] = len(inspection["constraints"])
return inspection
return await _public_operation(
request=request,
authorization=authorization,
x_api_key=x_api_key,
database_id=database_id,
table_code=table_code,
action="metadata",
operation=operation,
)
@public_router.get("/databases/{database_id}/tables/{table_code}/records")
async def public_records(
database_id: str,
table_code: str,
request: Request,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=500),
search: str | None = None,
sort_field: str | None = None,
sort_order: str = Query(default="desc", pattern="^(asc|desc)$"),
fields: str | None = None,
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
):
async def operation(_identity: dict[str, Any], policy: dict[str, Any] | None):
readable = set((policy or {}).get("readable_fields") or [])
if fields:
requested = {value.strip() for value in fields.split(",") if value.strip()}
if "*" not in readable and not requested.issubset(readable):
raise HTTPException(403, "请求包含未授权读取的字段")
readable = requested
return await list_records(
database_id,
table_code,
page=page,
page_size=page_size,
search=search,
sort_field=sort_field,
sort_order=sort_order,
allowed_fields=readable,
row_filter=(policy or {}).get("row_filter") or {},
)
return await _public_operation(
request=request,
authorization=authorization,
x_api_key=x_api_key,
database_id=database_id,
table_code=table_code,
action="read",
operation=operation,
)
def _check_writable_fields(policy: dict[str, Any] | None, body: dict[str, Any]) -> None:
writable = set((policy or {}).get("writable_fields") or [])
if "*" not in writable:
unknown = sorted(set(body) - writable)
if unknown:
raise HTTPException(403, f"包含未授权写入的字段:{', '.join(unknown)}")
@public_router.post("/databases/{database_id}/tables/{table_code}/records")
async def public_add_record(
database_id: str,
table_code: str,
body: dict[str, Any],
request: Request,
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
):
async def operation(identity: dict[str, Any], policy: dict[str, Any] | None):
_check_writable_fields(policy, body)
return await create_record(
database_id,
table_code,
body,
f"api:{identity['client_id']}",
row_filter=(policy or {}).get("row_filter") or {},
)
return await _public_operation(
request=request,
authorization=authorization,
x_api_key=x_api_key,
database_id=database_id,
table_code=table_code,
action="create",
operation=operation,
)
@public_router.patch("/databases/{database_id}/tables/{table_code}/records/{record_id}")
async def public_edit_record(
database_id: str,
table_code: str,
record_id: str,
body: dict[str, Any],
request: Request,
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
):
async def operation(identity: dict[str, Any], policy: dict[str, Any] | None):
_check_writable_fields(policy, body)
return await update_record(
database_id,
table_code,
record_id,
body,
f"api:{identity['client_id']}",
row_filter=(policy or {}).get("row_filter") or {},
)
return await _public_operation(
request=request,
authorization=authorization,
x_api_key=x_api_key,
database_id=database_id,
table_code=table_code,
action="update",
operation=operation,
)
@public_router.delete("/databases/{database_id}/tables/{table_code}/records/{record_id}")
async def public_remove_record(
database_id: str,
table_code: str,
record_id: str,
request: Request,
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
):
async def operation(identity: dict[str, Any], policy: dict[str, Any] | None):
return await delete_record(
database_id,
table_code,
record_id,
f"api:{identity['client_id']}",
row_filter=(policy or {}).get("row_filter") or {},
)
return await _public_operation(
request=request,
authorization=authorization,
x_api_key=x_api_key,
database_id=database_id,
table_code=table_code,
action="delete",
operation=operation,
)