489 lines
15 KiB
Python
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,
|
|
)
|