agromatrix: enforce mentor auth and expose shared-memory review via gateway
This commit is contained in:
@@ -46,6 +46,8 @@ services:
|
|||||||
- ONEOK_SCHEDULE_BASE_URL=http://oneok-schedule-adapter:8091
|
- ONEOK_SCHEDULE_BASE_URL=http://oneok-schedule-adapter:8091
|
||||||
- ONEOK_ADAPTER_API_KEY=${ONEOK_ADAPTER_API_KEY}
|
- ONEOK_ADAPTER_API_KEY=${ONEOK_ADAPTER_API_KEY}
|
||||||
- ROUTER_TOOL_MAX_ROUNDS=${ROUTER_TOOL_MAX_ROUNDS:-10}
|
- ROUTER_TOOL_MAX_ROUNDS=${ROUTER_TOOL_MAX_ROUNDS:-10}
|
||||||
|
- AGROMATRIX_REVIEW_AUTH_MODE=${AGROMATRIX_REVIEW_AUTH_MODE:-bearer}
|
||||||
|
- AGROMATRIX_REVIEW_BEARER_TOKENS=${AGROMATRIX_REVIEW_BEARER_TOKENS:-mentor-review-dev-token-change-me}
|
||||||
volumes:
|
volumes:
|
||||||
- ${DEPLOY_ROOT:-.}/services/router/router_config.yaml:/app/router_config.yaml:ro
|
- ${DEPLOY_ROOT:-.}/services/router/router_config.yaml:/app/router_config.yaml:ro
|
||||||
- ${DEPLOY_ROOT:-.}/services/router/router-config.yml:/app/router-config.yml:ro
|
- ${DEPLOY_ROOT:-.}/services/router/router-config.yml:/app/router-config.yml:ro
|
||||||
@@ -220,6 +222,8 @@ services:
|
|||||||
- WEB_SEARCH_SERVICE_URL=http://swapper-service:8890
|
- WEB_SEARCH_SERVICE_URL=http://swapper-service:8890
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- CREWAI_SERVICE_URL=http://dagi-staging-crewai-service:9010
|
- CREWAI_SERVICE_URL=http://dagi-staging-crewai-service:9010
|
||||||
|
- AGROMATRIX_REVIEW_AUTH_MODE=${AGROMATRIX_REVIEW_AUTH_MODE:-bearer}
|
||||||
|
- AGROMATRIX_REVIEW_BEARER_TOKENS=${AGROMATRIX_REVIEW_BEARER_TOKENS:-mentor-review-dev-token-change-me}
|
||||||
volumes:
|
volumes:
|
||||||
- ${DEPLOY_ROOT:-.}/gateway-bot:/app/gateway-bot:ro
|
- ${DEPLOY_ROOT:-.}/gateway-bot:/app/gateway-bot:ro
|
||||||
- ${DEPLOY_ROOT:-.}/logs:/app/logs
|
- ${DEPLOY_ROOT:-.}/logs:/app/logs
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, Request, status
|
from fastapi import APIRouter, HTTPException, Request, status
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -17,6 +19,14 @@ router = APIRouter(prefix="/v1", tags=["daarion-facade"])
|
|||||||
EVENT_TERMINAL_STATUSES = {"done", "failed"}
|
EVENT_TERMINAL_STATUSES = {"done", "failed"}
|
||||||
EVENT_KNOWN_STATUSES = {"queued", "running", "done", "failed"}
|
EVENT_KNOWN_STATUSES = {"queued", "running", "done", "failed"}
|
||||||
EVENT_POLL_SECONDS = float(os.getenv("DAARION_JOB_EVENTS_POLL_SECONDS", "0.5"))
|
EVENT_POLL_SECONDS = float(os.getenv("DAARION_JOB_EVENTS_POLL_SECONDS", "0.5"))
|
||||||
|
ROUTER_URL = os.getenv("ROUTER_URL", "http://router:8000").rstrip("/")
|
||||||
|
ROUTER_REVIEW_TIMEOUT = float(os.getenv("DAARION_ROUTER_REVIEW_TIMEOUT_SECONDS", "20"))
|
||||||
|
AGROMATRIX_REVIEW_AUTH_MODE = os.getenv("AGROMATRIX_REVIEW_AUTH_MODE", "bearer").strip().lower()
|
||||||
|
AGROMATRIX_REVIEW_BEARER_TOKENS = [
|
||||||
|
part.strip()
|
||||||
|
for part in os.getenv("AGROMATRIX_REVIEW_BEARER_TOKENS", "").replace(";", ",").split(",")
|
||||||
|
if part.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class InvokeInput(BaseModel):
|
class InvokeInput(BaseModel):
|
||||||
@@ -36,6 +46,69 @@ class InvokeResponse(BaseModel):
|
|||||||
status_url: str
|
status_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class SharedMemoryReviewRequest(BaseModel):
|
||||||
|
point_id: str
|
||||||
|
approve: bool
|
||||||
|
reviewer: str | None = None
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bearer_token(request: Request) -> str:
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
if not auth_header.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="Missing Bearer token")
|
||||||
|
token = auth_header[len("Bearer ") :].strip()
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=401, detail="Empty Bearer token")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _require_mentor_auth(request: Request) -> str:
|
||||||
|
mode = AGROMATRIX_REVIEW_AUTH_MODE
|
||||||
|
if mode in {"off", "none", "disabled"}:
|
||||||
|
return ""
|
||||||
|
if mode != "bearer":
|
||||||
|
raise HTTPException(status_code=500, detail=f"Unsupported AGROMATRIX_REVIEW_AUTH_MODE={mode}")
|
||||||
|
if not AGROMATRIX_REVIEW_BEARER_TOKENS:
|
||||||
|
raise HTTPException(status_code=503, detail="Review auth is not configured")
|
||||||
|
token = _extract_bearer_token(request)
|
||||||
|
if not any(hmac.compare_digest(token, candidate) for candidate in AGROMATRIX_REVIEW_BEARER_TOKENS):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid mentor token")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def _router_json(
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
payload: Dict[str, Any] | None = None,
|
||||||
|
params: Dict[str, Any] | None = None,
|
||||||
|
authorization: str | None = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
headers: Dict[str, str] = {}
|
||||||
|
if authorization:
|
||||||
|
headers["Authorization"] = authorization
|
||||||
|
url = f"{ROUTER_URL}{path}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=ROUTER_REVIEW_TIMEOUT) as client:
|
||||||
|
resp = await client.request(method, url, json=payload, params=params, headers=headers)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise HTTPException(status_code=504, detail="Router timeout")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Router unavailable: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
except Exception:
|
||||||
|
body = {"raw": resp.text}
|
||||||
|
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
detail = body.get("detail") if isinstance(body, dict) else body
|
||||||
|
raise HTTPException(status_code=resp.status_code, detail=detail or f"Router error {resp.status_code}")
|
||||||
|
return body if isinstance(body, dict) else {"data": body}
|
||||||
|
|
||||||
|
|
||||||
def _sse_message(event: str, payload: Dict[str, Any]) -> str:
|
def _sse_message(event: str, payload: Dict[str, Any]) -> str:
|
||||||
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
@@ -116,3 +189,24 @@ async def job_events(job_id: str, request: Request) -> StreamingResponse:
|
|||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agromatrix/shared-memory/pending")
|
||||||
|
async def agromatrix_shared_pending(limit: int = 50) -> Dict[str, Any]:
|
||||||
|
return await _router_json(
|
||||||
|
"GET",
|
||||||
|
"/v1/agromatrix/shared-memory/pending",
|
||||||
|
params={"limit": max(1, min(limit, 200))},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/agromatrix/shared-memory/review")
|
||||||
|
async def agromatrix_shared_review(req: SharedMemoryReviewRequest, request: Request) -> Dict[str, Any]:
|
||||||
|
token = _require_mentor_auth(request)
|
||||||
|
auth_header = f"Bearer {token}" if token else None
|
||||||
|
return await _router_json(
|
||||||
|
"POST",
|
||||||
|
"/v1/agromatrix/shared-memory/review",
|
||||||
|
payload=req.model_dump(),
|
||||||
|
authorization=auth_header,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -12,13 +13,13 @@ TINY_PNG_DATA_URL = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def http_json(method: str, url: str, payload=None):
|
def http_json(method: str, url: str, payload=None, headers=None):
|
||||||
data = None
|
data = None
|
||||||
headers = {}
|
req_headers = dict(headers or {})
|
||||||
if payload is not None:
|
if payload is not None:
|
||||||
data = json.dumps(payload).encode("utf-8")
|
data = json.dumps(payload).encode("utf-8")
|
||||||
headers["Content-Type"] = "application/json"
|
req_headers.setdefault("Content-Type", "application/json")
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
body = resp.read().decode("utf-8", errors="replace")
|
body = resp.read().decode("utf-8", errors="replace")
|
||||||
@@ -46,6 +47,14 @@ def main() -> int:
|
|||||||
parser.add_argument("--chat-id", default="smoke-agromatrix")
|
parser.add_argument("--chat-id", default="smoke-agromatrix")
|
||||||
parser.add_argument("--user-id", default="smoke-user")
|
parser.add_argument("--user-id", default="smoke-user")
|
||||||
parser.add_argument("--skip-review-404", action="store_true")
|
parser.add_argument("--skip-review-404", action="store_true")
|
||||||
|
parser.add_argument(
|
||||||
|
"--mentor-token",
|
||||||
|
default=(
|
||||||
|
os.getenv("AGROMATRIX_REVIEW_BEARER_TOKEN")
|
||||||
|
or (os.getenv("AGROMATRIX_REVIEW_BEARER_TOKENS", "").split(",")[0].strip())
|
||||||
|
or ""
|
||||||
|
),
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
ok_all = True
|
ok_all = True
|
||||||
@@ -95,6 +104,9 @@ def main() -> int:
|
|||||||
ok_all &= check(status == 200 and pending_shape, "shared_pending_endpoint", f"total={pending.get('total')}")
|
ok_all &= check(status == 200 and pending_shape, "shared_pending_endpoint", f"total={pending.get('total')}")
|
||||||
|
|
||||||
if not args.skip_review_404:
|
if not args.skip_review_404:
|
||||||
|
req_headers = {}
|
||||||
|
if args.mentor_token:
|
||||||
|
req_headers["Authorization"] = f"Bearer {args.mentor_token}"
|
||||||
status, review = http_json(
|
status, review = http_json(
|
||||||
"POST",
|
"POST",
|
||||||
f"{args.base_url}/v1/agromatrix/shared-memory/review",
|
f"{args.base_url}/v1/agromatrix/shared-memory/review",
|
||||||
@@ -104,8 +116,10 @@ def main() -> int:
|
|||||||
"reviewer": "smoke",
|
"reviewer": "smoke",
|
||||||
"note": "nonexistent id check",
|
"note": "nonexistent id check",
|
||||||
},
|
},
|
||||||
|
headers=req_headers,
|
||||||
)
|
)
|
||||||
ok_all &= check(status == 404, "shared_review_not_found_contract", str(review))
|
expected = 404 if args.mentor_token else 401
|
||||||
|
ok_all &= check(status == expected, "shared_review_not_found_contract", str(review))
|
||||||
|
|
||||||
return 0 if ok_all else 1
|
return 0 if ok_all else 1
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Literal, Optional, Dict, Any, List
|
from typing import Literal, Optional, Dict, Any, List
|
||||||
@@ -10,6 +10,7 @@ import yaml
|
|||||||
import httpx
|
import httpx
|
||||||
import logging
|
import logging
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
import time # For latency metrics
|
import time # For latency metrics
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
@@ -888,6 +889,12 @@ CLAN_RUNTIME_CONSENT_EVENT_SCHEMA_PATH = os.getenv(
|
|||||||
NEO4J_URI = os.getenv("NEO4J_BOLT_URL", "bolt://neo4j:7687")
|
NEO4J_URI = os.getenv("NEO4J_BOLT_URL", "bolt://neo4j:7687")
|
||||||
NEO4J_USER = os.getenv("NEO4J_USER", "neo4j")
|
NEO4J_USER = os.getenv("NEO4J_USER", "neo4j")
|
||||||
NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "DaarionNeo4j2026!")
|
NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "DaarionNeo4j2026!")
|
||||||
|
AGROMATRIX_REVIEW_AUTH_MODE = os.getenv("AGROMATRIX_REVIEW_AUTH_MODE", "bearer").strip().lower()
|
||||||
|
AGROMATRIX_REVIEW_BEARER_TOKENS = [
|
||||||
|
part.strip()
|
||||||
|
for part in os.getenv("AGROMATRIX_REVIEW_BEARER_TOKENS", "").replace(";", ",").split(",")
|
||||||
|
if part.strip()
|
||||||
|
]
|
||||||
|
|
||||||
# HTTP client for backend services
|
# HTTP client for backend services
|
||||||
http_client: Optional[httpx.AsyncClient] = None
|
http_client: Optional[httpx.AsyncClient] = None
|
||||||
@@ -1235,6 +1242,30 @@ class SharedMemoryReviewRequest(BaseModel):
|
|||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_agromatrix_review_auth(request: Request) -> None:
|
||||||
|
mode = AGROMATRIX_REVIEW_AUTH_MODE
|
||||||
|
if mode in {"off", "none", "disabled"}:
|
||||||
|
return
|
||||||
|
|
||||||
|
if mode != "bearer":
|
||||||
|
raise HTTPException(status_code=500, detail=f"Unsupported AGROMATRIX_REVIEW_AUTH_MODE={mode}")
|
||||||
|
|
||||||
|
if not AGROMATRIX_REVIEW_BEARER_TOKENS:
|
||||||
|
logger.error("AGROMATRIX_REVIEW_AUTH_MODE=bearer but AGROMATRIX_REVIEW_BEARER_TOKENS is empty")
|
||||||
|
raise HTTPException(status_code=503, detail="Review auth is not configured")
|
||||||
|
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
if not auth_header.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="Missing Bearer token")
|
||||||
|
|
||||||
|
token = auth_header[len("Bearer ") :].strip()
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=401, detail="Empty Bearer token")
|
||||||
|
|
||||||
|
if not any(hmac.compare_digest(token, candidate) for candidate in AGROMATRIX_REVIEW_BEARER_TOKENS):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid mentor token")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -2889,8 +2920,10 @@ async def agromatrix_shared_pending(limit: int = 50):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/v1/agromatrix/shared-memory/review")
|
@app.post("/v1/agromatrix/shared-memory/review")
|
||||||
async def agromatrix_shared_review(req: SharedMemoryReviewRequest):
|
async def agromatrix_shared_review(req: SharedMemoryReviewRequest, request: Request):
|
||||||
"""Approve or reject a pending shared agronomy memory case."""
|
"""Approve or reject a pending shared agronomy memory case."""
|
||||||
|
_require_agromatrix_review_auth(request)
|
||||||
|
|
||||||
if not MEMORY_RETRIEVAL_AVAILABLE or not memory_retrieval:
|
if not MEMORY_RETRIEVAL_AVAILABLE or not memory_retrieval:
|
||||||
raise HTTPException(status_code=503, detail="Memory retrieval not available")
|
raise HTTPException(status_code=503, detail="Memory retrieval not available")
|
||||||
if not hasattr(memory_retrieval, "review_shared_pending_case"):
|
if not hasattr(memory_retrieval, "review_shared_pending_case"):
|
||||||
|
|||||||
Reference in New Issue
Block a user