第一次提交
This commit is contained in:
1
authz-service/src/app/__init__.py
Normal file
1
authz-service/src/app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""AuthZ service package."""
|
||||
268
authz-service/src/app/json_store.py
Normal file
268
authz-service/src/app/json_store.py
Normal file
@ -0,0 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
from app.models import ChannelSettings, BackendCredential, BackendRecord, OutlookSettings, UserRecord, utcnow_iso
|
||||
|
||||
|
||||
class JsonStore:
|
||||
def __init__(self, data_dir: Path):
|
||||
self.data_dir = data_dir
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = Lock()
|
||||
|
||||
@property
|
||||
def backends_path(self) -> Path:
|
||||
return self.data_dir / "backends.json"
|
||||
|
||||
@property
|
||||
def credentials_path(self) -> Path:
|
||||
return self.data_dir / "backend_credentials.json"
|
||||
|
||||
@property
|
||||
def permissions_path(self) -> Path:
|
||||
return self.data_dir / "permissions.json"
|
||||
|
||||
@property
|
||||
def users_path(self) -> Path:
|
||||
return self.data_dir / "users.json"
|
||||
|
||||
@property
|
||||
def settings_path(self) -> Path:
|
||||
return self.data_dir / "settings.json"
|
||||
|
||||
def _read_json(self, path: Path, default: dict[str, Any]) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return default
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
return default
|
||||
|
||||
def _write_json(self, path: Path, payload: dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name, suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp_name, path)
|
||||
finally:
|
||||
if os.path.exists(tmp_name):
|
||||
os.unlink(tmp_name)
|
||||
|
||||
def list_backends(self) -> list[BackendRecord]:
|
||||
raw = self._read_json(self.backends_path, {"backends": []})
|
||||
items = raw.get("backends", [])
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
return [BackendRecord.model_validate(item) for item in items if isinstance(item, dict)]
|
||||
|
||||
def get_backend(self, backend_id: str) -> BackendRecord | None:
|
||||
for backend in self.list_backends():
|
||||
if backend.backend_id == backend_id:
|
||||
return backend
|
||||
return None
|
||||
|
||||
def save_backend(self, backend: BackendRecord) -> None:
|
||||
backends = [item for item in self.list_backends() if item.backend_id != backend.backend_id]
|
||||
backends.append(backend)
|
||||
backends.sort(key=lambda item: item.backend_id)
|
||||
self._write_json(
|
||||
self.backends_path,
|
||||
{"backends": [item.model_dump(mode="json") for item in backends]},
|
||||
)
|
||||
|
||||
def list_credentials(self) -> list[BackendCredential]:
|
||||
raw = self._read_json(self.credentials_path, {"credentials": []})
|
||||
items = raw.get("credentials", [])
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
return [BackendCredential.model_validate(item) for item in items if isinstance(item, dict)]
|
||||
|
||||
def get_credential_by_client_id(self, client_id: str) -> BackendCredential | None:
|
||||
for item in self.list_credentials():
|
||||
if item.client_id == client_id:
|
||||
return item
|
||||
return None
|
||||
|
||||
def save_credential(self, credential: BackendCredential) -> None:
|
||||
creds = [item for item in self.list_credentials() if item.backend_id != credential.backend_id]
|
||||
creds.append(credential)
|
||||
creds.sort(key=lambda item: item.backend_id)
|
||||
self._write_json(
|
||||
self.credentials_path,
|
||||
{"credentials": [item.model_dump(mode="json") for item in creds]},
|
||||
)
|
||||
|
||||
def list_users(self) -> list[UserRecord]:
|
||||
raw = self._read_json(self.users_path, {"users": []})
|
||||
items = raw.get("users", [])
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
return [UserRecord.model_validate(item) for item in items if isinstance(item, dict)]
|
||||
|
||||
def get_user(self, username: str) -> UserRecord | None:
|
||||
for item in self.list_users():
|
||||
if item.username == username:
|
||||
return item
|
||||
return None
|
||||
|
||||
def save_user(self, user: UserRecord) -> None:
|
||||
users = [item for item in self.list_users() if item.username != user.username]
|
||||
users.append(user)
|
||||
users.sort(key=lambda item: item.username)
|
||||
self._write_json(
|
||||
self.users_path,
|
||||
{"users": [item.model_dump(mode="json") for item in users]},
|
||||
)
|
||||
|
||||
def get_permissions(self, backend_id: str) -> dict[str, Any]:
|
||||
raw = self._read_json(self.permissions_path, {"permissions": {}})
|
||||
permissions = raw.get("permissions", {})
|
||||
if not isinstance(permissions, dict):
|
||||
return {}
|
||||
value = permissions.get(backend_id, {})
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
def save_permissions(self, backend_id: str, permissions: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = self._read_json(self.permissions_path, {"permissions": {}})
|
||||
root = raw.get("permissions", {})
|
||||
if not isinstance(root, dict):
|
||||
root = {}
|
||||
root[backend_id] = permissions
|
||||
self._write_json(self.permissions_path, {"permissions": root})
|
||||
return permissions
|
||||
|
||||
def get_outlook_settings(self, backend_id: str) -> OutlookSettings | None:
|
||||
raw = self._read_json(self.settings_path, {"settings": {}})
|
||||
root = raw.get("settings", {})
|
||||
if not isinstance(root, dict):
|
||||
return None
|
||||
backend_settings = root.get(backend_id, {})
|
||||
if not isinstance(backend_settings, dict):
|
||||
return None
|
||||
outlook = backend_settings.get("outlook")
|
||||
if not isinstance(outlook, dict):
|
||||
return None
|
||||
return OutlookSettings.model_validate(outlook)
|
||||
|
||||
def list_channel_settings(self, backend_id: str) -> dict[str, ChannelSettings]:
|
||||
raw = self._read_json(self.settings_path, {"settings": {}})
|
||||
root = raw.get("settings", {})
|
||||
if not isinstance(root, dict):
|
||||
return {}
|
||||
backend_settings = root.get(backend_id, {})
|
||||
if not isinstance(backend_settings, dict):
|
||||
return {}
|
||||
channels = backend_settings.get("channels", {})
|
||||
if not isinstance(channels, dict):
|
||||
return {}
|
||||
return {
|
||||
str(channel_id): ChannelSettings.model_validate(settings)
|
||||
for channel_id, settings in channels.items()
|
||||
if isinstance(channel_id, str) and channel_id.strip() and isinstance(settings, dict)
|
||||
}
|
||||
|
||||
def get_channel_settings(self, backend_id: str, channel_id: str) -> ChannelSettings | None:
|
||||
return self.list_channel_settings(backend_id).get(channel_id)
|
||||
|
||||
def save_outlook_settings(self, backend_id: str, settings: OutlookSettings) -> dict[str, Any]:
|
||||
raw = self._read_json(self.settings_path, {"settings": {}})
|
||||
root = raw.get("settings", {})
|
||||
if not isinstance(root, dict):
|
||||
root = {}
|
||||
backend_settings = root.get(backend_id, {})
|
||||
if not isinstance(backend_settings, dict):
|
||||
backend_settings = {}
|
||||
backend_settings["outlook"] = settings.model_dump(mode="json")
|
||||
root[backend_id] = backend_settings
|
||||
self._write_json(self.settings_path, {"settings": root})
|
||||
return backend_settings
|
||||
|
||||
def save_channel_settings(self, backend_id: str, channel_id: str, settings: ChannelSettings) -> dict[str, Any]:
|
||||
raw = self._read_json(self.settings_path, {"settings": {}})
|
||||
root = raw.get("settings", {})
|
||||
if not isinstance(root, dict):
|
||||
root = {}
|
||||
backend_settings = root.get(backend_id, {})
|
||||
if not isinstance(backend_settings, dict):
|
||||
backend_settings = {}
|
||||
channels = backend_settings.get("channels", {})
|
||||
if not isinstance(channels, dict):
|
||||
channels = {}
|
||||
channels[channel_id] = settings.model_dump(mode="json")
|
||||
backend_settings["channels"] = channels
|
||||
root[backend_id] = backend_settings
|
||||
self._write_json(self.settings_path, {"settings": root})
|
||||
return channels[channel_id]
|
||||
|
||||
def delete_outlook_settings(self, backend_id: str) -> bool:
|
||||
raw = self._read_json(self.settings_path, {"settings": {}})
|
||||
root = raw.get("settings", {})
|
||||
if not isinstance(root, dict):
|
||||
return False
|
||||
backend_settings = root.get(backend_id)
|
||||
if not isinstance(backend_settings, dict) or "outlook" not in backend_settings:
|
||||
return False
|
||||
backend_settings.pop("outlook", None)
|
||||
if backend_settings:
|
||||
root[backend_id] = backend_settings
|
||||
else:
|
||||
root.pop(backend_id, None)
|
||||
self._write_json(self.settings_path, {"settings": root})
|
||||
return True
|
||||
|
||||
def delete_channel_settings(self, backend_id: str, channel_id: str) -> bool:
|
||||
raw = self._read_json(self.settings_path, {"settings": {}})
|
||||
root = raw.get("settings", {})
|
||||
if not isinstance(root, dict):
|
||||
return False
|
||||
backend_settings = root.get(backend_id)
|
||||
if not isinstance(backend_settings, dict):
|
||||
return False
|
||||
channels = backend_settings.get("channels")
|
||||
if not isinstance(channels, dict) or channel_id not in channels:
|
||||
return False
|
||||
channels.pop(channel_id, None)
|
||||
if channels:
|
||||
backend_settings["channels"] = channels
|
||||
else:
|
||||
backend_settings.pop("channels", None)
|
||||
if backend_settings:
|
||||
root[backend_id] = backend_settings
|
||||
else:
|
||||
root.pop(backend_id, None)
|
||||
self._write_json(self.settings_path, {"settings": root})
|
||||
return True
|
||||
|
||||
def disable_backend(self, backend_id: str) -> BackendRecord | None:
|
||||
backend = self.get_backend(backend_id)
|
||||
if backend is None:
|
||||
return None
|
||||
backend.status = "disabled"
|
||||
backend.updated_at = utcnow_iso()
|
||||
self.save_backend(backend)
|
||||
return backend
|
||||
|
||||
def enable_backend(self, backend_id: str) -> BackendRecord | None:
|
||||
backend = self.get_backend(backend_id)
|
||||
if backend is None:
|
||||
return None
|
||||
backend.status = "active"
|
||||
backend.updated_at = utcnow_iso()
|
||||
self.save_backend(backend)
|
||||
return backend
|
||||
|
||||
def touch_backend(self, backend_id: str) -> BackendRecord | None:
|
||||
backend = self.get_backend(backend_id)
|
||||
if backend is None:
|
||||
return None
|
||||
backend.updated_at = utcnow_iso()
|
||||
self.save_backend(backend)
|
||||
return backend
|
||||
554
authz-service/src/app/main.py
Normal file
554
authz-service/src/app/main.py
Normal file
@ -0,0 +1,554 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.json_store import JsonStore
|
||||
from app.models import (
|
||||
ChannelSettings,
|
||||
BackendCredential,
|
||||
BackendRecord,
|
||||
IntrospectRequest,
|
||||
IntrospectResponse,
|
||||
OAuthTokenRequest,
|
||||
OAuthTokenResponse,
|
||||
OutlookSettings,
|
||||
RegisterBackendRequest,
|
||||
RegisterBackendResponse,
|
||||
RegisterUserRequest,
|
||||
RegisterUserResponse,
|
||||
RegisterUserBackendResult,
|
||||
RotateSecretResponse,
|
||||
UpdateBackendRequest,
|
||||
UserRecord,
|
||||
utcnow_iso,
|
||||
)
|
||||
from app.security import JwtSigner, generate_client_secret, hash_secret, verify_secret
|
||||
|
||||
DATA_DIR = Path(os.getenv("AUTHZ_DATA_DIR", Path(__file__).resolve().parents[1] / "data"))
|
||||
ISSUER = os.getenv("AUTHZ_ISSUER", "http://127.0.0.1:19090").rstrip("/")
|
||||
INTERNAL_TOKEN = os.getenv("AUTHZ_INTERNAL_TOKEN", "dev-internal-token")
|
||||
ACCESS_TOKEN_TTL_SECONDS = int(os.getenv("AUTHZ_ACCESS_TOKEN_TTL_SECONDS", "3600"))
|
||||
PRIVATE_KEY_PATH = Path(os.getenv("AUTHZ_PRIVATE_KEY_PATH", DATA_DIR / "signing_key.pem"))
|
||||
|
||||
store = JsonStore(DATA_DIR)
|
||||
signer = JwtSigner(PRIVATE_KEY_PATH, ISSUER, ACCESS_TOKEN_TTL_SECONDS)
|
||||
app = FastAPI(title="AuthZ Service", version="0.1.0")
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
lowered = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||
return lowered or "backend"
|
||||
|
||||
|
||||
def _next_backend_id(name: str) -> str:
|
||||
base = _slugify(name)
|
||||
existing = {item.backend_id for item in store.list_backends()}
|
||||
candidate = base
|
||||
index = 1
|
||||
while candidate in existing:
|
||||
index += 1
|
||||
candidate = f"{base}-{index:03d}"
|
||||
return candidate
|
||||
|
||||
|
||||
def _ensure_backend(backend_id: str) -> BackendRecord:
|
||||
backend = store.get_backend(backend_id)
|
||||
if backend is None:
|
||||
raise HTTPException(status_code=404, detail="Backend not found")
|
||||
return backend
|
||||
|
||||
|
||||
def _clean_optional(value: str | None) -> str | None:
|
||||
cleaned = (value or "").strip()
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _require_internal(authorization: str | None = Header(default=None)) -> None:
|
||||
token = ""
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
token = authorization[7:].strip()
|
||||
if token != INTERNAL_TOKEN:
|
||||
raise HTTPException(status_code=401, detail="Invalid internal token")
|
||||
|
||||
|
||||
def _allowed_scopes_for_audience(backend_id: str, audience: str) -> set[str]:
|
||||
permissions = store.get_permissions(backend_id)
|
||||
allowed: set[str] = set()
|
||||
|
||||
if audience.startswith("mcp:"):
|
||||
server_id = audience.split(":", 1)[1]
|
||||
mcp_config = permissions.get("mcp", {}).get(server_id, {})
|
||||
if not isinstance(mcp_config, dict) or not mcp_config.get("enabled"):
|
||||
return set()
|
||||
allowed.add("list_tools")
|
||||
for tool_name in mcp_config.get("tools", []) or []:
|
||||
if isinstance(tool_name, str) and tool_name.strip():
|
||||
allowed.add(f"tool:{tool_name.strip()}")
|
||||
return allowed
|
||||
|
||||
if audience.startswith("a2a:"):
|
||||
agent_id = audience.split(":", 1)[1]
|
||||
a2a_config = permissions.get("a2a", {})
|
||||
if not isinstance(a2a_config, dict) or not a2a_config.get("enabled"):
|
||||
return set()
|
||||
agents = a2a_config.get("agents", []) or []
|
||||
if agent_id in agents:
|
||||
allowed.add("run_task")
|
||||
return allowed
|
||||
|
||||
if audience.startswith("channel:"):
|
||||
channel_id = audience.split(":", 1)[1]
|
||||
channel_config = permissions.get("channels", {}).get(channel_id, {})
|
||||
if not isinstance(channel_config, dict) or not channel_config.get("enabled"):
|
||||
return set()
|
||||
# `invoke` is the baseline permission for a protected channel connector.
|
||||
allowed.add("invoke")
|
||||
for scope_name in channel_config.get("scopes", []) or []:
|
||||
if isinstance(scope_name, str) and scope_name.strip():
|
||||
allowed.add(scope_name.strip())
|
||||
return allowed
|
||||
|
||||
return set()
|
||||
|
||||
|
||||
def _issue_token(payload: OAuthTokenRequest) -> OAuthTokenResponse:
|
||||
if payload.grant_type != "client_credentials":
|
||||
raise HTTPException(status_code=400, detail="Unsupported grant_type")
|
||||
|
||||
credential = store.get_credential_by_client_id(payload.client_id)
|
||||
if credential is None or not verify_secret(payload.client_secret, credential.client_secret_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid client credentials")
|
||||
|
||||
backend = _ensure_backend(credential.backend_id)
|
||||
if backend.status != "active":
|
||||
raise HTTPException(status_code=403, detail="Backend is disabled")
|
||||
|
||||
allowed = _allowed_scopes_for_audience(credential.backend_id, payload.aud)
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Audience is not enabled for this backend")
|
||||
|
||||
requested = set(payload.scopes or [])
|
||||
if requested:
|
||||
if not requested.issubset(allowed):
|
||||
raise HTTPException(status_code=403, detail="Requested scopes exceed backend permissions")
|
||||
scopes = sorted(requested)
|
||||
else:
|
||||
scopes = sorted(allowed)
|
||||
|
||||
token, expires_in = signer.issue_access_token(
|
||||
credential=credential,
|
||||
audience=payload.aud,
|
||||
scopes=scopes,
|
||||
)
|
||||
return OAuthTokenResponse(access_token=token, expires_in=expires_in)
|
||||
|
||||
|
||||
def _parse_token_request_from_form(form: dict[str, Any]) -> OAuthTokenRequest:
|
||||
raw_scopes = form.get("scope", "")
|
||||
scopes = [item for item in str(raw_scopes).split() if item]
|
||||
return OAuthTokenRequest(
|
||||
grant_type=str(form.get("grant_type") or "client_credentials"),
|
||||
client_id=str(form.get("client_id") or ""),
|
||||
client_secret=str(form.get("client_secret") or ""),
|
||||
aud=str(form.get("aud") or form.get("resource") or ""),
|
||||
scopes=scopes,
|
||||
)
|
||||
|
||||
|
||||
def _create_backend(
|
||||
*,
|
||||
backend_id: str,
|
||||
name: str,
|
||||
base_url: str,
|
||||
frontend_base_url: str | None,
|
||||
) -> tuple[BackendRecord, str]:
|
||||
client_secret = generate_client_secret()
|
||||
backend = BackendRecord(
|
||||
backend_id=backend_id,
|
||||
name=name,
|
||||
base_url=base_url,
|
||||
frontend_base_url=frontend_base_url,
|
||||
)
|
||||
credential = BackendCredential(
|
||||
backend_id=backend_id,
|
||||
client_id=backend_id,
|
||||
client_secret_hash=hash_secret(client_secret),
|
||||
)
|
||||
store.save_backend(backend)
|
||||
store.save_credential(credential)
|
||||
return backend, client_secret
|
||||
|
||||
|
||||
def _update_backend_record(
|
||||
backend: BackendRecord,
|
||||
*,
|
||||
name: str | None = None,
|
||||
base_url: str | None = None,
|
||||
frontend_base_url: str | None = None,
|
||||
) -> BackendRecord:
|
||||
updated = backend.model_copy(deep=True)
|
||||
if name is not None:
|
||||
updated.name = name
|
||||
if base_url is not None:
|
||||
updated.base_url = base_url
|
||||
if frontend_base_url is not None:
|
||||
updated.frontend_base_url = frontend_base_url
|
||||
updated.updated_at = utcnow_iso()
|
||||
store.save_backend(updated)
|
||||
return updated
|
||||
|
||||
|
||||
def _upsert_user(
|
||||
*,
|
||||
username: str,
|
||||
email: str | None,
|
||||
default_backend_id: str | None,
|
||||
) -> UserRecord:
|
||||
existing = store.get_user(username)
|
||||
if existing is None:
|
||||
user = UserRecord(
|
||||
username=username,
|
||||
email=email,
|
||||
default_backend_id=default_backend_id,
|
||||
)
|
||||
else:
|
||||
user = existing.model_copy(deep=True)
|
||||
if email is not None:
|
||||
user.email = email
|
||||
if default_backend_id is not None:
|
||||
user.default_backend_id = default_backend_id
|
||||
user.updated_at = utcnow_iso()
|
||||
store.save_user(user)
|
||||
return user
|
||||
|
||||
|
||||
def _resolve_register_backend_payload(req: RegisterBackendRequest) -> tuple[str, str, str, str | None]:
|
||||
backend_name = req.name.strip() or "backend"
|
||||
backend_id = _clean_optional(req.backend_id)
|
||||
base_url = req.base_url.strip()
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail="base_url is required")
|
||||
frontend_base_url = _clean_optional(req.frontend_base_url)
|
||||
return backend_name, backend_id or _next_backend_id(backend_name), base_url, frontend_base_url
|
||||
|
||||
|
||||
def _resolve_register_user_backend_payload(req: RegisterUserRequest) -> tuple[str, str | None, str, str | None]:
|
||||
nested = req.backend
|
||||
backend_name = _clean_optional(
|
||||
(nested.name if nested else None)
|
||||
or req.backend_name
|
||||
or req.name
|
||||
) or req.username.strip()
|
||||
backend_id = _clean_optional((nested.backend_id if nested else None) or req.backend_id)
|
||||
base_url = _clean_optional(
|
||||
(nested.base_url if nested else None)
|
||||
or req.public_base_url
|
||||
or req.base_url
|
||||
)
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail="base_url is required")
|
||||
frontend_base_url = _clean_optional(
|
||||
(nested.frontend_base_url if nested else None)
|
||||
or req.frontend_base_url
|
||||
)
|
||||
return backend_name, backend_id, base_url, frontend_base_url
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/.well-known/oauth-authorization-server")
|
||||
async def oauth_metadata() -> dict[str, Any]:
|
||||
return {
|
||||
"issuer": ISSUER,
|
||||
"token_endpoint": f"{ISSUER}/oauth/token",
|
||||
"introspection_endpoint": f"{ISSUER}/oauth/introspect",
|
||||
"jwks_uri": f"{ISSUER}/.well-known/jwks.json",
|
||||
"grant_types_supported": ["client_credentials"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_post"],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/.well-known/jwks.json")
|
||||
async def jwks() -> dict[str, Any]:
|
||||
return signer.build_jwks()
|
||||
|
||||
|
||||
@app.post("/backends/register", response_model=RegisterBackendResponse)
|
||||
async def register_backend(req: RegisterBackendRequest) -> RegisterBackendResponse:
|
||||
backend_name, backend_id, base_url, frontend_base_url = _resolve_register_backend_payload(req)
|
||||
if store.get_backend(backend_id) is not None:
|
||||
raise HTTPException(status_code=409, detail="Backend already exists")
|
||||
backend, client_secret = _create_backend(
|
||||
backend_id=backend_id,
|
||||
name=backend_name,
|
||||
base_url=base_url,
|
||||
frontend_base_url=frontend_base_url,
|
||||
)
|
||||
return RegisterBackendResponse(
|
||||
backend_id=backend.backend_id,
|
||||
client_id=backend.backend_id,
|
||||
client_secret=client_secret,
|
||||
created_at=backend.created_at,
|
||||
frontend_base_url=backend.frontend_base_url,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/backends")
|
||||
async def list_backends() -> list[dict[str, Any]]:
|
||||
return [item.model_dump(mode="json") for item in store.list_backends()]
|
||||
|
||||
|
||||
@app.get("/backends/{backend_id}")
|
||||
async def get_backend(backend_id: str) -> dict[str, Any]:
|
||||
return _ensure_backend(backend_id).model_dump(mode="json")
|
||||
|
||||
|
||||
@app.put("/backends/{backend_id}")
|
||||
async def update_backend(backend_id: str, req: UpdateBackendRequest) -> dict[str, Any]:
|
||||
backend = _ensure_backend(backend_id)
|
||||
updated = _update_backend_record(
|
||||
backend,
|
||||
name=_clean_optional(req.name),
|
||||
base_url=_clean_optional(req.base_url),
|
||||
frontend_base_url=_clean_optional(req.frontend_base_url),
|
||||
)
|
||||
return updated.model_dump(mode="json")
|
||||
|
||||
|
||||
@app.post("/backends/{backend_id}/disable")
|
||||
async def disable_backend(backend_id: str) -> dict[str, Any]:
|
||||
backend = store.disable_backend(backend_id)
|
||||
if backend is None:
|
||||
raise HTTPException(status_code=404, detail="Backend not found")
|
||||
return backend.model_dump(mode="json")
|
||||
|
||||
|
||||
@app.post("/backends/{backend_id}/enable")
|
||||
async def enable_backend(backend_id: str) -> dict[str, Any]:
|
||||
backend = store.enable_backend(backend_id)
|
||||
if backend is None:
|
||||
raise HTTPException(status_code=404, detail="Backend not found")
|
||||
return backend.model_dump(mode="json")
|
||||
|
||||
|
||||
@app.post("/backends/{backend_id}/rotate-secret", response_model=RotateSecretResponse)
|
||||
async def rotate_secret(backend_id: str) -> RotateSecretResponse:
|
||||
backend = _ensure_backend(backend_id)
|
||||
client_secret = generate_client_secret()
|
||||
credential = BackendCredential(
|
||||
backend_id=backend_id,
|
||||
client_id=backend_id,
|
||||
client_secret_hash=hash_secret(client_secret),
|
||||
created_at=utcnow_iso(),
|
||||
rotated_at=utcnow_iso(),
|
||||
)
|
||||
store.save_credential(credential)
|
||||
return RotateSecretResponse(
|
||||
backend_id=backend_id,
|
||||
client_id=backend_id,
|
||||
client_secret=client_secret,
|
||||
rotated_at=credential.rotated_at or credential.created_at,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/backends/{backend_id}/permissions")
|
||||
async def get_permissions(backend_id: str) -> dict[str, Any]:
|
||||
_ensure_backend(backend_id)
|
||||
return store.get_permissions(backend_id)
|
||||
|
||||
|
||||
@app.post("/backends/{backend_id}/permissions")
|
||||
async def save_permissions(backend_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
_ensure_backend(backend_id)
|
||||
saved = store.save_permissions(backend_id, payload)
|
||||
store.touch_backend(backend_id)
|
||||
return saved
|
||||
|
||||
|
||||
@app.get("/backends/{backend_id}/settings/outlook")
|
||||
async def get_outlook_settings(backend_id: str) -> dict[str, Any]:
|
||||
_ensure_backend(backend_id)
|
||||
settings = store.get_outlook_settings(backend_id)
|
||||
if settings is None:
|
||||
return {"configured": False}
|
||||
return settings.masked_dict()
|
||||
|
||||
|
||||
@app.post("/backends/{backend_id}/settings/outlook")
|
||||
async def save_outlook_settings(backend_id: str, payload: OutlookSettings) -> dict[str, Any]:
|
||||
_ensure_backend(backend_id)
|
||||
existing = store.get_outlook_settings(backend_id)
|
||||
if not payload.password:
|
||||
if existing is None or not existing.password:
|
||||
raise HTTPException(status_code=400, detail="Password is required for initial Outlook setup")
|
||||
payload = payload.model_copy(update={"password": existing.password, "updated_at": utcnow_iso()})
|
||||
else:
|
||||
payload = payload.model_copy(update={"updated_at": utcnow_iso()})
|
||||
store.save_outlook_settings(backend_id, payload)
|
||||
store.touch_backend(backend_id)
|
||||
return payload.masked_dict()
|
||||
|
||||
|
||||
@app.delete("/backends/{backend_id}/settings/outlook")
|
||||
async def delete_outlook_settings(backend_id: str) -> dict[str, Any]:
|
||||
_ensure_backend(backend_id)
|
||||
removed = store.delete_outlook_settings(backend_id)
|
||||
if removed:
|
||||
store.touch_backend(backend_id)
|
||||
return {"ok": removed}
|
||||
|
||||
|
||||
@app.get("/backends/{backend_id}/settings/channels")
|
||||
async def list_channel_settings(backend_id: str) -> dict[str, Any]:
|
||||
_ensure_backend(backend_id)
|
||||
return {
|
||||
channel_id: settings.masked_dict()
|
||||
for channel_id, settings in store.list_channel_settings(backend_id).items()
|
||||
}
|
||||
|
||||
|
||||
@app.get("/backends/{backend_id}/settings/channels/{channel_id}")
|
||||
async def get_channel_settings(backend_id: str, channel_id: str) -> dict[str, Any]:
|
||||
_ensure_backend(backend_id)
|
||||
settings = store.get_channel_settings(backend_id, channel_id)
|
||||
if settings is None:
|
||||
return {"configured": False}
|
||||
return settings.masked_dict()
|
||||
|
||||
|
||||
@app.post("/backends/{backend_id}/settings/channels/{channel_id}")
|
||||
async def save_channel_settings(backend_id: str, channel_id: str, payload: ChannelSettings) -> dict[str, Any]:
|
||||
_ensure_backend(backend_id)
|
||||
existing = store.get_channel_settings(backend_id, channel_id)
|
||||
if not payload.secrets and existing is not None and existing.secrets:
|
||||
payload = payload.model_copy(update={"secrets": existing.secrets, "updated_at": utcnow_iso()})
|
||||
else:
|
||||
payload = payload.model_copy(update={"updated_at": utcnow_iso()})
|
||||
store.save_channel_settings(backend_id, channel_id, payload)
|
||||
store.touch_backend(backend_id)
|
||||
return payload.masked_dict()
|
||||
|
||||
|
||||
@app.delete("/backends/{backend_id}/settings/channels/{channel_id}")
|
||||
async def delete_channel_settings(backend_id: str, channel_id: str) -> dict[str, Any]:
|
||||
_ensure_backend(backend_id)
|
||||
removed = store.delete_channel_settings(backend_id, channel_id)
|
||||
if removed:
|
||||
store.touch_backend(backend_id)
|
||||
return {"ok": removed}
|
||||
|
||||
|
||||
@app.post("/oauth/register", response_model=RegisterUserResponse)
|
||||
async def oauth_register(req: RegisterUserRequest) -> RegisterUserResponse:
|
||||
username = req.username.strip()
|
||||
if not username:
|
||||
raise HTTPException(status_code=400, detail="username is required")
|
||||
if not req.password:
|
||||
raise HTTPException(status_code=400, detail="password is required")
|
||||
|
||||
backend_name, requested_backend_id, base_url, frontend_base_url = _resolve_register_user_backend_payload(req)
|
||||
backend_id = requested_backend_id or _next_backend_id(backend_name)
|
||||
existing_backend = store.get_backend(backend_id)
|
||||
|
||||
client_secret: str | None = None
|
||||
if existing_backend is None:
|
||||
backend, client_secret = _create_backend(
|
||||
backend_id=backend_id,
|
||||
name=backend_name,
|
||||
base_url=base_url,
|
||||
frontend_base_url=frontend_base_url,
|
||||
)
|
||||
else:
|
||||
backend = _update_backend_record(
|
||||
existing_backend,
|
||||
name=backend_name,
|
||||
base_url=base_url,
|
||||
frontend_base_url=frontend_base_url,
|
||||
)
|
||||
|
||||
user = _upsert_user(
|
||||
username=username,
|
||||
email=_clean_optional(req.email),
|
||||
default_backend_id=backend.backend_id,
|
||||
)
|
||||
return RegisterUserResponse(
|
||||
user=user,
|
||||
backend=RegisterUserBackendResult(
|
||||
backend_id=backend.backend_id,
|
||||
client_id=backend.backend_id,
|
||||
client_secret=client_secret,
|
||||
name=backend.name,
|
||||
base_url=backend.base_url,
|
||||
frontend_base_url=backend.frontend_base_url,
|
||||
status=backend.status,
|
||||
created_at=backend.created_at,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/internal/backends/{backend_id}/settings/outlook", dependencies=[Depends(_require_internal)])
|
||||
async def get_internal_outlook_settings(backend_id: str) -> dict[str, Any]:
|
||||
_ensure_backend(backend_id)
|
||||
settings = store.get_outlook_settings(backend_id)
|
||||
if settings is None:
|
||||
raise HTTPException(status_code=404, detail="Outlook settings not configured")
|
||||
return settings.model_dump(mode="json")
|
||||
|
||||
|
||||
@app.get("/internal/backends/{backend_id}/settings/channels", dependencies=[Depends(_require_internal)])
|
||||
async def list_internal_channel_settings(backend_id: str) -> dict[str, Any]:
|
||||
_ensure_backend(backend_id)
|
||||
return {
|
||||
channel_id: settings.model_dump(mode="json")
|
||||
for channel_id, settings in store.list_channel_settings(backend_id).items()
|
||||
}
|
||||
|
||||
|
||||
@app.get("/internal/backends/{backend_id}/settings/channels/{channel_id}", dependencies=[Depends(_require_internal)])
|
||||
async def get_internal_channel_settings(backend_id: str, channel_id: str) -> dict[str, Any]:
|
||||
_ensure_backend(backend_id)
|
||||
settings = store.get_channel_settings(backend_id, channel_id)
|
||||
if settings is None:
|
||||
raise HTTPException(status_code=404, detail="Channel settings not configured")
|
||||
return settings.model_dump(mode="json")
|
||||
|
||||
|
||||
@app.post("/oauth/token", response_model=OAuthTokenResponse)
|
||||
async def oauth_token(request: Request) -> OAuthTokenResponse:
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
payload = OAuthTokenRequest.model_validate(await request.json())
|
||||
else:
|
||||
form = await request.form()
|
||||
payload = _parse_token_request_from_form(dict(form))
|
||||
return _issue_token(payload)
|
||||
|
||||
|
||||
@app.post("/oauth/introspect", response_model=IntrospectResponse, dependencies=[Depends(_require_internal)])
|
||||
async def oauth_introspect(req: IntrospectRequest) -> IntrospectResponse:
|
||||
try:
|
||||
payload = signer.decode(req.token)
|
||||
except Exception:
|
||||
return IntrospectResponse(active=False)
|
||||
return IntrospectResponse(
|
||||
active=True,
|
||||
client_id=str(payload.get("client_id") or ""),
|
||||
backend_id=str(payload.get("backend_id") or ""),
|
||||
aud=str(payload.get("aud") or ""),
|
||||
scp=[str(item) for item in payload.get("scp", []) if str(item).strip()],
|
||||
exp=int(payload.get("exp")) if payload.get("exp") is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_error_handler(_: Request, exc: HTTPException):
|
||||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||
168
authz-service/src/app/models.py
Normal file
168
authz-service/src/app/models.py
Normal file
@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
def utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class BackendRecord(BaseModel):
|
||||
backend_id: str
|
||||
name: str
|
||||
base_url: str
|
||||
frontend_base_url: str | None = None
|
||||
status: str = "active"
|
||||
created_at: str = Field(default_factory=utcnow_iso)
|
||||
updated_at: str = Field(default_factory=utcnow_iso)
|
||||
|
||||
|
||||
class BackendCredential(BaseModel):
|
||||
backend_id: str
|
||||
client_id: str
|
||||
client_secret_hash: str
|
||||
created_at: str = Field(default_factory=utcnow_iso)
|
||||
rotated_at: str | None = None
|
||||
|
||||
|
||||
class UserRecord(BaseModel):
|
||||
username: str
|
||||
email: str | None = None
|
||||
default_backend_id: str | None = None
|
||||
created_at: str = Field(default_factory=utcnow_iso)
|
||||
updated_at: str = Field(default_factory=utcnow_iso)
|
||||
|
||||
|
||||
class PermissionsModel(BaseModel):
|
||||
permissions: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ChannelSettings(BaseModel):
|
||||
configured: bool = True
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
secrets: dict[str, Any] = Field(default_factory=dict)
|
||||
updated_at: str = Field(default_factory=utcnow_iso)
|
||||
|
||||
def masked_dict(self) -> dict[str, Any]:
|
||||
data = self.model_dump(mode="json")
|
||||
secrets = data.pop("secrets", {})
|
||||
data["secrets_masked"] = bool(secrets)
|
||||
data["secret_keys"] = sorted(
|
||||
str(key).strip()
|
||||
for key in (secrets.keys() if isinstance(secrets, dict) else [])
|
||||
if str(key).strip()
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
class OutlookSettings(BaseModel):
|
||||
configured: bool = True
|
||||
email: str
|
||||
username: str
|
||||
domain: str | None = None
|
||||
service_endpoint: str | None = None
|
||||
server: str | None = None
|
||||
autodiscover: bool = False
|
||||
default_timezone: str = "Asia/Shanghai"
|
||||
password: str
|
||||
updated_at: str = Field(default_factory=utcnow_iso)
|
||||
|
||||
def masked_dict(self) -> dict[str, Any]:
|
||||
data = self.model_dump()
|
||||
data.pop("password", None)
|
||||
data["password_masked"] = True
|
||||
return data
|
||||
|
||||
|
||||
class BackendRoutingPayload(BaseModel):
|
||||
name: str | None = None
|
||||
backend_id: str | None = None
|
||||
base_url: str | None = None
|
||||
frontend_base_url: str | None = None
|
||||
|
||||
|
||||
class RegisterBackendRequest(BaseModel):
|
||||
name: str
|
||||
base_url: str
|
||||
backend_id: str | None = None
|
||||
frontend_base_url: str | None = None
|
||||
|
||||
|
||||
class UpdateBackendRequest(BaseModel):
|
||||
name: str | None = None
|
||||
base_url: str | None = None
|
||||
frontend_base_url: str | None = None
|
||||
|
||||
|
||||
class RegisterBackendResponse(BaseModel):
|
||||
backend_id: str
|
||||
client_id: str
|
||||
client_secret: str
|
||||
created_at: str
|
||||
frontend_base_url: str | None = None
|
||||
|
||||
|
||||
class RegisterUserRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
email: str | None = None
|
||||
name: str | None = None
|
||||
backend_name: str | None = None
|
||||
backend_id: str | None = None
|
||||
base_url: str | None = None
|
||||
public_base_url: str | None = None
|
||||
frontend_base_url: str | None = None
|
||||
backend: BackendRoutingPayload | None = None
|
||||
|
||||
|
||||
class RegisterUserBackendResult(BaseModel):
|
||||
backend_id: str
|
||||
client_id: str
|
||||
client_secret: str | None = None
|
||||
name: str
|
||||
base_url: str
|
||||
frontend_base_url: str | None = None
|
||||
status: str = "active"
|
||||
created_at: str
|
||||
|
||||
|
||||
class RegisterUserResponse(BaseModel):
|
||||
user: UserRecord
|
||||
backend: RegisterUserBackendResult
|
||||
|
||||
|
||||
class RotateSecretResponse(BaseModel):
|
||||
backend_id: str
|
||||
client_id: str
|
||||
client_secret: str
|
||||
rotated_at: str
|
||||
|
||||
|
||||
class OAuthTokenRequest(BaseModel):
|
||||
grant_type: str = "client_credentials"
|
||||
client_id: str
|
||||
client_secret: str
|
||||
aud: str
|
||||
scopes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class OAuthTokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
class IntrospectRequest(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
class IntrospectResponse(BaseModel):
|
||||
active: bool
|
||||
client_id: str | None = None
|
||||
backend_id: str | None = None
|
||||
aud: str | None = None
|
||||
scp: list[str] = Field(default_factory=list)
|
||||
exp: int | None = None
|
||||
121
authz-service/src/app/security.py
Normal file
121
authz-service/src/app/security.py
Normal file
@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from app.models import BackendCredential
|
||||
|
||||
|
||||
def hash_secret(secret: str) -> str:
|
||||
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def verify_secret(secret: str, digest: str) -> bool:
|
||||
return secrets.compare_digest(hash_secret(secret), digest)
|
||||
|
||||
|
||||
def generate_client_secret() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
class JwtSigner:
|
||||
def __init__(self, key_path: Path, issuer: str, ttl_seconds: int):
|
||||
self.key_path = key_path
|
||||
self.issuer = issuer.rstrip("/")
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self._private_key = self._load_or_create_private_key()
|
||||
self._public_key = self._private_key.public_key()
|
||||
self._kid = self._compute_kid()
|
||||
|
||||
def _load_or_create_private_key(self):
|
||||
self.key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if self.key_path.exists():
|
||||
return serialization.load_pem_private_key(
|
||||
self.key_path.read_bytes(),
|
||||
password=None,
|
||||
)
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
self.key_path.write_bytes(pem)
|
||||
return private_key
|
||||
|
||||
def _compute_kid(self) -> str:
|
||||
der = self._public_key.public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
return hashlib.sha256(der).hexdigest()[:16]
|
||||
|
||||
def build_jwks(self) -> dict[str, Any]:
|
||||
public_numbers = self._public_key.public_numbers()
|
||||
|
||||
def _b64url(value: int) -> str:
|
||||
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
return {
|
||||
"keys": [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"kid": self._kid,
|
||||
"n": _b64url(public_numbers.n),
|
||||
"e": _b64url(public_numbers.e),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def issue_access_token(
|
||||
self,
|
||||
*,
|
||||
credential: BackendCredential,
|
||||
audience: str,
|
||||
scopes: list[str],
|
||||
) -> tuple[str, int]:
|
||||
now = datetime.now(timezone.utc)
|
||||
exp = now + timedelta(seconds=self.ttl_seconds)
|
||||
payload = {
|
||||
"iss": self.issuer,
|
||||
"sub": f"backend:{credential.backend_id}",
|
||||
"client_id": credential.client_id,
|
||||
"backend_id": credential.backend_id,
|
||||
"aud": audience,
|
||||
"scp": scopes,
|
||||
"iat": int(now.timestamp()),
|
||||
"nbf": int(now.timestamp()),
|
||||
"exp": int(exp.timestamp()),
|
||||
"jti": str(uuid4()),
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
self._private_key,
|
||||
algorithm="RS256",
|
||||
headers={"kid": self._kid},
|
||||
)
|
||||
return token, self.ttl_seconds
|
||||
|
||||
def decode(self, token: str) -> dict[str, Any]:
|
||||
return jwt.decode(
|
||||
token,
|
||||
self._public_key,
|
||||
algorithms=["RS256"],
|
||||
issuer=self.issuer,
|
||||
options={
|
||||
"require": ["iss", "sub", "aud", "exp", "iat", "jti"],
|
||||
"verify_aud": False,
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user