Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
175 lines
7.6 KiB
Python
175 lines
7.6 KiB
Python
"""Configuration for the NestQuest API service.
|
|
|
|
The API reads its configuration knobs from environment variables:
|
|
|
|
- ``NESTQUEST_DB_PATH`` — the SQLite database file. The API opens this
|
|
file on startup (creating it if absent) and applies migrations.
|
|
- ``NESTQUEST_PANEL_TOKEN`` — the static panel-plane service token
|
|
(D-012): the credential ``/api/v1/panel/*`` routes require via
|
|
:func:`api.dependencies.require_panel_token`. It is stored outside
|
|
the repo and handed to the service through the environment.
|
|
- ``NESTQUEST_OIDC_ISSUER`` — the Authentik OIDC issuer URL whose
|
|
``iss`` claim admin tokens must carry (D-012 admin plane).
|
|
- ``NESTQUEST_OIDC_AUDIENCE`` — the OIDC client id the admin tokens
|
|
must carry as their ``aud`` claim (the API service's client id).
|
|
- ``NESTQUEST_OIDC_JWKS_URL`` — the Authentik endpoint serving the
|
|
signing keys (the JWKS document) the admin tokens' signatures are
|
|
verified against. Cached per app (see :class:`api.auth.JwksCache`).
|
|
|
|
Unlike a dev convenience default, a missing or empty value is a
|
|
configuration ERROR, not a silent fallback: a default database path
|
|
would silently drop data at reboot, a default (or empty) service token
|
|
would either authenticate everyone or lock the panel plane out for
|
|
good, and a default (or empty) OIDC setting would silently leave the
|
|
admin plane trusting an issuer that was never configured. All five
|
|
variables are therefore REQUIRED — ``ApiConfig.from_env`` RAISES when
|
|
any of them is unset or empty. This mirrors the integration's
|
|
"explicit settings object" discipline (Feature 15's ``core.settings``):
|
|
the API never reads HA config; callers pass the resolved values into
|
|
the wiring.
|
|
|
|
Serving: the app is built with the factory ``api.app:create_app`` — run
|
|
it with ``uvicorn --factory api.app:create_app`` so the factory reads
|
|
the environment on each worker startup rather than at import time.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass
|
|
|
|
#: Environment variable holding the SQLite file path. The API opens
|
|
#: this file on startup (creating it if absent) and applies migrations.
|
|
#: REQUIRED — unset or empty raises :class:`ConfigError` from
|
|
#: :meth:`ApiConfig.from_env`.
|
|
DB_PATH_ENV = "NESTQUEST_DB_PATH"
|
|
|
|
#: Environment variable holding the static panel-plane service token
|
|
#: (D-012), checked by :func:`api.dependencies.require_panel_token`.
|
|
#: REQUIRED — unset or empty raises :class:`ConfigError` from
|
|
#: :meth:`ApiConfig.from_env`; there is no silent default, because an
|
|
#: empty configured token would either authenticate every caller or
|
|
#: lock the panel plane out permanently.
|
|
PANEL_TOKEN_ENV = "NESTQUEST_PANEL_TOKEN"
|
|
|
|
#: Environment variable holding the Authentik OIDC issuer URL (D-012
|
|
#: admin plane): every admin JWT's ``iss`` claim must equal it. Checked
|
|
#: by :func:`api.auth.require_admin_jwt`. REQUIRED — unset or empty
|
|
#: raises :class:`ConfigError` from :meth:`ApiConfig.from_env`; a default
|
|
#: issuer would silently make the admin plane trust tokens from an
|
|
#: issuer nobody configured.
|
|
OIDC_ISSUER_ENV = "NESTQUEST_OIDC_ISSUER"
|
|
|
|
#: Environment variable holding the OIDC audience (the API service's
|
|
#: client id at Authentik): every admin JWT's ``aud`` claim must carry
|
|
#: it. Checked by :func:`api.auth.require_admin_jwt`. REQUIRED — unset
|
|
#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`.
|
|
OIDC_AUDIENCE_ENV = "NESTQUEST_OIDC_AUDIENCE"
|
|
|
|
#: Environment variable holding the Authentik JWKS URL: the endpoint
|
|
#: serving the signing keys admin JWT signatures are verified against.
|
|
#: Fetched and cached by :class:`api.auth.JwksCache`. REQUIRED — unset
|
|
#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`;
|
|
#: without the provider's keys no token can be verified, so a silent
|
|
#: default would only move the failure to request time.
|
|
OIDC_JWKS_URL_ENV = "NESTQUEST_OIDC_JWKS_URL"
|
|
|
|
|
|
class ConfigError(RuntimeError):
|
|
"""Raised when required API configuration is missing or invalid."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ApiConfig:
|
|
"""Resolved API configuration.
|
|
|
|
Every field is required (no defaults): ``from_env`` rejects an
|
|
unset or empty value for each, and a caller building the dataclass
|
|
directly must pass explicit values — the token check and the JWT
|
|
checks themselves fail closed on empty configured values, but
|
|
expecting a caller to discover that at request time is a wiring
|
|
mistake, so every field is mandatory. Kept as a dataclass so later
|
|
tasks (SSE, settings persistence) can extend it without touching
|
|
the call sites that read ``db_path``.
|
|
"""
|
|
|
|
db_path: str
|
|
panel_token: str
|
|
#: The Authentik OIDC issuer URL admin tokens' ``iss`` must equal.
|
|
oidc_issuer: str
|
|
#: The OIDC audience (the API service's client id) admin tokens'
|
|
#: ``aud`` must carry.
|
|
oidc_audience: str
|
|
#: The Authentik JWKS URL the signing keys are fetched from.
|
|
oidc_jwks_url: str
|
|
|
|
@classmethod
|
|
def from_env(cls, env: dict[str, str] | None = None) -> "ApiConfig":
|
|
"""Build configuration from the process environment.
|
|
|
|
``env`` defaults to ``os.environ``; tests pass an explicit dict
|
|
so they are hermetic (no monkeypatch of the global environ).
|
|
|
|
Raises :class:`ConfigError` when ANY of the five variables is
|
|
unset or empty/whitespace-only — there is no silent default:
|
|
|
|
- ``NESTQUEST_DB_PATH``: a default in the temp directory would
|
|
silently lose data at reboot;
|
|
- ``NESTQUEST_PANEL_TOKEN``: a default token is never invented —
|
|
the panel plane is closed until a real one is configured;
|
|
- the three OIDC variables: an unconfigured issuer/audience/JWKS
|
|
URL must not silently leave the admin plane open (a default
|
|
JWKS URL or issuer would point at a provider nobody set up).
|
|
"""
|
|
source = env if env is not None else os.environ
|
|
|
|
def require(env_var: str, reason: str) -> str:
|
|
"""Return the stripped env value or raise ConfigError."""
|
|
value = source.get(env_var, "").strip()
|
|
if not value:
|
|
raise ConfigError(f"{env_var} {reason}")
|
|
return value
|
|
|
|
return cls(
|
|
db_path=require(
|
|
DB_PATH_ENV,
|
|
"must be set to a writable SQLite file path (it is unset "
|
|
"or empty — the API will not fall back to a temp directory "
|
|
"and silently lose data)",
|
|
),
|
|
panel_token=require(
|
|
PANEL_TOKEN_ENV,
|
|
"must be set to the panel service token (it is unset or "
|
|
"empty — the API will not start without one, and no "
|
|
"default token is invented)",
|
|
),
|
|
oidc_issuer=require(
|
|
OIDC_ISSUER_ENV,
|
|
"must be set to the Authentik OIDC issuer URL (it is "
|
|
"unset or empty — the admin plane will not trust tokens "
|
|
"from an issuer nobody configured)",
|
|
),
|
|
oidc_audience=require(
|
|
OIDC_AUDIENCE_ENV,
|
|
"must be set to the OIDC audience the admin tokens carry "
|
|
"(it is unset or empty — the admin plane will not accept "
|
|
"tokens without a configured audience)",
|
|
),
|
|
oidc_jwks_url=require(
|
|
OIDC_JWKS_URL_ENV,
|
|
"must be set to the Authentik JWKS URL (it is unset or "
|
|
"empty — admin token signatures cannot be verified "
|
|
"without the provider's signing keys)",
|
|
),
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"ApiConfig",
|
|
"ConfigError",
|
|
"DB_PATH_ENV",
|
|
"OIDC_AUDIENCE_ENV",
|
|
"OIDC_ISSUER_ENV",
|
|
"OIDC_JWKS_URL_ENV",
|
|
"PANEL_TOKEN_ENV",
|
|
]
|