feat: streamline platform and secure data access
- move the relational data center to MySQL and a standalone workbench\n- add Interface Center API credentials, policies, logs, and DBeaver SSH guidance\n- harden authentication and deployment while retiring unused management surfaces
This commit is contained in:
1 parent
15368f2779
commit
3dd5731751
137 files changed
+8571
-14151
No files matched your search
@@ -0,0 +1,461 @@
|
||||
"""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,
|
||||
list_api_call_logs,
|
||||
list_api_clients,
|
||||
list_api_credentials,
|
||||
list_api_policies,
|
||||
resolve_api_policy,
|
||||
revoke_api_credential,
|
||||
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("/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,
|
||||
)
|
||||
Reference in new issue
Block a user