diff --git a/app/api/deps/portal.py b/app/api/deps/portal.py new file mode 100644 index 0000000..87f66ef --- /dev/null +++ b/app/api/deps/portal.py @@ -0,0 +1,90 @@ +"""Portal-facing auth: validate a GhostAuth bearer + resolve the local user. + +Used only by the ghostboard-facing widget endpoints. The token is a GhostAuth +access token forwarded by the portal on the user's behalf; we verify it, require +the `ghostsuite:ghostmon` entitlement (carried either as an OAuth scope or in the +groups claim — same union ghostboard applies), and map the token subject to the +local GhostMonitor user via `oidc_subject`. + +A valid token whose subject has never logged into GhostMonitor resolves to +`user=None`; widget endpoints then return an empty payload and the portal tile +quietly removes itself. No just-in-time provisioning here — accounts are created +through the normal OIDC login, not by a background widget fetch. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Annotated, Any + +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy import select + +from app.api.deps.db import DBSession +from app.core.models.user import User +from app.core.security.resource_server import ( + ResourceServerNotConfiguredError, + TokenValidationError, + get_ghostauth_validator, +) + +_ENTITLEMENT = "ghostsuite:ghostmon" + + +def _claim_values(value: object) -> list[str]: + if isinstance(value, list): + return [str(v) for v in value] + if value in (None, ""): + return [] + return [str(value)] + + +@dataclass(frozen=True) +class PortalContext: + subject: str + user: User | None + claims: dict[str, Any] + + +async def require_portal_token(request: Request, session: DBSession) -> PortalContext: + header = request.headers.get("authorization", "") + scheme, _, token = header.partition(" ") + if scheme.lower() != "bearer" or not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="missing bearer token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + validator = get_ghostauth_validator() + except ResourceServerNotConfiguredError as exc: + # OIDC isn't configured on this deployment → portal integration is off. + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="portal integration not configured", + ) from exc + + try: + claims = await validator.verify(token) + except TokenValidationError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid token", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + + entitlements = set(str(claims.get("scope", "")).split()) | set( + _claim_values(claims.get("groups")) + ) + if _ENTITLEMENT not in entitlements: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not entitled") + + subject = str(claims.get("sub", "")) + user = ( + await session.scalar(select(User).where(User.oidc_subject == subject)) if subject else None + ) + return PortalContext(subject=subject, user=user, claims=claims) + + +PortalToken = Annotated[PortalContext, Depends(require_portal_token)] diff --git a/app/api/main.py b/app/api/main.py index 93496aa..72aff95 100644 --- a/app/api/main.py +++ b/app/api/main.py @@ -11,6 +11,7 @@ from app.api.ratelimit import add_rate_limit from app.api.routes import api_router, health_router from app.api.routes.web import router as web_router +from app.api.routes.wellknown import router as wellknown_router from app.core.config import get_settings from app.tasks.scheduler import build_scheduler @@ -69,6 +70,7 @@ def create_app(*, lifespan: Lifespan = scheduler_lifespan) -> FastAPI: name="static", ) app.include_router(health_router) + app.include_router(wellknown_router) app.include_router(api_router) app.include_router(web_router) return app diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py index 7f90875..a4021ca 100644 --- a/app/api/routes/__init__.py +++ b/app/api/routes/__init__.py @@ -13,6 +13,7 @@ from app.api.routes.problems import router as problems_router from app.api.routes.templates import router as templates_router from app.api.routes.triggers import router as triggers_router +from app.api.routes.widgets import router as widgets_router api_router = APIRouter(prefix="/api") api_router.include_router(auth_router) @@ -27,5 +28,6 @@ api_router.include_router(ingest_router) api_router.include_router(item_triggers_router) api_router.include_router(templates_router) +api_router.include_router(widgets_router) __all__ = ["api_router", "health_router"] diff --git a/app/api/routes/wellknown.py b/app/api/routes/wellknown.py new file mode 100644 index 0000000..7171f64 --- /dev/null +++ b/app/api/routes/wellknown.py @@ -0,0 +1,22 @@ +"""Public `/.well-known/ghostapp.yaml` — the portal discovery document. + +Served without authentication: ghostboard fetches it at startup with no bearer +to hydrate its registry. It carries only presentation metadata (no secrets, no +per-user data), so anonymous access is fine. +""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter +from fastapi.responses import FileResponse + +router = APIRouter() + +_MANIFEST = Path(__file__).resolve().parents[3] / "static" / "ghostapp.yaml" + + +@router.get("/.well-known/ghostapp.yaml", include_in_schema=False) +async def ghostapp_manifest() -> FileResponse: + return FileResponse(_MANIFEST, media_type="application/yaml") diff --git a/app/api/routes/widgets.py b/app/api/routes/widgets.py new file mode 100644 index 0000000..09391ed --- /dev/null +++ b/app/api/routes/widgets.py @@ -0,0 +1,70 @@ +"""Portal bento widgets — the `data_url` endpoints ghostboard renders. + +Each returns the small JSON shape ghostboard's widget contract expects for its +`kind` (stat / chart). Scoped to the token subject's own monitors; an unknown +subject yields an empty payload so the portal tile removes itself. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter +from sqlalchemy import select + +from app.api.deps.db import DBSession +from app.api.deps.portal import PortalToken +from app.core.models.monitor import Monitor, MonitorStatus +from app.core.models.monitor_result import MonitorResult + +router = APIRouter(prefix="/v1/widgets", tags=["portal-widgets"]) + +_LATENCY_POINTS = 30 + + +@router.get("/uptime") +async def uptime_widget(portal: PortalToken, session: DBSession) -> dict[str, Any]: + """Stat widget: how many of the user's monitors are currently up.""" + if portal.user is None: + return {"value": "—", "label": "no monitors", "trend": "flat"} + + stmt = select(Monitor.status).where( + Monitor.owner_id == portal.user.id, + Monitor.status != MonitorStatus.PAUSED, + ) + statuses = list(await session.scalars(stmt)) + total = len(statuses) + up = sum(1 for s in statuses if s == MonitorStatus.UP) + + if total == 0: + trend = "flat" + elif up == total: + trend = "up" + else: + trend = "down" + return {"value": f"{up}/{total}", "label": "services up", "trend": trend} + + +@router.get("/latency") +async def latency_widget(portal: PortalToken, session: DBSession) -> dict[str, Any]: + """Chart widget: recent probe latencies across the user's monitors.""" + empty: dict[str, Any] = {"points": [0], "variant": "line", "unit": "ms"} + if portal.user is None: + return empty + + stmt = ( + select(MonitorResult.latency_ms) + .join(Monitor, Monitor.id == MonitorResult.monitor_id) + .where( + Monitor.owner_id == portal.user.id, + MonitorResult.latency_ms.is_not(None), + ) + .order_by(MonitorResult.checked_at.desc()) + .limit(_LATENCY_POINTS) + ) + latencies = [v for v in await session.scalars(stmt) if v is not None] + if not latencies: + return empty + # DB gave newest-first; the sparkline reads left-to-right oldest-to-newest. + points = [float(v) for v in reversed(latencies)] + return {"points": points, "variant": "line", "unit": "ms"} diff --git a/app/core/config.py b/app/core/config.py index cbe2053..b5d558b 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -28,6 +28,9 @@ class Settings(BaseSettings): oidc_client_id: str | None = None oidc_client_secret: str | None = None oidc_redirect_uri: str | None = None + # Audience the portal resource-server accepts on GhostAuth access tokens. + # Defaults to `oidc_client_id` when unset (the common single-audience case). + oidc_audience: str | None = None argon2_time_cost: int = 3 argon2_memory_cost: int = 65536 diff --git a/app/core/security/resource_server.py b/app/core/security/resource_server.py new file mode 100644 index 0000000..6347ec6 --- /dev/null +++ b/app/core/security/resource_server.py @@ -0,0 +1,119 @@ +"""GhostAuth access-token validation — the resource-server side of the suite. + +GhostMonitor is an OIDC *client* for interactive login (see `oidc.py`). This +module is the complementary *resource-server* piece: it validates access tokens +minted by GhostAuth so the ghostboard portal can call GhostMonitor's widget +endpoints on a user's behalf, forwarding that user's `Authorization: Bearer`. + +It is deliberately narrow — mounted ONLY on the portal-facing routes +(`/.well-known/ghostapp.yaml` stays public; the `data_url` widgets require a +token). The app's own UI/API keep using local sessions (`deps/auth.py`). + +Validation mirrors ghostboard: signature against the IdP JWKS (RS256/ES256/ +**EdDSA** — `alg:none`/HMAC rejected), plus issuer + audience claim checks. +""" + +from __future__ import annotations + +import time +from typing import Any + +import httpx +from joserfc import jwt +from joserfc.errors import JoseError +from joserfc.jwk import KeySet + +from app.core.config import get_settings + +# Asymmetric algorithms accepted for a GhostAuth token signature. GhostAuth +# signs per-realm with RS256/ES256/EdDSA; pinning the allow-list avoids +# algorithm-confusion (and joserfc rejects `alg:none` by construction). +_ACCEPTED_ALGORITHMS = ["RS256", "ES256", "EdDSA"] +_JWKS_TTL_SECONDS = 3600 + + +class TokenValidationError(Exception): + """The presented bearer token is not a valid GhostAuth access token.""" + + +class ResourceServerNotConfiguredError(RuntimeError): + """Portal token validation was requested but issuer/audience are unset.""" + + +class GhostAuthValidator: + """Validates GhostAuth access tokens, caching discovery + JWKS.""" + + def __init__(self, *, issuer: str, audience: str) -> None: + self._issuer = issuer.rstrip("/") + self._audience = audience + self._jwks_uri: str | None = None + self._jwks: KeySet | None = None + self._jwks_expiry = 0.0 + + async def _load_jwks(self) -> KeySet: + now = time.time() + if self._jwks is not None and now < self._jwks_expiry: + return self._jwks + async with httpx.AsyncClient(timeout=5.0) as client: + if self._jwks_uri is None: + meta = await client.get(f"{self._issuer}/.well-known/openid-configuration") + meta.raise_for_status() + self._jwks_uri = meta.json()["jwks_uri"] + resp = await client.get(self._jwks_uri) + resp.raise_for_status() + self._jwks = KeySet.import_key_set(resp.json()) + self._jwks_expiry = now + _JWKS_TTL_SECONDS + return self._jwks + + async def verify(self, token: str) -> dict[str, Any]: + """Return the validated claims, or raise `TokenValidationError`.""" + try: + key_set = await self._load_jwks() + except (httpx.HTTPError, KeyError) as exc: + raise TokenValidationError("cannot load GhostAuth JWKS") from exc + + try: + decoded = jwt.decode(token, key_set, algorithms=_ACCEPTED_ALGORITHMS) + except (JoseError, ValueError) as exc: + raise TokenValidationError(f"bad token signature: {exc}") from exc + + claims = dict(decoded.claims) + registry = jwt.JWTClaimsRegistry( + iss={"essential": True, "value": self._issuer}, + aud={"essential": True, "value": self._audience}, + exp={"essential": True}, + ) + try: + registry.validate(claims) + except JoseError as exc: + raise TokenValidationError(f"claim validation failed: {exc}") from exc + return claims + + +_validator: GhostAuthValidator | None = None + + +def get_ghostauth_validator() -> GhostAuthValidator: + """Build (once) the validator from settings. + + Audience defaults to the OIDC client id — the common case where GhostAuth + mints access tokens whose `aud` is the requesting client. Override with + `OIDC_AUDIENCE` when the realm issues a distinct resource identifier. + """ + global _validator + if _validator is not None: + return _validator + settings = get_settings() + audience = settings.oidc_audience or settings.oidc_client_id + if not settings.oidc_issuer or not audience: + raise ResourceServerNotConfiguredError( + "portal token validation needs OIDC_ISSUER and OIDC_AUDIENCE/OIDC_CLIENT_ID" + ) + _validator = GhostAuthValidator(issuer=settings.oidc_issuer, audience=audience) + return _validator + + +def reset_validator_cache() -> None: + """Test hook: drop the cached validator so settings changes take effect.""" + global _validator + _validator = None diff --git a/pyproject.toml b/pyproject.toml index aff2f19..14228dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "aiosmtplib>=5.1.0", "croniter>=6.2.2", "pysnmp>=7.1.27", + "joserfc>=1.7.2", ] [project.optional-dependencies] diff --git a/static/ghostapp.yaml b/static/ghostapp.yaml new file mode 100644 index 0000000..3799b42 --- /dev/null +++ b/static/ghostapp.yaml @@ -0,0 +1,40 @@ +# GhostApp manifest — published for the ghostboard portal. +# ghostboard re-validates this whole document, then overlays only its +# presentation fields (name, description, version, switcher, widgets) onto its +# local stub. Identity/base_url/auth stay pinned by ghostboard, so the values +# here are informational for those — `icon` is intentionally omitted so the +# portal keeps its own same-origin icon (its CSP blocks cross-origin images). +apiVersion: ghostsuite.io/v1 +kind: GhostApp +metadata: + id: ghostmon + name: Ghostmon + description: Uptime & infrastructure monitoring + version: 0.1.0 +spec: + base_url: https://ghostmon.ghost.local + health_endpoint: /healthz + auth: + required_scopes: + - ghostsuite:ghostmon + switcher: + visible: true + order: 10 + category: monitoring + widgets: + - id: services_up + kind: stat + title: Services up + size: small + data_url: /api/v1/widgets/uptime + refresh_interval: 60 + required_scopes: + - ghostsuite:ghostmon + - id: response_time + kind: chart + title: Response time + size: medium + data_url: /api/v1/widgets/latency + refresh_interval: 120 + required_scopes: + - ghostsuite:ghostmon diff --git a/tests/test_portal_widgets.py b/tests/test_portal_widgets.py new file mode 100644 index 0000000..28d1adb --- /dev/null +++ b/tests/test_portal_widgets.py @@ -0,0 +1,168 @@ +"""Portal resource-server + bento widget endpoints. + +Two layers: +- `GhostAuthValidator` in isolation — real RS256/Ed25519 tokens verified against + an injected JWKS (no network), plus aud/iss/exp rejection. +- The `/api/v1/widgets/*` routes end to end over the ASGI app against a live + Postgres, with the validator monkeypatched to trust our test keys — proving + the token gate, the entitlement gate, and the per-user scoping/SQL. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from joserfc import jwt +from joserfc.jwk import KeySet, OKPKey, RSAKey + +from app.api.deps import portal as portal_dep +from app.core.models.monitor import Monitor, MonitorStatus, MonitorType +from app.core.models.monitor_result import MonitorResult, ProbeStatus +from app.core.security.resource_server import GhostAuthValidator, TokenValidationError +from app.core.services.user_service import UserService + +ISS = "https://idp.ghost.local" +AUD = "ghostmon" +ENTITLEMENT = "ghostsuite:ghostmon" + +_RSA = RSAKey.generate_key(2048, parameters={"kid": "r1"}, private=True) +_ED = OKPKey.generate_key("Ed25519", parameters={"kid": "e1"}, private=True) +_PUBLIC_JWKS = KeySet([_RSA, _ED]).as_dict(private=False) + + +def _token(*, alg: str = "RS256", kid: str = "r1", key: Any = _RSA, **overrides: Any) -> str: + claims: dict[str, Any] = { + "iss": ISS, + "aud": AUD, + "sub": "portal-user", + "exp": 9_999_999_999, + "iat": 1, + "scope": f"openid {ENTITLEMENT}", + **overrides, + } + return jwt.encode({"alg": alg, "kid": kid}, claims, key, algorithms=[alg]) + + +def _validator() -> GhostAuthValidator: + v = GhostAuthValidator(issuer=ISS, audience=AUD) + # Inject the JWKS so verify() does no network I/O. + v._jwks = KeySet.import_key_set(_PUBLIC_JWKS) + v._jwks_expiry = 9_999_999_999.0 + return v + + +# ── Validator unit tests (no DB, no network) ─────────────────────────────── + + +async def test_validator_accepts_rs256() -> None: + claims = await _validator().verify(_token(alg="RS256", kid="r1", key=_RSA)) + assert claims["sub"] == "portal-user" + + +async def test_validator_accepts_eddsa() -> None: + # GhostAuth realms may sign with Ed25519 — must validate (python-jose can't). + claims = await _validator().verify(_token(alg="EdDSA", kid="e1", key=_ED, sub="ed")) + assert claims["sub"] == "ed" + + +async def test_validator_rejects_wrong_audience() -> None: + with pytest.raises(TokenValidationError): + await _validator().verify(_token(aud="someone-else")) + + +async def test_validator_rejects_wrong_issuer() -> None: + with pytest.raises(TokenValidationError): + await _validator().verify(_token(iss="https://evil")) + + +async def test_validator_rejects_expired() -> None: + with pytest.raises(TokenValidationError): + await _validator().verify(_token(exp=1)) + + +async def test_validator_rejects_garbage() -> None: + with pytest.raises(TokenValidationError): + await _validator().verify("not-a-jwt") + + +# ── Route integration tests (live Postgres) ──────────────────────────────── + + +@pytest.fixture(autouse=True) +def _trust_test_keys(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(portal_dep, "get_ghostauth_validator", _validator) + + +def _bearer(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +async def _seed_monitors(session: Any, owner_id: Any, statuses: list[MonitorStatus]) -> list[Any]: + monitors = [] + for i, st in enumerate(statuses): + m = Monitor( + name=f"mon-{i}", + type=MonitorType.HTTP, + url="https://example.com", + status=st, + owner_id=owner_id, + ) + session.add(m) + monitors.append(m) + await session.commit() + for m in monitors: + await session.refresh(m) + return monitors + + +async def test_wellknown_manifest_is_public(client: Any) -> None: + resp = await client.get("/.well-known/ghostapp.yaml") + assert resp.status_code == 200 + assert "id: ghostmon" in resp.text + assert "ghostsuite:ghostmon" in resp.text + + +async def test_uptime_widget_counts_user_monitors(client: Any, session: Any) -> None: + user = await UserService(session).upsert_oidc("portal-user", "u@ghost.local", None) + await _seed_monitors( + session, + user.id, + [MonitorStatus.UP, MonitorStatus.UP, MonitorStatus.DOWN, MonitorStatus.PAUSED], + ) + resp = await client.get("/api/v1/widgets/uptime", headers=_bearer(_token())) + assert resp.status_code == 200 + body = resp.json() + assert body["value"] == "2/3" # paused excluded from the total + assert body["trend"] == "down" + + +async def test_latency_widget_returns_points(client: Any, session: Any) -> None: + user = await UserService(session).upsert_oidc("portal-user", "u@ghost.local", None) + (monitor,) = await _seed_monitors(session, user.id, [MonitorStatus.UP]) + for ms in (120, 90, 150): + session.add(MonitorResult(monitor_id=monitor.id, status=ProbeStatus.UP, latency_ms=ms)) + await session.commit() + resp = await client.get("/api/v1/widgets/latency", headers=_bearer(_token())) + assert resp.status_code == 200 + body = resp.json() + assert body["variant"] == "line" and body["unit"] == "ms" + assert sorted(body["points"]) == [90.0, 120.0, 150.0] + + +async def test_widget_requires_a_token(client: Any) -> None: + resp = await client.get("/api/v1/widgets/uptime") + assert resp.status_code == 401 + + +async def test_widget_requires_entitlement(client: Any) -> None: + # Valid token but missing ghostsuite:ghostmon in scope/groups. + resp = await client.get("/api/v1/widgets/uptime", headers=_bearer(_token(scope="openid"))) + assert resp.status_code == 403 + + +async def test_widget_unknown_subject_is_empty(client: Any) -> None: + # Valid, entitled token whose subject was never linked to a local user. + resp = await client.get("/api/v1/widgets/uptime", headers=_bearer(_token(sub="ghost"))) + assert resp.status_code == 200 + assert resp.json()["value"] == "—" diff --git a/uv.lock b/uv.lock index e7be3bb..a7741b3 100644 --- a/uv.lock +++ b/uv.lock @@ -502,6 +502,7 @@ dependencies = [ { name = "httpx" }, { name = "itsdangerous" }, { name = "jinja2" }, + { name = "joserfc" }, { name = "prometheus-client" }, { name = "prometheus-fastapi-instrumentator" }, { name = "pydantic", extra = ["email"] }, @@ -541,6 +542,7 @@ requires-dist = [ { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.0" }, { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "jinja2", specifier = ">=3.1.4" }, + { name = "joserfc", specifier = ">=1.7.2" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13.0" }, { name = "prometheus-client", specifier = ">=0.21.0" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=7.0.0" },