From 393ba71272a18785cbf7bc8976a50bed24d8ab8d Mon Sep 17 00:00:00 2001 From: seniorswe Date: Fri, 26 Sep 2025 16:49:47 -0400 Subject: [PATCH] tool tips and credit page updates --- backend-services/doorman.py | 2 + backend-services/routes/config_routes.py | 304 ++++++++++++++++++ backend-services/routes/security_routes.py | 2 +- web-client/public/docs/using-fields.html | 108 +++++++ .../app/apis/[apiId]/endpoints/add/page.tsx | 15 +- web-client/src/app/apis/[apiId]/page.tsx | 18 +- web-client/src/app/apis/add/page.tsx | 52 ++- web-client/src/app/auth-admin/page.tsx | 2 + .../src/app/credit-defs/[group]/page.tsx | 11 +- web-client/src/app/credit-defs/add/page.tsx | 11 +- .../src/app/credits/[username]/page.tsx | 252 +++++++++++---- .../src/app/groups/[groupName]/page.tsx | 9 +- web-client/src/app/groups/add/page.tsx | 5 + web-client/src/app/import-export/page.tsx | 163 ++++++++++ web-client/src/app/roles/[roleName]/page.tsx | 7 +- web-client/src/app/roles/add/page.tsx | 2 + .../src/app/routings/[clientKey]/page.tsx | 9 +- web-client/src/app/routings/add/page.tsx | 4 + web-client/src/app/security/page.tsx | 35 +- web-client/src/app/settings/page.tsx | 3 + web-client/src/app/users/[username]/page.tsx | 17 +- web-client/src/app/users/add/page.tsx | 33 +- web-client/src/components/FormHelp.tsx | 24 ++ web-client/src/components/InfoTooltip.tsx | 36 +++ 24 files changed, 1008 insertions(+), 116 deletions(-) create mode 100644 backend-services/routes/config_routes.py create mode 100644 web-client/public/docs/using-fields.html create mode 100644 web-client/src/app/import-export/page.tsx create mode 100644 web-client/src/components/FormHelp.tsx create mode 100644 web-client/src/components/InfoTooltip.tsx diff --git a/backend-services/doorman.py b/backend-services/doorman.py index 55d58a1..3ecd48c 100755 --- a/backend-services/doorman.py +++ b/backend-services/doorman.py @@ -40,6 +40,7 @@ from routes.security_routes import security_router from routes.credit_routes import credit_router from routes.demo_routes import demo_router from routes.monitor_routes import monitor_router +from routes.config_routes import config_router from utils.security_settings_util import load_settings, start_auto_save_task, stop_auto_save_task, get_cached_settings from utils.memory_dump_util import dump_memory_to_file, restore_memory_from_file, find_latest_dump_path from utils.metrics_util import metrics_store @@ -452,6 +453,7 @@ doorman.include_router(monitor_router, prefix="/platform", tags=["Monitor"]) # Expose token management under both legacy and new prefixes doorman.include_router(credit_router, prefix="/platform/credit", tags=["Credit"]) doorman.include_router(demo_router, prefix="/platform/demo", tags=["Demo"]) +doorman.include_router(config_router, prefix="/platform", tags=["Config"]) def start(): if os.path.exists(PID_FILE): diff --git a/backend-services/routes/config_routes.py b/backend-services/routes/config_routes.py new file mode 100644 index 0000000..1cb09ac --- /dev/null +++ b/backend-services/routes/config_routes.py @@ -0,0 +1,304 @@ +""" +Routes to export and import platform configuration (APIs, Endpoints, Roles, Groups, Routings). +""" + +from fastapi import APIRouter, Request +from typing import Any, Dict, List, Optional +import uuid +import time +import logging +import copy + +from models.response_model import ResponseModel +from utils.response_util import process_response +from utils.auth_util import auth_required +from utils.role_util import platform_role_required_bool +from utils.doorman_cache_util import doorman_cache +from utils.database import ( + api_collection, + endpoint_collection, + group_collection, + role_collection, + routing_collection, +) + +config_router = APIRouter() +logger = logging.getLogger("doorman.gateway") + + +def _strip_id(doc: Dict[str, Any]) -> Dict[str, Any]: + d = dict(doc) + d.pop("_id", None) + return d + + +def _export_all() -> Dict[str, Any]: + apis = [_strip_id(a) for a in api_collection.find().to_list(length=None)] + endpoints = [_strip_id(e) for e in endpoint_collection.find().to_list(length=None)] + roles = [_strip_id(r) for r in role_collection.find().to_list(length=None)] + groups = [_strip_id(g) for g in group_collection.find().to_list(length=None)] + routings = [_strip_id(r) for r in routing_collection.find().to_list(length=None)] + return { + "apis": apis, + "endpoints": endpoints, + "roles": roles, + "groups": groups, + "routings": routings, + } + + +@config_router.get("/config/export/all", + description="Export all platform configuration (APIs, Endpoints, Roles, Groups, Routings)", + response_model=ResponseModel, +) +async def export_all(request: Request): + request_id = str(uuid.uuid4()) + start = time.time() * 1000 + try: + payload = await auth_required(request) + username = payload.get("sub") + if not await platform_role_required_bool(username, 'manage_gateway'): + return process_response(ResponseModel(status_code=403, error_code="CFG001", error_message="Insufficient permissions").dict(), "rest") + data = _export_all() + return process_response(ResponseModel(status_code=200, response_headers={"request_id": request_id}, response=data).dict(), "rest") + except Exception as e: + logger.error(f"{request_id} | export_all error: {e}") + return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest") + finally: + logger.info(f"{request_id} | export_all took {time.time()*1000 - start:.2f}ms") + + +@config_router.get("/config/export/apis", + description="Export APIs (optionally a single API with its endpoints)", + response_model=ResponseModel) +async def export_apis(request: Request, api_name: Optional[str] = None, api_version: Optional[str] = None): + request_id = str(uuid.uuid4()) + start = time.time() * 1000 + try: + payload = await auth_required(request) + username = payload.get("sub") + if not await platform_role_required_bool(username, 'manage_apis'): + return process_response(ResponseModel(status_code=403, error_code="CFG002", error_message="Insufficient permissions").dict(), "rest") + if api_name and api_version: + api = api_collection.find_one({"api_name": api_name, "api_version": api_version}) + if not api: + return process_response(ResponseModel(status_code=404, error_code="CFG404", error_message="API not found").dict(), "rest") + aid = api.get('api_id') + eps = endpoint_collection.find({"api_name": api_name, "api_version": api_version}).to_list(length=None) + return process_response(ResponseModel(status_code=200, response={ + "api": _strip_id(api), + "endpoints": [_strip_id(e) for e in eps] + }).dict(), "rest") + apis = [_strip_id(a) for a in api_collection.find().to_list(length=None)] + return process_response(ResponseModel(status_code=200, response={"apis": apis}).dict(), "rest") + except Exception as e: + logger.error(f"{request_id} | export_apis error: {e}") + return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest") + finally: + logger.info(f"{request_id} | export_apis took {time.time()*1000 - start:.2f}ms") + + +@config_router.get("/config/export/roles", description="Export Roles", response_model=ResponseModel) +async def export_roles(request: Request, role_name: Optional[str] = None): + request_id = str(uuid.uuid4()) + try: + payload = await auth_required(request) + username = payload.get("sub") + if not await platform_role_required_bool(username, 'manage_roles'): + return process_response(ResponseModel(status_code=403, error_code="CFG003", error_message="Insufficient permissions").dict(), "rest") + if role_name: + role = role_collection.find_one({"role_name": role_name}) + if not role: + return process_response(ResponseModel(status_code=404, error_code="CFG404", error_message="Role not found").dict(), "rest") + return process_response(ResponseModel(status_code=200, response={"role": _strip_id(role)}).dict(), "rest") + roles = [_strip_id(r) for r in role_collection.find().to_list(length=None)] + return process_response(ResponseModel(status_code=200, response={"roles": roles}).dict(), "rest") + except Exception as e: + logger.error(f"{request_id} | export_roles error: {e}") + return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest") + + +@config_router.get("/config/export/groups", description="Export Groups", response_model=ResponseModel) +async def export_groups(request: Request, group_name: Optional[str] = None): + request_id = str(uuid.uuid4()) + try: + payload = await auth_required(request) + username = payload.get("sub") + if not await platform_role_required_bool(username, 'manage_groups'): + return process_response(ResponseModel(status_code=403, error_code="CFG004", error_message="Insufficient permissions").dict(), "rest") + if group_name: + group = group_collection.find_one({"group_name": group_name}) + if not group: + return process_response(ResponseModel(status_code=404, error_code="CFG404", error_message="Group not found").dict(), "rest") + return process_response(ResponseModel(status_code=200, response={"group": _strip_id(group)}).dict(), "rest") + groups = [_strip_id(g) for g in group_collection.find().to_list(length=None)] + return process_response(ResponseModel(status_code=200, response={"groups": groups}).dict(), "rest") + except Exception as e: + logger.error(f"{request_id} | export_groups error: {e}") + return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest") + + +@config_router.get("/config/export/routings", description="Export Routings", response_model=ResponseModel) +async def export_routings(request: Request, client_key: Optional[str] = None): + request_id = str(uuid.uuid4()) + try: + payload = await auth_required(request) + username = payload.get("sub") + if not await platform_role_required_bool(username, 'manage_routings'): + return process_response(ResponseModel(status_code=403, error_code="CFG005", error_message="Insufficient permissions").dict(), "rest") + if client_key: + routing = routing_collection.find_one({"client_key": client_key}) + if not routing: + return process_response(ResponseModel(status_code=404, error_code="CFG404", error_message="Routing not found").dict(), "rest") + return process_response(ResponseModel(status_code=200, response={"routing": _strip_id(routing)}).dict(), "rest") + routings = [_strip_id(r) for r in routing_collection.find().to_list(length=None)] + return process_response(ResponseModel(status_code=200, response={"routings": routings}).dict(), "rest") + except Exception as e: + logger.error(f"{request_id} | export_routings error: {e}") + return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest") + + +@config_router.get("/config/export/endpoints", + description="Export endpoints (optionally filter by api_name/api_version)", + response_model=ResponseModel) +async def export_endpoints(request: Request, api_name: Optional[str] = None, api_version: Optional[str] = None): + request_id = str(uuid.uuid4()) + try: + payload = await auth_required(request) + username = payload.get("sub") + # Reuse manage_endpoints permission for endpoint export + if not await platform_role_required_bool(username, 'manage_endpoints'): + return process_response(ResponseModel(status_code=403, error_code="CFG007", error_message="Insufficient permissions").dict(), "rest") + query = {} + if api_name: + query['api_name'] = api_name + if api_version: + query['api_version'] = api_version + eps = [_strip_id(e) for e in endpoint_collection.find(query).to_list(length=None)] + return process_response(ResponseModel(status_code=200, response={"endpoints": eps}).dict(), "rest") + except Exception as e: + logger.error(f"{request_id} | export_endpoints error: {e}") + return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest") + + +def _upsert_api(doc: Dict[str, Any]) -> None: + api_name = doc.get('api_name') + api_version = doc.get('api_version') + if not api_name or not api_version: + return + existing = api_collection.find_one({"api_name": api_name, "api_version": api_version}) + to_set = copy.deepcopy(_strip_id(doc)) + # Ensure identifiers + if existing: + if not to_set.get('api_id'): + to_set['api_id'] = existing.get('api_id') + else: + to_set.setdefault('api_id', str(uuid.uuid4())) + to_set.setdefault('api_path', f"/{api_name}/{api_version}") + if existing: + api_collection.update_one({"api_name": api_name, "api_version": api_version}, {"$set": to_set}) + else: + api_collection.insert_one(to_set) + + +def _upsert_endpoint(doc: Dict[str, Any]) -> None: + api_name = doc.get('api_name') + api_version = doc.get('api_version') + method = doc.get('endpoint_method') + uri = doc.get('endpoint_uri') + if not (api_name and api_version and method and uri): + return + # Ensure endpoint_id and api_id + api_doc = api_collection.find_one({"api_name": api_name, "api_version": api_version}) + to_set = copy.deepcopy(_strip_id(doc)) + if api_doc: + to_set['api_id'] = api_doc.get('api_id') + to_set.setdefault('endpoint_id', str(uuid.uuid4())) + existing = endpoint_collection.find_one({ + 'api_name': api_name, + 'api_version': api_version, + 'endpoint_method': method, + 'endpoint_uri': uri, + }) + if existing: + endpoint_collection.update_one({ + 'api_name': api_name, + 'api_version': api_version, + 'endpoint_method': method, + 'endpoint_uri': uri, + }, {"$set": to_set}) + else: + endpoint_collection.insert_one(to_set) + + +def _upsert_role(doc: Dict[str, Any]) -> None: + name = doc.get('role_name') + if not name: + return + to_set = copy.deepcopy(_strip_id(doc)) + existing = role_collection.find_one({'role_name': name}) + if existing: + role_collection.update_one({'role_name': name}, {"$set": to_set}) + else: + role_collection.insert_one(to_set) + + +def _upsert_group(doc: Dict[str, Any]) -> None: + name = doc.get('group_name') + if not name: + return + to_set = copy.deepcopy(_strip_id(doc)) + existing = group_collection.find_one({'group_name': name}) + if existing: + group_collection.update_one({'group_name': name}, {"$set": to_set}) + else: + group_collection.insert_one(to_set) + + +def _upsert_routing(doc: Dict[str, Any]) -> None: + key = doc.get('client_key') + if not key: + return + to_set = copy.deepcopy(_strip_id(doc)) + existing = routing_collection.find_one({'client_key': key}) + if existing: + routing_collection.update_one({'client_key': key}, {"$set": to_set}) + else: + routing_collection.insert_one(to_set) + + +@config_router.post("/config/import", + description="Import platform configuration (any subset of apis, endpoints, roles, groups, routings)", + response_model=ResponseModel) +async def import_all(request: Request, body: Dict[str, Any]): + request_id = str(uuid.uuid4()) + start = time.time() * 1000 + try: + payload = await auth_required(request) + username = payload.get("sub") + # Require broad gateway permission for bulk import + if not await platform_role_required_bool(username, 'manage_gateway'): + return process_response(ResponseModel(status_code=403, error_code="CFG006", error_message="Insufficient permissions").dict(), "rest") + counts = {"apis": 0, "endpoints": 0, "roles": 0, "groups": 0, "routings": 0} + for api in body.get('apis', []) or []: + _upsert_api(api); counts['apis'] += 1 + for ep in body.get('endpoints', []) or []: + _upsert_endpoint(ep); counts['endpoints'] += 1 + for r in body.get('roles', []) or []: + _upsert_role(r); counts['roles'] += 1 + for g in body.get('groups', []) or []: + _upsert_group(g); counts['groups'] += 1 + for rt in body.get('routings', []) or []: + _upsert_routing(rt); counts['routings'] += 1 + # Invalidate caches so changes take immediate effect + try: + doorman_cache.clear_all_caches() + except Exception: + pass + return process_response(ResponseModel(status_code=200, response={"imported": counts}).dict(), "rest") + except Exception as e: + logger.error(f"{request_id} | import_all error: {e}") + return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest") + finally: + logger.info(f"{request_id} | import_all took {time.time()*1000 - start:.2f}ms") + diff --git a/backend-services/routes/security_routes.py b/backend-services/routes/security_routes.py index 0eafae9..47df25b 100644 --- a/backend-services/routes/security_routes.py +++ b/backend-services/routes/security_routes.py @@ -139,7 +139,7 @@ async def restart_gateway(request: Request): status_code=409, response_headers={"request_id": request_id}, error_code="SEC004", - error_message="Restart not supported: no PID file found (run using 'doorman start' or use your orchestrator to restart)" + error_message="Restart not supported: no PID file found (run using 'doorman start' or contact your admin)" ).dict(), "rest") # Spawn a detached helper to perform restart so this request can return 202 doorman_path = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'doorman.py')) diff --git a/web-client/public/docs/using-fields.html b/web-client/public/docs/using-fields.html new file mode 100644 index 0000000..4333341 --- /dev/null +++ b/web-client/public/docs/using-fields.html @@ -0,0 +1,108 @@ + + + + + + Doorman Field Guide + + + +

Doorman Field Guide

+
+

This guide explains sensitive fields and common configurations with examples.

+ +

APIs

+

API Name/Version define the base path clients call: /api/rest/<name>/<version>/....

+
Example: name users, version v1 → client calls /api/rest/users/v1/list
+ +

Configuration

+ +
+# Example curl
+curl -H "Authorization: Bearer ..." \
+     http://localhost:5001/api/rest/users/v1/list
+    
+ +

Servers

+

Add one or more upstream base URLs (scheme + host + port). Endpoint-level servers can override. Selection defaults to round-robin.

+ +

Access Control

+

Access requires BOTH an allowed role and membership in ANY allowed group.

+ +

Endpoints

+

Define Method and URI relative to the API base. Use {param} syntax for path variables.

+
Example: GET /items/{id} matches /api/rest/users/v1/items/123
+

Enable Endpoint Servers to override API servers for this endpoint only.

+ +

Routing

+

Create named routing sets with an ordered list of upstreams. Doorman may choose an upstream based on client key, method, and policies.

+ +

Users

+ + +

Rate Limiting

+

Limits requests per user over a time window (e.g., 100 per minute). Exceeding limits returns 429.

+ +

Throttling

+

Controls burst behavior with duration, wait, and optional queue size.

+ + +

Credits

+

Credit definitions specify a credit group, an API key header name, a default API key value, and one or more tiers.

+ + +

Security

+

Auto-save writes encrypted memory dumps periodically (requires MEM_ENCRYPTION_KEY). Dump Path stores the file; use an encrypted volume. +

Restart is required to apply server TLS changes when running with built-in HTTPS. Use an edge proxy for zero-downtime rotation.
+ +

Auth Admin

+

Check status, revoke tokens, and enable/disable users. Revocation immediately invalidates tokens; enable/disable toggles login capability.

+ +

Examples

+
+# REST call through Doorman
+curl -H "Authorization: Bearer <token>" \
+     -H "client-key: demo-client" \
+     http://localhost:5001/api/rest/users/v1/items/42
+
+# GraphQL
+curl -H "Authorization: Bearer <token>" \
+     -H "X-API-Version: v1" \
+     -H "Content-Type: application/json" \
+     -d '{"query":"{ hello }"}' \
+     http://localhost:5001/api/graphql/example
+    
+
+ + diff --git a/web-client/src/app/apis/[apiId]/endpoints/add/page.tsx b/web-client/src/app/apis/[apiId]/endpoints/add/page.tsx index e2c8313..cf997f7 100644 --- a/web-client/src/app/apis/[apiId]/endpoints/add/page.tsx +++ b/web-client/src/app/apis/[apiId]/endpoints/add/page.tsx @@ -4,6 +4,8 @@ import React, { useEffect, useState } from 'react' import Link from 'next/link' import { useParams, useRouter } from 'next/navigation' import Layout from '@/components/Layout' +import InfoTooltip from '@/components/InfoTooltip' +import FormHelp from '@/components/FormHelp' import { SERVER_URL } from '@/utils/config' import { postJson } from '@/utils/api' @@ -100,6 +102,9 @@ export default function AddEndpointPage() {
+
+ Define method and URI; optional upstream override per endpoint. +
- + setUri(e.target.value)} placeholder="/path/{id}" />
@@ -118,7 +126,10 @@ export default function AddEndpointPage() {
setUseOverride(e.target.checked)} /> - +
{
{api.api_credits_enabled && ( -
- +
+ {isEditing ? ( {
{isEditing ? ( {
{isEditing ? (
@@ -902,8 +905,9 @@ const ApiDetailPage = () => { {/* Allowed Groups */}
-
+

Allowed Groups

+ User must belong to any listed group.
{isEditing && ( @@ -951,6 +955,7 @@ const ApiDetailPage = () => {

Servers

Used when no client routing or endpoint override is configured

+ Add base upstreams; include scheme and port.
{isEditing && ( @@ -1067,8 +1072,9 @@ const ApiDetailPage = () => { {/* Allowed Headers */}
-
+

Allowed Headers

+ Forward only selected upstream response headers.
{isEditing && ( diff --git a/web-client/src/app/apis/add/page.tsx b/web-client/src/app/apis/add/page.tsx index 3f8a889..428b64f 100644 --- a/web-client/src/app/apis/add/page.tsx +++ b/web-client/src/app/apis/add/page.tsx @@ -4,6 +4,8 @@ import React, { useState } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' import Layout from '@/components/Layout' +import InfoTooltip from '@/components/InfoTooltip' +import FormHelp from '@/components/FormHelp' import { SERVER_URL } from '@/utils/config' import { postJson } from '@/utils/api' @@ -153,8 +155,9 @@ const AddApiPage = () => { {/* Form */}
-
+

Basic Information

+ Fill API name/version; these form the base path clients call.
@@ -194,7 +197,7 @@ const AddApiPage = () => { disabled={loading} />

- API version (e.g., v1, v2, beta) + API version (e.g., v1, v2)

@@ -243,12 +246,16 @@ const AddApiPage = () => {
-

Configuration

+
+

Configuration

+ Set credits, auth header mapping, and validations. +
{
{ )}
- +
-

Servers

+
+

Servers

+ Add one or more upstream base URLs used for proxying. +
- +
setNewServer(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && addServer()} disabled={loading} /> @@ -317,11 +334,15 @@ const AddApiPage = () => {
-

Allowed Roles

+
+

Allowed Roles

+ Grant access by platform roles and groups. +
{
{
-

Allowed Groups

+
+

Allowed Groups

+ Restrict by user groups; use ALL to allow any group. +
{
-

Allowed Headers

+
+

Allowed Headers

+ Choose which upstream response headers are forwarded. +
- +
setNewHeader(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && addHeader()} disabled={loading} /> diff --git a/web-client/src/app/auth-admin/page.tsx b/web-client/src/app/auth-admin/page.tsx index ffc4f81..d718fbc 100644 --- a/web-client/src/app/auth-admin/page.tsx +++ b/web-client/src/app/auth-admin/page.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react' import Layout from '@/components/Layout' +import FormHelp from '@/components/FormHelp' import { SERVER_URL } from '@/utils/config' import { getJson } from '@/utils/api' import { ProtectedRoute } from '@/components/ProtectedRoute' @@ -86,6 +87,7 @@ export default function AuthAdminPage() {
+ Look up user status, revoke tokens, and enable/disable accounts.
diff --git a/web-client/src/app/credit-defs/[group]/page.tsx b/web-client/src/app/credit-defs/[group]/page.tsx index d5bc386..ca955b3 100644 --- a/web-client/src/app/credit-defs/[group]/page.tsx +++ b/web-client/src/app/credit-defs/[group]/page.tsx @@ -4,6 +4,8 @@ import React, { useEffect, useState } from 'react' import Link from 'next/link' import { useParams, useRouter } from 'next/navigation' import Layout from '@/components/Layout' +import InfoTooltip from '@/components/InfoTooltip' +import FormHelp from '@/components/FormHelp' import { SERVER_URL } from '@/utils/config' import { getJson, putJson, delJson } from '@/utils/api' import { ProtectedRoute } from '@/components/ProtectedRoute' @@ -79,23 +81,24 @@ export default function EditCreditDefPage() {
+ Edit key header, optional key, and tiers. Changes apply immediately. {error &&
{error}
} {success &&
{success}
}
- +
- + setHeader(e.target.value)} />
- + setKey(e.target.value)} placeholder="sk_live_..." />
- +