Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions app/api/deps/portal.py
Original file line number Diff line number Diff line change
@@ -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)]
2 changes: 2 additions & 0 deletions app/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/api/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"]
22 changes: 22 additions & 0 deletions app/api/routes/wellknown.py
Original file line number Diff line number Diff line change
@@ -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")
70 changes: 70 additions & 0 deletions app/api/routes/widgets.py
Original file line number Diff line number Diff line change
@@ -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"}
3 changes: 3 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions app/core/security/resource_server.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies = [
"aiosmtplib>=5.1.0",
"croniter>=6.2.2",
"pysnmp>=7.1.27",
"joserfc>=1.7.2",
]

[project.optional-dependencies]
Expand Down
40 changes: 40 additions & 0 deletions static/ghostapp.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading