Implement KCA: web reimplementation of XCA PKI workflows
- kaya backend with OIDC sessions, API tokens, Postgres/Tortoise models - AES-256-GCM private key storage and full certificate/CSR/CRL domain logic - Sycamore/WASM SPA for vaults, keys, requests, certificates, CRLs and templates - imports/exports, XCA .xdb migration, Docker Compose stack and tests
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# kca-app
|
||||
|
||||
The deployable KCA application: environment-driven settings, kaya app assembly
|
||||
(sessions, OIDC, Tortoise, OpenAPI, CORS, optional OpenTelemetry), the HTTP
|
||||
API, Tortoise models, aerich migrations, and the SPA/PWA shell hosting.
|
||||
@@ -0,0 +1,66 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "kca-app"
|
||||
version = "0.1.0"
|
||||
description = "KCA: kaya application wiring, Tortoise models, API and SPA hosting"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"kca-core",
|
||||
"kaya-core>=0.0.7",
|
||||
"kaya-cors",
|
||||
"kaya-session",
|
||||
"kaya-session-redis",
|
||||
"kaya-oidc>=0.0.7",
|
||||
"kaya-openapi",
|
||||
"kaya-rsgi",
|
||||
"granian>=2.0",
|
||||
"tortoise-orm>=0.25",
|
||||
"asyncpg",
|
||||
"aerich",
|
||||
"httpx",
|
||||
"PyJWT[crypto]",
|
||||
"PyYAML",
|
||||
"redis",
|
||||
"cryptography>=44",
|
||||
"pyOpenSSL>=25",
|
||||
"paramiko>=3.5",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
otel = [
|
||||
"kaya-otel>=0.0.7",
|
||||
"opentelemetry-instrumentation-asyncpg>=0.66b0",
|
||||
"opentelemetry-instrumentation-redis>=0.66b0",
|
||||
"opentelemetry-instrumentation-system-metrics>=0.66b0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"mypy",
|
||||
"types-PyYAML",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
namespaces = true
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
ignore_missing_imports = true
|
||||
plugins = []
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "kca.models"
|
||||
disable_error_code = ["attr-defined"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "kca.service"
|
||||
disable_error_code = ["attr-defined", "assignment", "index", "arg-type"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "kca.routes.*"
|
||||
disable_error_code = ["attr-defined", "assignment", "index", "arg-type"]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Tortoise ORM configuration consumed by the aerich CLI."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .config import settings
|
||||
|
||||
TORTOISE_ORM = {
|
||||
"connections": {"default": settings.database_url},
|
||||
"apps": {
|
||||
"models": {
|
||||
"models": ["kca.models", "aerich.models"],
|
||||
"default_connection": "default",
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Application entry point.
|
||||
|
||||
Assembles the kaya app with sessions, OIDC, Tortoise, OpenAPI, the KCA API
|
||||
and the SPA shell. Optional CORS mirrors spendwise's environment-driven
|
||||
configuration.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from logging import getLogger
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from kaya.core import KayaApp, KayaMixin
|
||||
from kaya.cors import CorsMixin
|
||||
from kaya.oidc import OIDCConfig, OIDCProviders
|
||||
from kaya.session import SessionMixin
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from .config import Settings, settings
|
||||
from .logging_config import configure_logging
|
||||
from .mixin import Kca, KcaMixin
|
||||
from .openapi import SCHEMAS, SECURITY_SCHEMES, KcaOpenAPI
|
||||
from .tortoise_mixin import TortoiseMixin
|
||||
|
||||
configure_logging(settings.logging_config)
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
def cors_mixin_from_settings(settings: Settings) -> Optional[CorsMixin]:
|
||||
if settings.cors_allow_origins is None and settings.cors_allow_origin_regex is None:
|
||||
return None
|
||||
return CorsMixin(
|
||||
allow_origins=settings.cors_allow_origins or (),
|
||||
allow_origin_regex=settings.cors_allow_origin_regex,
|
||||
allow_methods=settings.cors_allow_methods or ("GET",),
|
||||
allow_headers=settings.cors_allow_headers or (),
|
||||
allow_credentials=settings.cors_allow_credentials,
|
||||
expose_headers=settings.cors_expose_headers or (),
|
||||
max_age=settings.cors_max_age,
|
||||
)
|
||||
|
||||
|
||||
if settings.master_key_is_dev:
|
||||
log.warning(
|
||||
"KCA_MASTER_KEY is unset: using the built-in development key. "
|
||||
"Set a random 32-byte base64 key in production."
|
||||
)
|
||||
|
||||
if settings.redis_url is not None:
|
||||
from kaya.session.redis import RedisSessionStore
|
||||
|
||||
session_store: Any = RedisSessionStore(Redis.from_url(settings.redis_url))
|
||||
log.info("using the Redis session store")
|
||||
else:
|
||||
from kaya.session import InMemorySessionStore
|
||||
|
||||
session_store = InMemorySessionStore()
|
||||
log.info("REDIS_URL unset: using the in-memory session store")
|
||||
|
||||
session_mixin = SessionMixin(session_store, secure=settings.session_cookie_secure)
|
||||
oidc = OIDCProviders(
|
||||
[
|
||||
OIDCConfig(
|
||||
provider=provider.provider,
|
||||
issuer=provider.issuer,
|
||||
client_id=provider.client_id,
|
||||
client_secret=provider.client_secret,
|
||||
redirect_uri=provider.redirect_uri,
|
||||
post_login_redirect=provider.post_login_redirect,
|
||||
post_logout_redirect=provider.post_logout_redirect,
|
||||
scopes=("openid", "profile"),
|
||||
fetch_userinfo=True,
|
||||
)
|
||||
for provider in settings.oidc_providers
|
||||
],
|
||||
session=session_mixin,
|
||||
)
|
||||
log.info("OIDC providers: %s", ", ".join(p.provider for p in settings.oidc_providers))
|
||||
|
||||
openapi_mixin = KcaOpenAPI(
|
||||
title="KCA",
|
||||
version="0.1.0",
|
||||
description="Key and certificate authority API (XCA web reimplementation)",
|
||||
spec_path="/api/openapi.json",
|
||||
docs_path="/api/docs",
|
||||
components={"schemas": SCHEMAS, "securitySchemes": SECURITY_SCHEMES},
|
||||
)
|
||||
tortoise_mixin = TortoiseMixin(
|
||||
database_url=settings.database_url,
|
||||
models_modules=["kca.models"],
|
||||
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
|
||||
)
|
||||
kca = Kca(oidc=oidc)
|
||||
|
||||
mixins: list[KayaMixin] = [
|
||||
session_mixin,
|
||||
oidc,
|
||||
tortoise_mixin,
|
||||
openapi_mixin,
|
||||
KcaMixin(kca),
|
||||
]
|
||||
|
||||
cors_mixin = cors_mixin_from_settings(settings)
|
||||
if cors_mixin is not None:
|
||||
mixins.insert(0, cors_mixin)
|
||||
log.info("CORS enabled: origins=%s", settings.cors_allow_origins)
|
||||
|
||||
app = KayaApp(mixins=mixins)
|
||||
|
||||
# Register the SPA shell and PWA entry points.
|
||||
from . import static # noqa: E402,F401
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Authentication helpers on top of the kaya-oidc mixin."""
|
||||
from __future__ import annotations
|
||||
|
||||
from logging import getLogger
|
||||
from typing import Any, Awaitable, Callable, Dict, Mapping, Optional
|
||||
|
||||
from kaya.core import HttpContext
|
||||
from kaya.oidc import OIDCProviders, OIDCUser
|
||||
from kaya.session import Session
|
||||
|
||||
from .models import User
|
||||
from .tokens import bearer_token, resolve_token
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
PROFILE_CLAIMS = (
|
||||
"name",
|
||||
"family_name",
|
||||
"given_name",
|
||||
"middle_name",
|
||||
"nickname",
|
||||
"preferred_username",
|
||||
"profile",
|
||||
"picture",
|
||||
"website",
|
||||
"gender",
|
||||
"birthdate",
|
||||
"zoneinfo",
|
||||
"locale",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
_URL_CLAIMS = frozenset({"profile", "picture", "website"})
|
||||
_CLAIM_MAX_LENGTH: Dict[str, int] = {
|
||||
"name": 255,
|
||||
"family_name": 255,
|
||||
"given_name": 255,
|
||||
"middle_name": 255,
|
||||
"nickname": 255,
|
||||
"preferred_username": 255,
|
||||
"profile": 2048,
|
||||
"picture": 2048,
|
||||
"website": 2048,
|
||||
"gender": 100,
|
||||
"birthdate": 32,
|
||||
"zoneinfo": 100,
|
||||
"locale": 100,
|
||||
}
|
||||
|
||||
_OIDC_PREFIX = "oidc:"
|
||||
_USER_SUFFIX = ":user"
|
||||
_CACHED_USER_ATTR = "_kca_user"
|
||||
|
||||
_UNAUTHENTICATED_BODY = b'{"error":"unauthenticated"}'
|
||||
_UNAUTHENTICATED_HEADERS = {"content-type": ("application/json",)}
|
||||
|
||||
|
||||
def session_user(session: Session) -> Optional[OIDCUser]:
|
||||
for key in session:
|
||||
if key.startswith(_OIDC_PREFIX) and key.endswith(_USER_SUFFIX):
|
||||
claims = session.get(key)
|
||||
if isinstance(claims, Mapping):
|
||||
return OIDCUser(claims)
|
||||
return None
|
||||
|
||||
|
||||
def display_name(user: OIDCUser) -> str:
|
||||
for key in ("preferred_username", "name"):
|
||||
value = user.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return user.sub
|
||||
|
||||
|
||||
def _text_claim(value: Any, max_length: int) -> Optional[str]:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
return value[:max_length]
|
||||
|
||||
|
||||
def _url_claim(value: Any, max_length: int) -> Optional[str]:
|
||||
value = _text_claim(value, max_length)
|
||||
if value is not None and not value.startswith(("http://", "https://")):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def profile_claims(user: OIDCUser) -> Dict[str, Any]:
|
||||
values: Dict[str, Any] = {}
|
||||
for name in PROFILE_CLAIMS:
|
||||
raw = user.get(name)
|
||||
if name == "updated_at":
|
||||
values[name] = raw if isinstance(raw, int) and not isinstance(raw, bool) else None
|
||||
elif name in _URL_CLAIMS:
|
||||
values[name] = _url_claim(raw, _CLAIM_MAX_LENGTH[name])
|
||||
else:
|
||||
values[name] = _text_claim(raw, _CLAIM_MAX_LENGTH[name])
|
||||
return values
|
||||
|
||||
|
||||
def current_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[OIDCUser]:
|
||||
return next(iter(oidc.get_users(ctx).values()), None)
|
||||
|
||||
|
||||
async def ensure_user(user: OIDCUser) -> User:
|
||||
updates: Dict[str, Any] = profile_claims(user)
|
||||
updates["display_name"] = display_name(user)
|
||||
row = await User.get_or_none(sub=user.sub)
|
||||
if row is None:
|
||||
row = await User.create(sub=user.sub, **updates)
|
||||
log.info("registered user sub=%s name=%r", user.sub, updates["display_name"])
|
||||
return row
|
||||
changed = [field for field, value in updates.items() if getattr(row, field) != value]
|
||||
if changed:
|
||||
for field in changed:
|
||||
setattr(row, field, updates[field])
|
||||
await row.save(update_fields=changed)
|
||||
return row
|
||||
|
||||
|
||||
async def _resolve_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[User]:
|
||||
secret = bearer_token(ctx)
|
||||
if secret is not None:
|
||||
return await resolve_token(secret)
|
||||
oidc_user = current_user(oidc, ctx)
|
||||
if oidc_user is None:
|
||||
return None
|
||||
return await ensure_user(oidc_user)
|
||||
|
||||
|
||||
async def _send_unauthenticated(ctx: HttpContext) -> None:
|
||||
await ctx.send_bytes(401, _UNAUTHENTICATED_BODY, _UNAUTHENTICATED_HEADERS)
|
||||
|
||||
|
||||
async def authenticated_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[User]:
|
||||
cached = getattr(ctx, _CACHED_USER_ATTR, None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
return await _resolve_user(oidc, ctx)
|
||||
|
||||
|
||||
def cached_user(ctx: HttpContext) -> Optional[User]:
|
||||
user = getattr(ctx, _CACHED_USER_ATTR, None)
|
||||
return user if isinstance(user, User) else None
|
||||
|
||||
|
||||
def _require_user(
|
||||
resolve: Callable[[HttpContext], Awaitable[Optional[User]]],
|
||||
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
|
||||
async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None:
|
||||
user = await resolve(ctx)
|
||||
if user is None:
|
||||
await _send_unauthenticated(ctx)
|
||||
return
|
||||
setattr(ctx, _CACHED_USER_ATTR, user)
|
||||
await handler(ctx, *args, **kwargs)
|
||||
|
||||
return guarded
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def require_auth(oidc: OIDCProviders) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||
return _require_user(lambda ctx: _resolve_user(oidc, ctx))
|
||||
|
||||
|
||||
def require_session_auth(oidc: OIDCProviders) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||
async def resolve_session(ctx: HttpContext) -> Optional[User]:
|
||||
oidc_user = current_user(oidc, ctx)
|
||||
if oidc_user is None:
|
||||
return None
|
||||
return await ensure_user(oidc_user)
|
||||
|
||||
return _require_user(resolve_session)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PROFILE_CLAIMS",
|
||||
"authenticated_user",
|
||||
"cached_user",
|
||||
"current_user",
|
||||
"display_name",
|
||||
"ensure_user",
|
||||
"profile_claims",
|
||||
"require_auth",
|
||||
"require_session_auth",
|
||||
"session_user",
|
||||
]
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Environment-driven configuration for KCA.
|
||||
|
||||
Mirrors kaya's own pattern: read ``os.environ`` directly into a frozen
|
||||
dataclass. No pydantic-settings, no settings module.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import quote
|
||||
|
||||
from kca.core.crypto import decode_master_key
|
||||
|
||||
# Per-provider OIDC settings are indexed with a 0-based suffix.
|
||||
_OIDC_VARIABLES = (
|
||||
"PROVIDER",
|
||||
"ISSUER",
|
||||
"CLIENT_ID",
|
||||
"CLIENT_SECRET",
|
||||
"REDIRECT_URI",
|
||||
"POST_LOGIN_REDIRECT",
|
||||
"POST_LOGOUT_REDIRECT",
|
||||
)
|
||||
_OIDC_ENV = re.compile(r"^OIDC_(\d+)_([A-Z0-9_]+)$")
|
||||
_PROVIDER_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
_DEFAULT_OIDC_ISSUER = "http://localhost:8180/kca"
|
||||
_DEFAULT_OIDC_CLIENT_ID = "kca"
|
||||
|
||||
#: Development-only master key. Production deployments must set
|
||||
#: KCA_MASTER_KEY to a random 32-byte base64 value.
|
||||
_DEV_MASTER_KEY = hashlib.sha256(b"kca-development-master-key").digest()
|
||||
|
||||
|
||||
def _env(name: str, default: Optional[str] = None) -> str:
|
||||
value = os.environ.get(name)
|
||||
if value is None or value == "":
|
||||
if default is None:
|
||||
raise RuntimeError(f"Missing required environment variable: {name}")
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def _env_list(name: str) -> Optional[Tuple[str, ...]]:
|
||||
value = os.environ.get(name)
|
||||
if value is None or value.strip() == "":
|
||||
return None
|
||||
return tuple(part.strip() for part in value.split(",") if part.strip())
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
value = os.environ.get(name)
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OidcProviderSettings:
|
||||
provider: str
|
||||
issuer: str
|
||||
client_id: str
|
||||
client_secret: Optional[str]
|
||||
redirect_uri: str
|
||||
post_login_redirect: str
|
||||
post_logout_redirect: str
|
||||
|
||||
|
||||
def _oidc_providers_from_env() -> Tuple[OidcProviderSettings, ...]:
|
||||
indices: set[int] = set()
|
||||
for name in os.environ:
|
||||
match = _OIDC_ENV.match(name)
|
||||
if match is None:
|
||||
continue
|
||||
index, variable = match.groups()
|
||||
if variable not in _OIDC_VARIABLES:
|
||||
raise RuntimeError(
|
||||
f"unknown OIDC environment variable {name!r}; expected one of "
|
||||
+ ", ".join(f"OIDC_<n>_{v}" for v in _OIDC_VARIABLES)
|
||||
)
|
||||
indices.add(int(index))
|
||||
|
||||
if not indices:
|
||||
return (
|
||||
OidcProviderSettings(
|
||||
provider="default",
|
||||
issuer=_DEFAULT_OIDC_ISSUER,
|
||||
client_id=_DEFAULT_OIDC_CLIENT_ID,
|
||||
client_secret=None,
|
||||
redirect_uri="http://localhost:8080/auth/default/callback",
|
||||
post_login_redirect="/",
|
||||
post_logout_redirect="/",
|
||||
),
|
||||
)
|
||||
|
||||
missing = sorted(set(range(max(indices) + 1)) - indices)
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"OIDC provider indices must be contiguous from 0; missing "
|
||||
+ ", ".join(f"OIDC_{index}_*" for index in missing)
|
||||
)
|
||||
|
||||
providers = []
|
||||
seen = set()
|
||||
for index in sorted(indices):
|
||||
prefix = f"OIDC_{index}_"
|
||||
provider = _env(prefix + "PROVIDER")
|
||||
if _PROVIDER_NAME.match(provider) is None:
|
||||
raise RuntimeError(
|
||||
f"{prefix}PROVIDER must be a non-empty string of letters, "
|
||||
f"digits, underscores or hyphens (got {provider!r})"
|
||||
)
|
||||
if provider in seen:
|
||||
raise RuntimeError(f"duplicate OIDC provider name {provider!r}")
|
||||
seen.add(provider)
|
||||
providers.append(
|
||||
OidcProviderSettings(
|
||||
provider=provider,
|
||||
issuer=_env(prefix + "ISSUER"),
|
||||
client_id=_env(prefix + "CLIENT_ID"),
|
||||
client_secret=os.environ.get(prefix + "CLIENT_SECRET") or None,
|
||||
redirect_uri=_env(
|
||||
prefix + "REDIRECT_URI",
|
||||
f"http://localhost:8080/auth/{provider}/callback",
|
||||
),
|
||||
post_login_redirect=_env(prefix + "POST_LOGIN_REDIRECT", "/"),
|
||||
post_logout_redirect=_env(prefix + "POST_LOGOUT_REDIRECT", "/"),
|
||||
)
|
||||
)
|
||||
return tuple(providers)
|
||||
|
||||
|
||||
def _database_url_from_parts(
|
||||
engine: str,
|
||||
user: str,
|
||||
password: Optional[str],
|
||||
host: str,
|
||||
port: str,
|
||||
name: str,
|
||||
options: str,
|
||||
) -> str:
|
||||
netloc = quote(user, safe="")
|
||||
if password:
|
||||
netloc += ":" + quote(password, safe="")
|
||||
netloc += "@" + host
|
||||
if port:
|
||||
netloc += ":" + port
|
||||
url = f"{engine}://{netloc}/{name}"
|
||||
options = options.lstrip("?")
|
||||
if options:
|
||||
url += "?" + options
|
||||
return url
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
database_url: str
|
||||
oidc_providers: Tuple[OidcProviderSettings, ...]
|
||||
app_host: str
|
||||
app_port: int
|
||||
redis_url: Optional[str]
|
||||
static_dir: str
|
||||
logging_config: Optional[str]
|
||||
#: AES-256-GCM master key protecting private keys at rest.
|
||||
master_key: bytes
|
||||
#: True when the master key came from the built-in development default.
|
||||
master_key_is_dev: bool
|
||||
max_upload_bytes: int
|
||||
keygen_concurrency: int
|
||||
session_cookie_secure: bool
|
||||
cors_allow_origins: Optional[Tuple[str, ...]]
|
||||
cors_allow_origin_regex: Optional[str]
|
||||
cors_allow_methods: Optional[Tuple[str, ...]]
|
||||
cors_allow_headers: Optional[Tuple[str, ...]]
|
||||
cors_allow_credentials: bool
|
||||
cors_expose_headers: Optional[Tuple[str, ...]]
|
||||
cors_max_age: int
|
||||
otel_enabled: bool
|
||||
otel_service_name: str
|
||||
otel_exporter_endpoint: Optional[str]
|
||||
otel_exporter_headers: Optional[Tuple[str, ...]]
|
||||
otel_excluded_paths: Tuple[str, ...]
|
||||
otel_metric_export_interval_millis: int
|
||||
otel_metrics_poll_interval_seconds: int
|
||||
|
||||
@staticmethod
|
||||
def from_env() -> "Settings":
|
||||
raw_master = os.environ.get("KCA_MASTER_KEY")
|
||||
master_key_is_dev = not raw_master
|
||||
master_key = decode_master_key(raw_master) if raw_master else _DEV_MASTER_KEY
|
||||
return Settings(
|
||||
database_url=os.environ.get("DATABASE_URL")
|
||||
or _database_url_from_parts(
|
||||
engine=_env("DATABASE_ENGINE", "postgres"),
|
||||
user=_env("DATABASE_USER", "kca"),
|
||||
password=_env("DATABASE_PASSWORD", "password"),
|
||||
host=_env("DATABASE_HOST", "localhost"),
|
||||
port=os.environ.get("DATABASE_PORT", ""),
|
||||
name=_env("DATABASE_NAME", "kca"),
|
||||
options=os.environ.get("DATABASE_OPTIONS", ""),
|
||||
),
|
||||
oidc_providers=_oidc_providers_from_env(),
|
||||
app_host=_env("APP_HOST", "0.0.0.0"),
|
||||
app_port=int(_env("APP_PORT", "8080")),
|
||||
redis_url=os.environ.get("REDIS_URL") or None,
|
||||
static_dir=_env("STATIC_DIR", "web/dist"),
|
||||
logging_config=os.environ.get("LOGGING_CONFIG") or None,
|
||||
master_key=master_key,
|
||||
master_key_is_dev=master_key_is_dev,
|
||||
max_upload_bytes=int(_env("MAX_UPLOAD_BYTES", str(20 * 1024 * 1024))),
|
||||
keygen_concurrency=max(1, int(_env("KEYGEN_CONCURRENCY", "2"))),
|
||||
session_cookie_secure=_env_bool("SESSION_COOKIE_SECURE"),
|
||||
cors_allow_origins=_env_list("CORS_ALLOW_ORIGINS"),
|
||||
cors_allow_origin_regex=os.environ.get("CORS_ALLOW_ORIGIN_REGEX") or None,
|
||||
cors_allow_methods=_env_list("CORS_ALLOW_METHODS"),
|
||||
cors_allow_headers=_env_list("CORS_ALLOW_HEADERS"),
|
||||
cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"),
|
||||
cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"),
|
||||
cors_max_age=int(_env("CORS_MAX_AGE", "600")),
|
||||
otel_enabled=_env_bool("OTEL_ENABLED"),
|
||||
otel_service_name=_env("OTEL_SERVICE_NAME", "kca"),
|
||||
otel_exporter_endpoint=os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or None,
|
||||
otel_exporter_headers=_env_list("OTEL_EXPORTER_OTLP_HEADERS"),
|
||||
otel_excluded_paths=_env_list("OTEL_EXCLUDED_PATHS") or ("/api/health",),
|
||||
otel_metric_export_interval_millis=int(
|
||||
_env("OTEL_METRIC_EXPORT_INTERVAL_MILLIS", "60000")
|
||||
),
|
||||
otel_metrics_poll_interval_seconds=int(
|
||||
_env("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
settings: Settings = Settings.from_env()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""JSON helpers for kaya HTTP handlers.
|
||||
|
||||
Kaya has no built-in request/response JSON helpers: the request body is an
|
||||
async byte stream on ``ctx.request_body`` and responses are sent with
|
||||
``ctx.send_*``. These wrappers handle the boilerplate of draining the body,
|
||||
parsing JSON, and sending JSON responses.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, List, Mapping
|
||||
|
||||
from kaya.core import HttpContext
|
||||
|
||||
JSON_HEADERS = {"content-type": ("application/json",)}
|
||||
|
||||
|
||||
class JsonRequestError(ValueError):
|
||||
"""Raised by :func:`read_json` when the request body is not valid JSON
|
||||
or is not a JSON object."""
|
||||
|
||||
|
||||
def extract_query_params(query_string: str) -> Mapping[str, List[str]]:
|
||||
"""Parse a raw query string into a mapping of param name to list of
|
||||
values.
|
||||
|
||||
Wraps :func:`urllib.parse.parse_qs` so callers don't repeat the
|
||||
incantation; always returns a mapping (never None).
|
||||
"""
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
return parse_qs(query_string, keep_blank_values=True)
|
||||
|
||||
|
||||
async def read_json(ctx: HttpContext) -> dict:
|
||||
"""Drain and parse the request body as JSON.
|
||||
|
||||
Returns the parsed ``dict`` on success. Raises :class:`JsonRequestError`
|
||||
with a short human-readable reason on failure.
|
||||
"""
|
||||
body = b""
|
||||
async for chunk in ctx.request_body:
|
||||
body += chunk
|
||||
if not body:
|
||||
raise JsonRequestError("empty body")
|
||||
try:
|
||||
parsed = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise JsonRequestError("invalid JSON") from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise JsonRequestError("JSON body must be an object")
|
||||
return parsed
|
||||
|
||||
|
||||
async def read_json_optional(ctx: HttpContext) -> dict:
|
||||
"""Like :func:`read_json` but treats an empty body as ``{}``."""
|
||||
try:
|
||||
return await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
if str(exc) == "empty body":
|
||||
return {}
|
||||
raise
|
||||
|
||||
|
||||
async def send_json(ctx: HttpContext, status: int, payload: Any) -> None:
|
||||
"""Send ``payload`` as a JSON response."""
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
await ctx.send_bytes(status, body, JSON_HEADERS)
|
||||
|
||||
|
||||
async def send_error(ctx: HttpContext, status: int, message: str) -> None:
|
||||
"""Send a JSON error envelope."""
|
||||
await send_json(ctx, status, {"error": message})
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Logging setup for the kca application.
|
||||
|
||||
Configured once at app import time (see :mod:`kca.app`). By default a
|
||||
single console handler at DEBUG level is installed, formatting records as
|
||||
``{asctime} [{levelname}] ({processName}/{threadName}) - {name} - {message}``;
|
||||
``urllib3.connectionpool`` is capped at INFO so its per-request DEBUG
|
||||
chatter does not drown the log.
|
||||
|
||||
Point the ``LOGGING_CONFIG`` environment variable at a YAML file to take
|
||||
over the configuration entirely; the file follows the
|
||||
:data:`logging.config.dictConfig` schema, e.g.::
|
||||
|
||||
version: 1
|
||||
disable_existing_loggers: false
|
||||
formatters:
|
||||
default:
|
||||
format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}"
|
||||
style: "{"
|
||||
handlers:
|
||||
console:
|
||||
class: logging.StreamHandler
|
||||
formatter: default
|
||||
root:
|
||||
level: WARNING
|
||||
handlers: [console]
|
||||
loggers:
|
||||
kca:
|
||||
level: INFO
|
||||
|
||||
``disable_existing_loggers`` should stay ``false``: Granian configures its
|
||||
own loggers before importing the application, and disabling them would
|
||||
silence the server and Tortoise.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from logging.config import dictConfig
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"default": {
|
||||
"format": "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}",
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "default",
|
||||
"level": "DEBUG",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"level": "DEBUG",
|
||||
"handlers": ["console"],
|
||||
},
|
||||
"loggers": {
|
||||
# Every pooled HTTP request is a DEBUG record; keep the logger's
|
||||
# INFO+ output, drop the per-connection noise.
|
||||
"urllib3.connectionpool": {
|
||||
"level": "INFO",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def configure_logging(config_path: Optional[str]) -> None:
|
||||
"""Apply the YAML logging configuration at ``config_path``, or the
|
||||
built-in default when unset."""
|
||||
if config_path is None:
|
||||
dictConfig(DEFAULT_CONFIG)
|
||||
return
|
||||
try:
|
||||
with open(config_path, "rb") as handle:
|
||||
config = yaml.safe_load(handle)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Cannot read LOGGING_CONFIG file: {config_path}") from exc
|
||||
if not isinstance(config, dict):
|
||||
raise RuntimeError(f"LOGGING_CONFIG file is not a YAML mapping: {config_path}")
|
||||
dictConfig(config)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""The KCA mixin: mounts the whole API on a kaya app."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from kaya.core import KayaApp, KayaMixin
|
||||
from kaya.oidc import OIDCProviders
|
||||
|
||||
|
||||
@dataclass
|
||||
class Kca:
|
||||
oidc: OIDCProviders
|
||||
|
||||
|
||||
class KcaMixin(KayaMixin):
|
||||
def __init__(self, kca: Kca) -> None:
|
||||
self.kca = kca
|
||||
|
||||
def apply(self, app: KayaApp) -> None:
|
||||
from .routes import (
|
||||
auth,
|
||||
certificates,
|
||||
crls,
|
||||
health,
|
||||
import_,
|
||||
keys,
|
||||
me,
|
||||
requests,
|
||||
search,
|
||||
templates,
|
||||
tokens,
|
||||
vaults,
|
||||
)
|
||||
|
||||
for module in (
|
||||
health,
|
||||
auth,
|
||||
me,
|
||||
tokens,
|
||||
vaults,
|
||||
search,
|
||||
keys,
|
||||
requests,
|
||||
certificates,
|
||||
crls,
|
||||
templates,
|
||||
import_,
|
||||
):
|
||||
module.register(app, self.kca)
|
||||
@@ -0,0 +1,332 @@
|
||||
"""Tortoise ORM models for KCA.
|
||||
|
||||
The schema mirrors XCA's entities rather than spendwise's expense-tracking
|
||||
model: a *vault* is one XCA database, and keys, certificates, requests, CRLs,
|
||||
templates and revocations are its items. Private key material is stored as
|
||||
AES-256-GCM ciphertext (``private_der`` + ``private_nonce``); the plaintext
|
||||
exists only in memory during a request.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from tortoise import fields
|
||||
from tortoise.models import Model
|
||||
|
||||
|
||||
class User(Model):
|
||||
"""One OIDC identity that has logged in at least once."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
sub = fields.CharField(max_length=255, unique=True)
|
||||
display_name = fields.CharField(max_length=200)
|
||||
|
||||
name = fields.CharField(max_length=255, null=True)
|
||||
family_name = fields.CharField(max_length=255, null=True)
|
||||
given_name = fields.CharField(max_length=255, null=True)
|
||||
middle_name = fields.CharField(max_length=255, null=True)
|
||||
nickname = fields.CharField(max_length=255, null=True)
|
||||
preferred_username = fields.CharField(max_length=255, null=True)
|
||||
profile = fields.CharField(max_length=2048, null=True)
|
||||
picture = fields.CharField(max_length=2048, null=True)
|
||||
website = fields.CharField(max_length=2048, null=True)
|
||||
gender = fields.CharField(max_length=100, null=True)
|
||||
birthdate = fields.CharField(max_length=32, null=True)
|
||||
zoneinfo = fields.CharField(max_length=100, null=True)
|
||||
locale = fields.CharField(max_length=100, null=True)
|
||||
updated_at = fields.BigIntField(null=True)
|
||||
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
last_seen_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "app_user"
|
||||
ordering = ["display_name", "id"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"User(sub={self.sub!r}, display_name={self.display_name!r})"
|
||||
|
||||
|
||||
class ApiToken(Model):
|
||||
"""Personal access token for programmatic API access."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
user_sub = fields.CharField(max_length=255, db_index=True)
|
||||
name = fields.CharField(max_length=100)
|
||||
token_prefix = fields.CharField(max_length=16)
|
||||
token_hash = fields.CharField(max_length=64, unique=True)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
last_used_at = fields.DatetimeField(null=True)
|
||||
expires_at = fields.DateField(null=True)
|
||||
|
||||
class Meta:
|
||||
table = "api_token"
|
||||
ordering = ["-created_at", "id"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"ApiToken(name={self.name!r}, prefix={self.token_prefix!r})"
|
||||
|
||||
|
||||
class Vault(Model):
|
||||
"""One private XCA database owned by an OIDC user."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
owner_sub = fields.CharField(max_length=255, db_index=True)
|
||||
name = fields.CharField(max_length=200)
|
||||
description = fields.TextField(default="")
|
||||
settings = fields.JSONField(default=dict)
|
||||
version = fields.IntField(default=1)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
keys: fields.ReverseRelation["Key"]
|
||||
certificates: fields.ReverseRelation["Certificate"]
|
||||
requests: fields.ReverseRelation["Request"]
|
||||
crls: fields.ReverseRelation["Crl"]
|
||||
templates: fields.ReverseRelation["Template"]
|
||||
|
||||
class Meta:
|
||||
table = "vault"
|
||||
ordering = ["name", "id"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Vault(name={self.name!r})"
|
||||
|
||||
|
||||
class Key(Model):
|
||||
"""A public or private key item."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField(
|
||||
"models.Vault", related_name="keys", on_delete=fields.CASCADE
|
||||
)
|
||||
name = fields.CharField(max_length=200)
|
||||
comment = fields.TextField(default="")
|
||||
source = fields.CharField(max_length=20, default="generated")
|
||||
key_type = fields.CharField(max_length=20)
|
||||
key_size = fields.IntField(null=True)
|
||||
curve = fields.CharField(max_length=100, null=True)
|
||||
public_der = fields.BinaryField()
|
||||
public_key_sha256 = fields.CharField(max_length=64, db_index=True)
|
||||
private_der = fields.BinaryField(null=True)
|
||||
private_nonce = fields.BinaryField(null=True)
|
||||
has_private = fields.BooleanField(default=False)
|
||||
version = fields.IntField(default=1)
|
||||
search_text = fields.TextField(default="")
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "key"
|
||||
ordering = ["name", "id"]
|
||||
unique_together = (("vault", "public_key_sha256"),)
|
||||
indexes = (("vault", "name"), ("vault", "key_type"))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Key(name={self.name!r}, type={self.key_type!r})"
|
||||
|
||||
|
||||
class Certificate(Model):
|
||||
"""An X.509 certificate item."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField(
|
||||
"models.Vault", related_name="certificates", on_delete=fields.CASCADE
|
||||
)
|
||||
key: fields.ForeignKeyRelation[Key] = fields.ForeignKeyField(
|
||||
"models.Key", related_name="certificates", on_delete=fields.SET_NULL, null=True
|
||||
)
|
||||
issuer: fields.ForeignKeyRelation["Certificate"] = fields.ForeignKeyField(
|
||||
"models.Certificate", related_name="children", on_delete=fields.SET_NULL, null=True
|
||||
)
|
||||
request: fields.ForeignKeyRelation["Request"] = fields.ForeignKeyField(
|
||||
"models.Request", related_name="certificates", on_delete=fields.SET_NULL, null=True
|
||||
)
|
||||
name = fields.CharField(max_length=200)
|
||||
comment = fields.TextField(default="")
|
||||
source = fields.CharField(max_length=20, default="generated")
|
||||
serial_hex = fields.CharField(max_length=128, db_index=True)
|
||||
subject_der = fields.BinaryField()
|
||||
issuer_der = fields.BinaryField()
|
||||
subject_hash = fields.CharField(max_length=64, db_index=True)
|
||||
issuer_hash = fields.CharField(max_length=64, db_index=True)
|
||||
not_before = fields.DatetimeField()
|
||||
not_after = fields.DatetimeField()
|
||||
is_ca = fields.BooleanField(default=False, db_index=True)
|
||||
path_length = fields.IntField(null=True)
|
||||
cert_der = fields.BinaryField()
|
||||
public_key_der = fields.BinaryField()
|
||||
signature_algorithm = fields.CharField(max_length=100, default="")
|
||||
fingerprint_md5 = fields.CharField(max_length=32, default="")
|
||||
fingerprint_sha1 = fields.CharField(max_length=40, default="")
|
||||
fingerprint_sha256 = fields.CharField(max_length=64, db_index=True)
|
||||
version = fields.IntField(default=1)
|
||||
search_text = fields.TextField(default="")
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "certificate"
|
||||
ordering = ["name", "id"]
|
||||
unique_together = (("vault", "fingerprint_sha256"),)
|
||||
indexes = (("vault", "name"), ("vault", "issuer"), ("vault", "not_after"))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Certificate(name={self.name!r}, serial={self.serial_hex!r})"
|
||||
|
||||
|
||||
class Authority(Model):
|
||||
"""CA-specific properties, one row per CA certificate."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
certificate: fields.OneToOneRelation[Certificate] = fields.OneToOneField(
|
||||
"models.Certificate", related_name="authority", on_delete=fields.CASCADE
|
||||
)
|
||||
template: fields.ForeignKeyRelation["Template"] = fields.ForeignKeyField(
|
||||
"models.Template", related_name="authorities", on_delete=fields.SET_NULL, null=True
|
||||
)
|
||||
crl_expire = fields.DatetimeField(null=True)
|
||||
crl_number = fields.IntField(default=0)
|
||||
crl_days = fields.IntField(default=30)
|
||||
tls_auth_key = fields.BinaryField(null=True)
|
||||
tls_auth_key_nonce = fields.BinaryField(null=True)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "authority"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Authority(certificate={self.certificate_id})"
|
||||
|
||||
|
||||
class CertificateRevocation(Model):
|
||||
"""A serial revoked by a CA (XCA stores revocations on the CA)."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
ca: fields.ForeignKeyRelation[Certificate] = fields.ForeignKeyField(
|
||||
"models.Certificate", related_name="revocations", on_delete=fields.CASCADE
|
||||
)
|
||||
serial_hex = fields.CharField(max_length=128, db_index=True)
|
||||
revoked_at = fields.DatetimeField()
|
||||
invalidity_date = fields.DatetimeField(null=True)
|
||||
reason = fields.CharField(max_length=50, default="unspecified")
|
||||
crl_number = fields.IntField(default=0)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
table = "certificate_revocation"
|
||||
ordering = ["-revoked_at", "id"]
|
||||
unique_together = (("ca", "serial_hex"),)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Revocation(ca={self.ca_id}, serial={self.serial_hex!r})"
|
||||
|
||||
|
||||
class Request(Model):
|
||||
"""A PKCS#10 certificate signing request item."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField(
|
||||
"models.Vault", related_name="requests", on_delete=fields.CASCADE
|
||||
)
|
||||
key: fields.ForeignKeyRelation[Key] = fields.ForeignKeyField(
|
||||
"models.Key", related_name="requests", on_delete=fields.SET_NULL, null=True
|
||||
)
|
||||
name = fields.CharField(max_length=200)
|
||||
comment = fields.TextField(default="")
|
||||
source = fields.CharField(max_length=20, default="generated")
|
||||
subject_der = fields.BinaryField()
|
||||
req_der = fields.BinaryField()
|
||||
public_key_der = fields.BinaryField()
|
||||
request_sha256 = fields.CharField(max_length=64, db_index=True)
|
||||
signed = fields.BooleanField(default=False)
|
||||
signed_at = fields.DatetimeField(null=True)
|
||||
attributes = fields.JSONField(default=list)
|
||||
version = fields.IntField(default=1)
|
||||
search_text = fields.TextField(default="")
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "request"
|
||||
ordering = ["name", "id"]
|
||||
unique_together = (("vault", "request_sha256"),)
|
||||
indexes = (("vault", "name"), ("vault", "signed"))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Request(name={self.name!r}, signed={self.signed})"
|
||||
|
||||
|
||||
class Crl(Model):
|
||||
"""A certificate revocation list item."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField(
|
||||
"models.Vault", related_name="crls", on_delete=fields.CASCADE
|
||||
)
|
||||
issuer: fields.ForeignKeyRelation[Certificate] = fields.ForeignKeyField(
|
||||
"models.Certificate", related_name="crls", on_delete=fields.SET_NULL, null=True
|
||||
)
|
||||
name = fields.CharField(max_length=200)
|
||||
comment = fields.TextField(default="")
|
||||
source = fields.CharField(max_length=20, default="generated")
|
||||
crl_der = fields.BinaryField()
|
||||
crl_hash = fields.CharField(max_length=64, db_index=True)
|
||||
crl_number = fields.IntField(null=True)
|
||||
last_update = fields.DatetimeField()
|
||||
next_update = fields.DatetimeField(null=True)
|
||||
revoked_count = fields.IntField(default=0)
|
||||
signature_algorithm = fields.CharField(max_length=100, default="")
|
||||
search_text = fields.TextField(default="")
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "crl"
|
||||
ordering = ["-last_update", "id"]
|
||||
unique_together = (("vault", "crl_hash"),)
|
||||
indexes = (("vault", "name"), ("vault", "issuer"))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Crl(name={self.name!r}, number={self.crl_number})"
|
||||
|
||||
|
||||
class Template(Model):
|
||||
"""An XCA-style template: subject + extension/validity defaults."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField(
|
||||
"models.Vault", related_name="templates", on_delete=fields.CASCADE
|
||||
)
|
||||
name = fields.CharField(max_length=200)
|
||||
comment = fields.TextField(default="")
|
||||
source = fields.CharField(max_length=20, default="generated")
|
||||
subject = fields.JSONField(default=list)
|
||||
settings = fields.JSONField(default=dict)
|
||||
version = fields.IntField(default=1)
|
||||
search_text = fields.TextField(default="")
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "template"
|
||||
ordering = ["name", "id"]
|
||||
indexes = (("vault", "name"),)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Template(name={self.name!r})"
|
||||
|
||||
|
||||
__all__: List[str] = [
|
||||
"ApiToken",
|
||||
"Authority",
|
||||
"Certificate",
|
||||
"CertificateRevocation",
|
||||
"Crl",
|
||||
"Key",
|
||||
"Request",
|
||||
"Template",
|
||||
"User",
|
||||
"Vault",
|
||||
]
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Shared OpenAPI fragments and the authentication post-processor."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Mapping
|
||||
|
||||
from kaya.core import KayaApp
|
||||
from kaya.openapi import OpenAPIMixin, schema_ref
|
||||
|
||||
from .auth import PROFILE_CLAIMS
|
||||
|
||||
__all__ = [
|
||||
"API_SECURITY",
|
||||
"PUBLIC_PATHS",
|
||||
"SCHEMAS",
|
||||
"SECURITY_SCHEMES",
|
||||
"SESSION_ONLY_PATHS",
|
||||
"KcaOpenAPI",
|
||||
"error_response",
|
||||
"json_response",
|
||||
"schema_ref",
|
||||
]
|
||||
|
||||
SESSION_COOKIE_NAME = "session_id"
|
||||
|
||||
SECURITY_SCHEMES: Dict[str, Any] = {
|
||||
"sessionCookie": {
|
||||
"type": "apiKey",
|
||||
"in": "cookie",
|
||||
"name": SESSION_COOKIE_NAME,
|
||||
"description": "The OIDC login session (interactive use).",
|
||||
},
|
||||
"bearerToken": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"description": "A personal access token, ``Authorization: Bearer kca_...``.",
|
||||
},
|
||||
}
|
||||
|
||||
PUBLIC_PATHS = frozenset({"/api/health", "/api/auth/providers"})
|
||||
SESSION_ONLY_PATHS = frozenset({"/api/tokens", "/api/tokens/{token_id}"})
|
||||
API_SECURITY: List[Dict[str, List[str]]] = [
|
||||
{"sessionCookie": []},
|
||||
{"bearerToken": []},
|
||||
]
|
||||
SESSION_SECURITY: List[Dict[str, List[str]]] = [{"sessionCookie": []}]
|
||||
|
||||
|
||||
def _object(properties: Mapping[str, Any], required: Iterable[str] = ()) -> Dict[str, Any]:
|
||||
schema: Dict[str, Any] = {"type": "object", "properties": dict(properties)}
|
||||
if required:
|
||||
schema["required"] = list(required)
|
||||
return schema
|
||||
|
||||
|
||||
def _string(*, nullable: bool = False) -> Dict[str, Any]:
|
||||
return {"type": ["string", "null"] if nullable else "string"}
|
||||
|
||||
|
||||
def _integer(*, nullable: bool = False) -> Dict[str, Any]:
|
||||
return {"type": ["integer", "null"] if nullable else "integer"}
|
||||
|
||||
|
||||
def _boolean() -> Dict[str, Any]:
|
||||
return {"type": "boolean"}
|
||||
|
||||
|
||||
def _date_time() -> Dict[str, Any]:
|
||||
return {"type": "string", "format": "date-time"}
|
||||
|
||||
|
||||
def _array(items: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
return {"type": "array", "items": items}
|
||||
|
||||
|
||||
def _loose(name: str) -> Dict[str, Any]:
|
||||
"""A named, intentionally loose object schema.
|
||||
|
||||
The API is documented at the field level in the README and the route
|
||||
descriptions; keeping the OpenAPI component loose avoids duplicating the
|
||||
large nested extension/template structures here.
|
||||
"""
|
||||
return {"type": "object", "description": name, "additionalProperties": True}
|
||||
|
||||
|
||||
def json_response(description: str, schema: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
return {"description": description, "content": {"application/json": {"schema": dict(schema)}}}
|
||||
|
||||
|
||||
def error_response(description: str) -> Dict[str, Any]:
|
||||
return json_response(description, schema_ref("Error"))
|
||||
|
||||
|
||||
ERROR_SCHEMA = _object({"error": _string()}, required=("error",))
|
||||
HEALTH_SCHEMA = _object({"status": _string()}, required=("status",))
|
||||
ME_SCHEMA = _object(
|
||||
{
|
||||
"sub": _string(),
|
||||
"display_name": _string(),
|
||||
**{
|
||||
claim: _integer(nullable=True) if claim == "updated_at" else _string(nullable=True)
|
||||
for claim in PROFILE_CLAIMS
|
||||
},
|
||||
"master_key_is_dev": _boolean(),
|
||||
},
|
||||
required=("sub", "display_name", *PROFILE_CLAIMS, "master_key_is_dev"),
|
||||
)
|
||||
AUTH_PROVIDER_SCHEMA = _object({"name": _string(), "login_url": _string()}, required=("name", "login_url"))
|
||||
AUTH_PROVIDERS_PAGE_SCHEMA = _object({"providers": _array(schema_ref("AuthProvider"))}, required=("providers",))
|
||||
API_TOKEN_SCHEMA = _object(
|
||||
{
|
||||
"id": _string(),
|
||||
"name": _string(),
|
||||
"prefix": _string(),
|
||||
"created_at": _date_time(),
|
||||
"last_used_at": _string(nullable=True),
|
||||
"expires_at": _string(nullable=True),
|
||||
}
|
||||
)
|
||||
CREATED_API_TOKEN_SCHEMA = _object(
|
||||
{
|
||||
"id": _string(),
|
||||
"name": _string(),
|
||||
"prefix": _string(),
|
||||
"created_at": _date_time(),
|
||||
"last_used_at": _string(nullable=True),
|
||||
"expires_at": _string(nullable=True),
|
||||
"token": _string(),
|
||||
}
|
||||
)
|
||||
API_TOKENS_PAGE_SCHEMA = _object({"results": _array(schema_ref("ApiToken"))}, required=("results",))
|
||||
|
||||
SCHEMAS: Dict[str, Any] = {
|
||||
"Error": ERROR_SCHEMA,
|
||||
"Health": HEALTH_SCHEMA,
|
||||
"Me": ME_SCHEMA,
|
||||
"AuthProvider": AUTH_PROVIDER_SCHEMA,
|
||||
"AuthProvidersPage": AUTH_PROVIDERS_PAGE_SCHEMA,
|
||||
"ApiToken": API_TOKEN_SCHEMA,
|
||||
"CreatedApiToken": CREATED_API_TOKEN_SCHEMA,
|
||||
"ApiTokensPage": API_TOKENS_PAGE_SCHEMA,
|
||||
}
|
||||
for name in (
|
||||
"VaultSummary",
|
||||
"VaultDetail",
|
||||
"VaultsPage",
|
||||
"KeySummary",
|
||||
"KeyDetail",
|
||||
"KeysPage",
|
||||
"CertificateSummary",
|
||||
"CertificateDetail",
|
||||
"CertificatesPage",
|
||||
"RequestSummary",
|
||||
"RequestDetail",
|
||||
"RequestsPage",
|
||||
"CrlSummary",
|
||||
"CrlDetail",
|
||||
"CrlsPage",
|
||||
"TemplateSummary",
|
||||
"TemplateDetail",
|
||||
"TemplatesPage",
|
||||
"Revocation",
|
||||
"RevocationsPage",
|
||||
"SearchResult",
|
||||
"SearchPage",
|
||||
"VaultSummaryPage",
|
||||
"ImportPreview",
|
||||
"ImportResult",
|
||||
"ExportPayload",
|
||||
):
|
||||
SCHEMAS[name] = _loose(name)
|
||||
|
||||
|
||||
class KcaOpenAPI(OpenAPIMixin):
|
||||
"""Attach the authentication schemes to the generated document."""
|
||||
|
||||
def generate_spec(self, app: KayaApp) -> Mapping[str, Any]:
|
||||
spec: Dict[str, Any] = dict(super().generate_spec(app))
|
||||
paths = spec.get("paths")
|
||||
if isinstance(paths, Mapping):
|
||||
for path, item in paths.items():
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
if path in PUBLIC_PATHS or not path.startswith("/api/"):
|
||||
security: List[Dict[str, List[str]]] = []
|
||||
elif path in SESSION_ONLY_PATHS:
|
||||
security = SESSION_SECURITY
|
||||
else:
|
||||
security = API_SECURITY
|
||||
for operation in item.values():
|
||||
if isinstance(operation, dict):
|
||||
operation["security"] = security
|
||||
return spec
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Cursor-based pagination for listing endpoints.
|
||||
|
||||
Uses keyset pagination (not OFFSET/LIMIT): each page ends with an opaque
|
||||
cursor encoding the sort key tuple of the last item on that page; the next
|
||||
request passes that cursor and the query continues from the point it left
|
||||
off. This is stable under concurrent inserts and cheaper than OFFSET for
|
||||
large result sets.
|
||||
|
||||
Cursor is a base64-encoded JSON object mapping the sort-field names to the
|
||||
values of the last item on the previous page. It's opaque to callers and
|
||||
must be treated as a black box.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||
|
||||
from tortoise.queryset import QuerySet
|
||||
|
||||
from .http import extract_query_params
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tortoise.models import Model
|
||||
|
||||
DEFAULT_LIMIT = 20
|
||||
MAX_LIMIT = 100
|
||||
MIN_LIMIT = 1
|
||||
|
||||
CURSOR_PARAM = "cursor"
|
||||
LIMIT_PARAM = "limit"
|
||||
|
||||
|
||||
class CursorDecodeError(ValueError):
|
||||
"""Raised when the ``cursor`` query param cannot be decoded."""
|
||||
|
||||
|
||||
def encode_cursor(values: Dict[str, Any]) -> str:
|
||||
import base64
|
||||
import json
|
||||
|
||||
# ``default=str`` handles datetimes/dates (ISO) so keyset cursors can
|
||||
# carry date-typed sort fields.
|
||||
raw = json.dumps(values, separators=(",", ":"), default=str).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def decode_cursor(s: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
import base64
|
||||
import json
|
||||
|
||||
if s is None or s == "":
|
||||
return None
|
||||
try:
|
||||
# Tolerate missing padding.
|
||||
padded = s + "=" * (-len(s) % 4)
|
||||
raw = base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
obj = json.loads(raw.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise CursorDecodeError("invalid cursor") from exc
|
||||
if not isinstance(obj, dict):
|
||||
raise CursorDecodeError("invalid cursor")
|
||||
return obj
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Cursor:
|
||||
limit: int
|
||||
after: Optional[Dict[str, Any]]
|
||||
|
||||
|
||||
def parse_cursor_params(query_string: str) -> Cursor:
|
||||
params = extract_query_params(query_string)
|
||||
limit_raw = params.get(LIMIT_PARAM)
|
||||
if limit_raw:
|
||||
try:
|
||||
limit = int(limit_raw[0])
|
||||
except ValueError as exc:
|
||||
raise CursorDecodeError("invalid limit") from exc
|
||||
else:
|
||||
limit = DEFAULT_LIMIT
|
||||
limit = max(MIN_LIMIT, min(MAX_LIMIT, limit))
|
||||
after = decode_cursor(params.get(CURSOR_PARAM, [None])[0])
|
||||
return Cursor(limit=limit, after=after)
|
||||
|
||||
|
||||
# A sort field: (model field name, "ASC" or "DESC"). The tuple is the full
|
||||
# keyset; the cursor encodes exactly these fields.
|
||||
Sort = List[Tuple[str, str]]
|
||||
|
||||
|
||||
def _coerce_sort_values(
|
||||
model: "type[Model]", sort: Sort, after: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Restore the Python type of every decoded cursor value.
|
||||
|
||||
The cursor is JSON, so field values travel as strings (datetimes via
|
||||
``default=str``). asyncpg rejects strings where a ``timestamp``
|
||||
parameter is expected, and sqlite only tolerates them by accident, so
|
||||
each value is run through its model field's ``to_python_value`` before
|
||||
it reaches the keyset filter.
|
||||
"""
|
||||
fields = model._meta.fields_map
|
||||
coerced: Dict[str, Any] = {}
|
||||
for field, _ in sort:
|
||||
value = after.get(field)
|
||||
field_obj = fields.get(field)
|
||||
if value is not None and field_obj is not None:
|
||||
value = field_obj.to_python_value(value)
|
||||
coerced[field] = value
|
||||
return coerced
|
||||
|
||||
|
||||
def _keyset_where(sort: Sort, after: Dict[str, Any]):
|
||||
"""Build a Tortoise ``Q`` filter from a cursor."""
|
||||
from tortoise.queryset import Q # local to keep import edge narrow
|
||||
|
||||
clauses = []
|
||||
for i, (field, direction) in enumerate(sort):
|
||||
key = f"{field}__{'gt' if direction == 'ASC' else 'lt'}"
|
||||
value = after.get(field)
|
||||
if value is None:
|
||||
return Q()
|
||||
clause = Q(**{key: value})
|
||||
for j in range(i):
|
||||
prev_field, _ = sort[j]
|
||||
prev_value = after.get(prev_field)
|
||||
if prev_value is None:
|
||||
return Q()
|
||||
clause = clause & Q(**{prev_field: prev_value})
|
||||
clauses.append(clause)
|
||||
result = clauses[0]
|
||||
for clause in clauses[1:]:
|
||||
result = result | clause
|
||||
return result
|
||||
|
||||
|
||||
async def paginate(
|
||||
queryset: QuerySet,
|
||||
sort: Sort,
|
||||
cursor: Cursor,
|
||||
) -> Tuple[List[Any], Optional[str]]:
|
||||
"""Return one page of ``queryset`` plus the opaque cursor to continue."""
|
||||
order_by: List[str] = []
|
||||
for field, direction in sort:
|
||||
order_by.append(field if direction == "ASC" else f"-{field}")
|
||||
qs = queryset.order_by(*order_by)
|
||||
if cursor.after:
|
||||
after = _coerce_sort_values(queryset.model, sort, cursor.after)
|
||||
qs = qs.filter(_keyset_where(sort, after))
|
||||
rows = await qs.limit(cursor.limit + 1)
|
||||
if len(rows) <= cursor.limit:
|
||||
return rows, None
|
||||
page = rows[: cursor.limit]
|
||||
last = page[-1]
|
||||
key: Dict[str, Any] = {}
|
||||
for field, _ in sort:
|
||||
key[field] = getattr(last, field)
|
||||
return page, encode_cursor(key)
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP route modules."""
|
||||
@@ -0,0 +1,39 @@
|
||||
"""OIDC provider list and provider-agnostic login/logout routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
|
||||
from ..http import send_json
|
||||
from ..openapi import json_response, schema_ref
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Kca
|
||||
|
||||
|
||||
def _login_url(kca: "Kca", provider: str) -> str:
|
||||
return f"{kca.oidc.base_path}/{provider}/login"
|
||||
|
||||
|
||||
def register(app: KayaApp, kca: "Kca") -> None:
|
||||
providers = list(kca.oidc.providers)
|
||||
|
||||
@app.GET("/api/auth/providers")
|
||||
@operation(summary="Configured login providers", tags=["auth"],
|
||||
responses={200: json_response("The configured providers", schema_ref("AuthProvidersPage"))})
|
||||
async def auth_providers(ctx: HttpContext) -> None:
|
||||
await send_json(ctx, 200, {
|
||||
"providers": [{"name": name, "login_url": _login_url(kca, name)} for name in providers]
|
||||
})
|
||||
|
||||
@app.GET("/auth/login")
|
||||
async def login(ctx: HttpContext) -> None:
|
||||
await ctx.send_empty(302, {"Location": _login_url(kca, providers[0])})
|
||||
|
||||
@app.GET("/auth/logout")
|
||||
async def logout(ctx: HttpContext) -> None:
|
||||
logged_in = kca.oidc.logged_in_providers(ctx)
|
||||
location = f"{kca.oidc.base_path}/{logged_in[0]}/logout" if logged_in else "/"
|
||||
await ctx.send_empty(302, {"Location": location})
|
||||
@@ -0,0 +1,612 @@
|
||||
"""Certificates: issue, renew, revoke, CA properties and revocation manager."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
|
||||
from kca.core import certificates as cert_core
|
||||
from kca.core import crls as crl_core
|
||||
from kca.core import exports as export_core
|
||||
from kca.core import requests as req_core
|
||||
from kca.core import templates as template_core
|
||||
from kca.core.crypto import EncryptedBlob, decrypt_blob, encrypt_blob
|
||||
|
||||
from ..auth import authenticated_user, require_auth
|
||||
from ..config import settings
|
||||
from ..http import JsonRequestError, read_json, send_error, send_json
|
||||
from ..models import Authority, Certificate, CertificateRevocation, Crl, Key, Request, Template
|
||||
from ..openapi import error_response, json_response, schema_ref
|
||||
from ..service import (
|
||||
ValidationError,
|
||||
chain_for_certificate,
|
||||
delete_certificate_cascade,
|
||||
generate_crl,
|
||||
issue_certificate,
|
||||
load_private_key,
|
||||
parse_datetime,
|
||||
parse_uuid,
|
||||
renew_certificate,
|
||||
revoke_certificate,
|
||||
save_key,
|
||||
search_text,
|
||||
serialize_certificate_detail,
|
||||
serialize_certificate_summary,
|
||||
serialize_revocation,
|
||||
serialize_template_summary,
|
||||
serialize_crl_detail,
|
||||
vault_for_owner,
|
||||
vault_settings,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Kca
|
||||
|
||||
|
||||
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
||||
user = await authenticated_user(kca.oidc, ctx)
|
||||
assert user is not None
|
||||
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
|
||||
if vault is None:
|
||||
await send_error(ctx, 404, "vault not found")
|
||||
return vault
|
||||
|
||||
|
||||
async def _cert(vault, cert_id: str) -> Optional[Certificate]:
|
||||
uuid = parse_uuid(cert_id)
|
||||
return await Certificate.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
|
||||
|
||||
|
||||
def register(app: KayaApp, kca: "Kca") -> None:
|
||||
@app.GET("/api/vaults/${vault_id}/certificates")
|
||||
@operation(summary="List certificates", tags=["certificates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def list_certificates(ctx: HttpContext, vault_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
rows = await Certificate.filter(vault_id=vault.id).order_by("name", "id")
|
||||
await send_json(ctx, 200, {"results": [await serialize_certificate_summary(c) for c in rows]})
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/certificates")
|
||||
@operation(summary="Issue a certificate", tags=["certificates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def create_certificate(ctx: HttpContext, vault_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
user = await authenticated_user(kca.oidc, ctx)
|
||||
assert user is not None
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
try:
|
||||
cert = await issue_certificate(vault, body, user=user)
|
||||
except ValidationError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
except ValueError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
await send_json(ctx, 201, await serialize_certificate_detail(cert, vault))
|
||||
|
||||
@app.GET("/api/vaults/${vault_id}/certificates/${cert_id}")
|
||||
@operation(summary="Certificate detail", tags=["certificates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def get_certificate(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None:
|
||||
await send_error(ctx, 404, "certificate not found")
|
||||
return
|
||||
await send_json(ctx, 200, await serialize_certificate_detail(cert, vault))
|
||||
|
||||
@app.PATCH("/api/vaults/${vault_id}/certificates/${cert_id}")
|
||||
@operation(summary="Update certificate metadata", tags=["certificates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def update_certificate(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None:
|
||||
await send_error(ctx, 404, "certificate not found")
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
if "version" in body and int(body["version"]) != cert.version:
|
||||
await send_error(ctx, 409, "the certificate changed since it was loaded")
|
||||
return
|
||||
if "name" in body:
|
||||
if not isinstance(body["name"], str) or not body["name"].strip():
|
||||
await send_error(ctx, 400, "name must not be empty")
|
||||
return
|
||||
cert.name = body["name"].strip()[:200]
|
||||
if "comment" in body:
|
||||
cert.comment = str(body["comment"])[:8000]
|
||||
cert.version += 1
|
||||
await cert.save()
|
||||
await send_json(ctx, 200, await serialize_certificate_detail(cert, vault))
|
||||
|
||||
@app.DELETE("/api/vaults/${vault_id}/certificates/${cert_id}")
|
||||
@operation(summary="Delete a certificate", tags=["certificates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def delete_certificate(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None:
|
||||
await send_error(ctx, 404, "certificate not found")
|
||||
return
|
||||
await delete_certificate_cascade(vault, cert)
|
||||
await ctx.send_empty(204)
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/export")
|
||||
@operation(summary="Export a certificate", tags=["certificates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def export_certificate(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None:
|
||||
await send_error(ctx, 404, "certificate not found")
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
parsed = cert_core.parse_certificate(bytes(cert.cert_der))
|
||||
chain = [cert_core.parse_certificate(bytes(item.cert_der)) for item in await chain_for_certificate(cert, vault)]
|
||||
private_key = None
|
||||
if cert.key_id:
|
||||
key = await Key.get_or_none(id=cert.key_id)
|
||||
if key is not None and key.has_private:
|
||||
private_key = await load_private_key(key)
|
||||
authority = await Authority.get_or_none(certificate_id=cert.id)
|
||||
tls_auth = None
|
||||
if authority is not None and authority.tls_auth_key and authority.tls_auth_key_nonce:
|
||||
raw = decrypt_blob(
|
||||
EncryptedBlob(bytes(authority.tls_auth_key), bytes(authority.tls_auth_key_nonce)),
|
||||
master_key=settings.master_key,
|
||||
aad=str(cert.id).encode(),
|
||||
)
|
||||
tls_auth = _format_tls_auth(raw)
|
||||
try:
|
||||
result = export_core.export_certificate(
|
||||
parsed,
|
||||
str(body.get("format") or "pem"),
|
||||
name=cert.name,
|
||||
chain=chain,
|
||||
private_key=private_key,
|
||||
password=body.get("password"),
|
||||
tls_auth_key=tls_auth,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
await send_json(ctx, 200, {
|
||||
"filename": result.filename,
|
||||
"content_type": result.content_type,
|
||||
"content_base64": base64.b64encode(result.data).decode("ascii"),
|
||||
})
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/renew")
|
||||
@operation(summary="Renew a certificate", tags=["certificates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def renew(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None:
|
||||
await send_error(ctx, 404, "certificate not found")
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
try:
|
||||
new_cert = await renew_certificate(vault, cert, body)
|
||||
except ValueError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
await send_json(ctx, 201, await serialize_certificate_detail(new_cert, vault))
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/revoke")
|
||||
@operation(summary="Revoke a certificate", tags=["certificates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def revoke(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None:
|
||||
await send_error(ctx, 404, "certificate not found")
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
try:
|
||||
row = await revoke_certificate(vault, cert, body)
|
||||
except ValueError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
await send_json(ctx, 200, await serialize_revocation(row))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CA properties / revocation manager / CRL generation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.GET("/api/vaults/${vault_id}/certificates/${cert_id}/ca")
|
||||
@operation(summary="CA properties", tags=["ca"])
|
||||
@require_auth(kca.oidc)
|
||||
async def get_ca(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None or not cert.is_ca:
|
||||
await send_error(ctx, 404, "CA certificate not found")
|
||||
return
|
||||
authority = await Authority.get_or_none(certificate_id=cert.id)
|
||||
if authority is None:
|
||||
authority = await Authority.create(certificate_id=cert.id, crl_days=30)
|
||||
await send_json(ctx, 200, {
|
||||
"certificate_id": str(cert.id),
|
||||
"crl_days": authority.crl_days,
|
||||
"crl_number": authority.crl_number,
|
||||
"crl_expire": authority.crl_expire.isoformat() if authority.crl_expire else None,
|
||||
"default_template_id": str(authority.template_id) if authority.template_id else None,
|
||||
"has_tls_auth_key": bool(authority.tls_auth_key),
|
||||
})
|
||||
|
||||
@app.PATCH("/api/vaults/${vault_id}/certificates/${cert_id}/ca")
|
||||
@operation(summary="Update CA properties", tags=["ca"])
|
||||
@require_auth(kca.oidc)
|
||||
async def update_ca(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None or not cert.is_ca:
|
||||
await send_error(ctx, 404, "CA certificate not found")
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
authority = await Authority.get_or_none(certificate_id=cert.id)
|
||||
if authority is None:
|
||||
authority = await Authority.create(certificate_id=cert.id, crl_days=30)
|
||||
if "crl_days" in body:
|
||||
try:
|
||||
days = int(body["crl_days"])
|
||||
except (TypeError, ValueError):
|
||||
await send_error(ctx, 400, "crl_days must be an integer")
|
||||
return
|
||||
if days <= 0:
|
||||
await send_error(ctx, 400, "crl_days must be positive")
|
||||
return
|
||||
authority.crl_days = days
|
||||
if "template_id" in body:
|
||||
template_id = body.get("template_id")
|
||||
if template_id:
|
||||
template = await Template.get_or_none(id=parse_uuid(str(template_id)), vault_id=vault.id)
|
||||
if template is None:
|
||||
await send_error(ctx, 400, "template not found")
|
||||
return
|
||||
authority.template_id = template.id
|
||||
else:
|
||||
authority.template_id = None
|
||||
await authority.save()
|
||||
await send_json(ctx, 200, {
|
||||
"certificate_id": str(cert.id),
|
||||
"crl_days": authority.crl_days,
|
||||
"crl_number": authority.crl_number,
|
||||
"crl_expire": authority.crl_expire.isoformat() if authority.crl_expire else None,
|
||||
"default_template_id": str(authority.template_id) if authority.template_id else None,
|
||||
"has_tls_auth_key": bool(authority.tls_auth_key),
|
||||
})
|
||||
|
||||
@app.GET("/api/vaults/${vault_id}/certificates/${cert_id}/revocations")
|
||||
@operation(summary="List CA revocations", tags=["ca"])
|
||||
@require_auth(kca.oidc)
|
||||
async def list_revocations(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None or not cert.is_ca:
|
||||
await send_error(ctx, 404, "CA certificate not found")
|
||||
return
|
||||
rows = await CertificateRevocation.filter(ca_id=cert.id).order_by("-revoked_at")
|
||||
await send_json(ctx, 200, {"results": [await serialize_revocation(row) for row in rows]})
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/revocations")
|
||||
@operation(summary="Add a revocation manually", tags=["ca"])
|
||||
@require_auth(kca.oidc)
|
||||
async def add_revocation(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None or not cert.is_ca:
|
||||
await send_error(ctx, 404, "CA certificate not found")
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
serial = str(body.get("serial_hex") or "").strip().lower().replace(":", "")
|
||||
if not serial:
|
||||
await send_error(ctx, 400, "serial_hex is required")
|
||||
return
|
||||
try:
|
||||
revoked_at = parse_datetime(body.get("revoked_at") or datetime.now(timezone.utc).isoformat())
|
||||
invalidity = parse_datetime(body["invalidity_date"]) if body.get("invalidity_date") else None
|
||||
except ValidationError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
reason = str(body.get("reason") or "unspecified")
|
||||
if reason not in {item["name"] for item in crl_core.CRL_REASONS}:
|
||||
await send_error(ctx, 400, "unknown revocation reason")
|
||||
return
|
||||
row, created = await CertificateRevocation.get_or_create(
|
||||
ca_id=cert.id,
|
||||
serial_hex=serial,
|
||||
defaults={"revoked_at": revoked_at, "invalidity_date": invalidity, "reason": reason},
|
||||
)
|
||||
if not created:
|
||||
await send_error(ctx, 409, "this serial is already revoked")
|
||||
return
|
||||
await send_json(ctx, 201, await serialize_revocation(row))
|
||||
|
||||
@app.PATCH("/api/vaults/${vault_id}/certificates/${cert_id}/revocations/${rev_id}")
|
||||
@operation(summary="Edit a revocation", tags=["ca"])
|
||||
@require_auth(kca.oidc)
|
||||
async def update_revocation(ctx: HttpContext, vault_id: str, cert_id: str, rev_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None:
|
||||
await send_error(ctx, 404, "CA certificate not found")
|
||||
return
|
||||
row = await CertificateRevocation.get_or_none(id=parse_uuid(rev_id), ca_id=cert.id)
|
||||
if row is None:
|
||||
await send_error(ctx, 404, "revocation not found")
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
try:
|
||||
if "revoked_at" in body:
|
||||
row.revoked_at = parse_datetime(body["revoked_at"], "revoked_at")
|
||||
if "invalidity_date" in body:
|
||||
row.invalidity_date = (
|
||||
parse_datetime(body["invalidity_date"], "invalidity_date")
|
||||
if body["invalidity_date"]
|
||||
else None
|
||||
)
|
||||
except ValidationError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
if "reason" in body:
|
||||
if str(body["reason"]) not in {item["name"] for item in crl_core.CRL_REASONS}:
|
||||
await send_error(ctx, 400, "unknown revocation reason")
|
||||
return
|
||||
row.reason = str(body["reason"])
|
||||
await row.save()
|
||||
await send_json(ctx, 200, await serialize_revocation(row))
|
||||
|
||||
@app.DELETE("/api/vaults/${vault_id}/certificates/${cert_id}/revocations/${rev_id}")
|
||||
@operation(summary="Delete a revocation", tags=["ca"])
|
||||
@require_auth(kca.oidc)
|
||||
async def delete_revocation(ctx: HttpContext, vault_id: str, cert_id: str, rev_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None:
|
||||
await send_error(ctx, 404, "CA certificate not found")
|
||||
return
|
||||
row = await CertificateRevocation.get_or_none(id=parse_uuid(rev_id), ca_id=cert.id)
|
||||
if row is None:
|
||||
await send_error(ctx, 404, "revocation not found")
|
||||
return
|
||||
await row.delete()
|
||||
await ctx.send_empty(204)
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/crl")
|
||||
@operation(summary="Generate a CRL", tags=["ca"])
|
||||
@require_auth(kca.oidc)
|
||||
async def create_crl(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None:
|
||||
await send_error(ctx, 404, "certificate not found")
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
try:
|
||||
row = await generate_crl(vault, cert, body)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
await send_json(ctx, 201, await serialize_crl_detail(row))
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/tls-auth")
|
||||
@operation(summary="Generate or import the CA's OpenVPN tls-auth key", tags=["ca"])
|
||||
@require_auth(kca.oidc)
|
||||
async def tls_auth(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None or not cert.is_ca:
|
||||
await send_error(ctx, 404, "CA certificate not found")
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
authority = await Authority.get_or_none(certificate_id=cert.id)
|
||||
if authority is None:
|
||||
authority = await Authority.create(certificate_id=cert.id, crl_days=30)
|
||||
raw = None
|
||||
if body.get("key"):
|
||||
text = str(body["key"])
|
||||
raw = _parse_tls_auth(text)
|
||||
if raw is None:
|
||||
await send_error(ctx, 400, "invalid OpenVPN tls-auth key")
|
||||
return
|
||||
else:
|
||||
raw = os.urandom(32)
|
||||
blob = encrypt_blob(raw, master_key=settings.master_key, aad=str(cert.id).encode())
|
||||
authority.tls_auth_key = blob.ciphertext
|
||||
authority.tls_auth_key_nonce = blob.nonce
|
||||
await authority.save(update_fields=["tls_auth_key", "tls_auth_key_nonce"])
|
||||
await send_json(ctx, 200, {"key": _format_tls_auth(raw)})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Transforms
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/transform/open-key")
|
||||
@operation(summary="Extract the public key from a certificate", tags=["certificates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def extract_key(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None:
|
||||
await send_error(ctx, 404, "certificate not found")
|
||||
return
|
||||
parsed = cert_core.parse_certificate(bytes(cert.cert_der))
|
||||
key = await save_key(
|
||||
vault,
|
||||
parsed.public_key(),
|
||||
name=f"{cert.name} public key",
|
||||
comment=f"Extracted from certificate {cert.name}",
|
||||
source="transformed",
|
||||
)
|
||||
await send_json(ctx, 201, {"id": str(key.id), "name": key.name})
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/transform/request")
|
||||
@operation(summary="Create a request from a certificate", tags=["certificates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def to_request(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None:
|
||||
await send_error(ctx, 404, "certificate not found")
|
||||
return
|
||||
key = await Key.get_or_none(id=cert.key_id) if cert.key_id else None
|
||||
if key is None or not key.has_private:
|
||||
await send_error(ctx, 400, "the certificate has no private key")
|
||||
return
|
||||
parsed = cert_core.parse_certificate(bytes(cert.cert_der))
|
||||
try:
|
||||
csr = req_core.build_csr(
|
||||
subject=parsed.subject,
|
||||
private_key=await load_private_key(key),
|
||||
extension_spec=cert_core.certificate_extension_specs(parsed),
|
||||
signature_hash=cert_core.signature_hash_name(parsed),
|
||||
)
|
||||
except ValueError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
info = req_core.request_info(csr)
|
||||
row = await Request.create(
|
||||
vault_id=vault.id,
|
||||
key_id=key.id,
|
||||
name=f"{cert.name} request",
|
||||
comment=f"Transformed from certificate {cert.name}",
|
||||
source="transformed",
|
||||
subject_der=info.subject.public_bytes(),
|
||||
req_der=req_core.csr_der(csr),
|
||||
public_key_der=info.public_key_der,
|
||||
request_sha256=info.fingerprint_sha256,
|
||||
attributes=info.attributes,
|
||||
search_text=search_text(cert.name, "request", info.subject.rfc4514_string()),
|
||||
)
|
||||
await send_json(ctx, 201, {"id": str(row.id), "name": row.name})
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/transform/template")
|
||||
@operation(summary="Create a template from a certificate", tags=["certificates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def to_template(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None:
|
||||
await send_error(ctx, 404, "certificate not found")
|
||||
return
|
||||
spec = template_core.template_from_certificate(
|
||||
cert_core.parse_certificate(bytes(cert.cert_der)),
|
||||
cert.name,
|
||||
f"Transformed from certificate {cert.name}",
|
||||
)
|
||||
template = await Template.create(
|
||||
vault_id=vault.id,
|
||||
name=spec["name"],
|
||||
comment=spec["comment"],
|
||||
source="transformed",
|
||||
subject=spec["subject"],
|
||||
settings=spec["settings"],
|
||||
search_text=search_text(spec["name"], spec["comment"]),
|
||||
)
|
||||
await send_json(ctx, 201, await serialize_template_summary(template))
|
||||
|
||||
|
||||
def _parse_tls_auth(text: str) -> Optional[bytes]:
|
||||
begin = "-----BEGIN OpenVPN Static key V1-----"
|
||||
end = "-----END OpenVPN Static key V1-----"
|
||||
if begin not in text or end not in text:
|
||||
return None
|
||||
body = text.split(begin, 1)[1].split(end, 1)[0]
|
||||
try:
|
||||
data = bytes.fromhex("".join(body.split()))
|
||||
except ValueError:
|
||||
return None
|
||||
return data if len(data) == 32 else None
|
||||
|
||||
|
||||
def _format_tls_auth(raw: bytes) -> str:
|
||||
text = raw.hex()
|
||||
return (
|
||||
"-----BEGIN OpenVPN Static key V1-----\n"
|
||||
+ "\n".join(text[i : i + 32] for i in range(0, len(text), 32))
|
||||
+ "\n-----END OpenVPN Static key V1-----\n"
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Revocation list list/detail/delete/export."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
|
||||
from kca.core import crls as crl_core
|
||||
from kca.core import exports as export_core
|
||||
|
||||
from ..auth import authenticated_user, require_auth
|
||||
from ..http import JsonRequestError, read_json, send_error, send_json
|
||||
from ..models import Crl
|
||||
from ..openapi import error_response, json_response, schema_ref
|
||||
from ..service import (
|
||||
parse_uuid,
|
||||
serialize_crl_detail,
|
||||
serialize_crl_summary,
|
||||
vault_for_owner,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Kca
|
||||
|
||||
|
||||
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
||||
user = await authenticated_user(kca.oidc, ctx)
|
||||
assert user is not None
|
||||
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
|
||||
if vault is None:
|
||||
await send_error(ctx, 404, "vault not found")
|
||||
return vault
|
||||
|
||||
|
||||
async def _crl(vault, crl_id: str) -> Optional[Crl]:
|
||||
uuid = parse_uuid(crl_id)
|
||||
return await Crl.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
|
||||
|
||||
|
||||
def register(app: KayaApp, kca: "Kca") -> None:
|
||||
@app.GET("/api/vaults/${vault_id}/crls")
|
||||
@operation(summary="List revocation lists", tags=["crls"])
|
||||
@require_auth(kca.oidc)
|
||||
async def list_crls(ctx: HttpContext, vault_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
rows = await Crl.filter(vault_id=vault.id).order_by("-last_update")
|
||||
await send_json(ctx, 200, {"results": [await serialize_crl_summary(c) for c in rows]})
|
||||
|
||||
@app.GET("/api/vaults/${vault_id}/crls/${crl_id}")
|
||||
@operation(summary="Revocation list detail", tags=["crls"])
|
||||
@require_auth(kca.oidc)
|
||||
async def get_crl(ctx: HttpContext, vault_id: str, crl_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
row = await _crl(vault, crl_id)
|
||||
if row is None:
|
||||
await send_error(ctx, 404, "CRL not found")
|
||||
return
|
||||
await send_json(ctx, 200, await serialize_crl_detail(row))
|
||||
|
||||
@app.DELETE("/api/vaults/${vault_id}/crls/${crl_id}")
|
||||
@operation(summary="Delete a revocation list", tags=["crls"])
|
||||
@require_auth(kca.oidc)
|
||||
async def delete_crl(ctx: HttpContext, vault_id: str, crl_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
row = await _crl(vault, crl_id)
|
||||
if row is None:
|
||||
await send_error(ctx, 404, "CRL not found")
|
||||
return
|
||||
await row.delete()
|
||||
await ctx.send_empty(204)
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/crls/${crl_id}/export")
|
||||
@operation(summary="Export a revocation list", tags=["crls"])
|
||||
@require_auth(kca.oidc)
|
||||
async def export_crl(ctx: HttpContext, vault_id: str, crl_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
row = await _crl(vault, crl_id)
|
||||
if row is None:
|
||||
await send_error(ctx, 404, "CRL not found")
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
try:
|
||||
result = export_core.export_crl(
|
||||
crl_core.parse_crl(bytes(row.crl_der)),
|
||||
str(body.get("format") or "pem"),
|
||||
name=row.name,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
await send_json(ctx, 200, {
|
||||
"filename": result.filename,
|
||||
"content_type": result.content_type,
|
||||
"content_base64": base64.b64encode(result.data).decode("ascii"),
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Liveness probe."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
|
||||
from ..openapi import json_response, schema_ref
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Kca
|
||||
|
||||
|
||||
def register(app: KayaApp, kca: "Kca") -> None:
|
||||
@app.GET("/api/health")
|
||||
@operation(summary="Health check", tags=["health"],
|
||||
responses={200: json_response("The service is up", schema_ref("Health"))})
|
||||
async def health(ctx: HttpContext) -> None:
|
||||
await ctx.send_bytes(200, b'{"status":"ok"}', {"content-type": ("application/json",)})
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Import files, PEM bundles and XCA databases."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
|
||||
from kca.core import certificates as cert_core
|
||||
from kca.core import crls as crl_core
|
||||
from kca.core import imports as import_core
|
||||
from kca.core import keys as key_core
|
||||
from kca.core import requests as req_core
|
||||
|
||||
from ..auth import authenticated_user, require_auth
|
||||
from ..config import settings
|
||||
from ..http import JsonRequestError, read_json, send_error, send_json
|
||||
from ..models import Certificate, Crl, Key, Request
|
||||
from ..openapi import error_response, json_response, schema_ref
|
||||
from ..service import (
|
||||
import_parsed_items,
|
||||
parse_uuid,
|
||||
vault_for_owner,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Kca
|
||||
|
||||
|
||||
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
||||
user = await authenticated_user(kca.oidc, ctx)
|
||||
assert user is not None
|
||||
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
|
||||
if vault is None:
|
||||
await send_error(ctx, 404, "vault not found")
|
||||
return vault
|
||||
|
||||
|
||||
async def _duplicate(vault, item: import_core.ParsedItem) -> bool:
|
||||
if item.kind == "key":
|
||||
info = key_core.key_info(item.obj)
|
||||
return await Key.filter(vault_id=vault.id, public_key_sha256=info.public_sha256).exists()
|
||||
if item.kind == "certificate":
|
||||
info = cert_core.certificate_info(item.obj)
|
||||
return await Certificate.filter(vault_id=vault.id, fingerprint_sha256=info.fingerprint_sha256).exists()
|
||||
if item.kind == "request":
|
||||
info = req_core.request_info(item.obj)
|
||||
return await Request.filter(vault_id=vault.id, request_sha256=info.fingerprint_sha256).exists()
|
||||
if item.kind == "crl":
|
||||
info = crl_core.crl_info(item.obj)
|
||||
return await Crl.filter(vault_id=vault.id, crl_hash=info.fingerprint_sha256).exists()
|
||||
return False
|
||||
|
||||
|
||||
def register(app: KayaApp, kca: "Kca") -> None:
|
||||
@app.POST("/api/vaults/${vault_id}/import")
|
||||
@operation(summary="Import items into a vault", tags=["import"],
|
||||
responses={200: json_response("Import result", schema_ref("ImportResult")),
|
||||
400: error_response("Invalid file, password or selection"),
|
||||
401: error_response("Authentication required"),
|
||||
404: error_response("Vault not found"),
|
||||
413: error_response("File too large")})
|
||||
@require_auth(kca.oidc)
|
||||
async def import_items(ctx: HttpContext, vault_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
filename = str(body.get("filename") or "import")
|
||||
password = body.get("password")
|
||||
if password is not None and not isinstance(password, str):
|
||||
await send_error(ctx, 400, "password must be a string")
|
||||
return
|
||||
raw: Optional[bytes] = None
|
||||
if body.get("content_base64"):
|
||||
try:
|
||||
raw = base64.b64decode(str(body["content_base64"]), validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
await send_error(ctx, 400, "content_base64 is not valid base64")
|
||||
return
|
||||
elif body.get("pem"):
|
||||
raw = str(body["pem"]).encode("utf-8")
|
||||
if raw is None:
|
||||
await send_error(ctx, 400, "content_base64 or pem is required")
|
||||
return
|
||||
if len(raw) > settings.max_upload_bytes:
|
||||
await send_error(ctx, 413, f"the file is larger than {settings.max_upload_bytes} bytes")
|
||||
return
|
||||
dry_run = bool(body.get("dry_run"))
|
||||
use_xca_db = bool(body.get("xca_db")) or filename.lower().endswith(".xdb")
|
||||
try:
|
||||
if use_xca_db:
|
||||
items = import_core.parse_xca_database(raw, filename, password)
|
||||
else:
|
||||
items = import_core.parse_file(raw, filename, password)
|
||||
except ValueError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
if dry_run:
|
||||
preview = []
|
||||
for index, item in enumerate(items):
|
||||
preview.append(
|
||||
{
|
||||
"index": index,
|
||||
"kind": item.kind,
|
||||
"name": item.name,
|
||||
"summary": item.summary,
|
||||
"is_private": item.is_private,
|
||||
"duplicate": await _duplicate(vault, item),
|
||||
"warnings": item.warnings,
|
||||
}
|
||||
)
|
||||
await send_json(ctx, 200, {"dry_run": True, "items": preview, "count": len(preview)})
|
||||
return
|
||||
selected = body.get("selected")
|
||||
if selected is not None:
|
||||
if not isinstance(selected, list) or not all(isinstance(i, int) for i in selected):
|
||||
await send_error(ctx, 400, "selected must be a list of item indexes")
|
||||
return
|
||||
result = await import_parsed_items(vault, items, selected=selected)
|
||||
await send_json(ctx, 200, result)
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Key generation, detail, rename/delete and export."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
from tortoise.exceptions import IntegrityError
|
||||
|
||||
from kca.core import exports as export_core
|
||||
from kca.core import keys as key_core
|
||||
|
||||
from ..auth import authenticated_user, require_auth
|
||||
from ..http import JsonRequestError, read_json, send_error, send_json
|
||||
from ..models import Certificate, Key, Request
|
||||
from ..openapi import error_response, json_response, schema_ref
|
||||
from ..service import (
|
||||
ValidationError,
|
||||
parse_key_generation,
|
||||
parse_uuid,
|
||||
save_key,
|
||||
serialize_key_detail,
|
||||
serialize_key_summary,
|
||||
load_private_key,
|
||||
vault_for_owner,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Kca
|
||||
|
||||
# Key generation is CPU-bound; this bounds concurrent RSA/DSA jobs.
|
||||
_keygen_semaphore = asyncio.Semaphore(2)
|
||||
|
||||
|
||||
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
||||
user = await authenticated_user(kca.oidc, ctx)
|
||||
assert user is not None
|
||||
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
|
||||
if vault is None:
|
||||
await send_error(ctx, 404, "vault not found")
|
||||
return vault
|
||||
|
||||
|
||||
async def _key(vault, key_id: str) -> Optional[Key]:
|
||||
uuid = parse_uuid(key_id)
|
||||
return await Key.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
|
||||
|
||||
|
||||
def register(app: KayaApp, kca: "Kca") -> None:
|
||||
@app.GET("/api/vaults/${vault_id}/keys")
|
||||
@operation(summary="List keys", tags=["keys"],
|
||||
responses={200: json_response("Keys", schema_ref("KeysPage")),
|
||||
401: error_response("Authentication required"),
|
||||
404: error_response("Vault not found")})
|
||||
@require_auth(kca.oidc)
|
||||
async def list_keys(ctx: HttpContext, vault_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
rows = await Key.filter(vault_id=vault.id).order_by("name", "id")
|
||||
await send_json(ctx, 200, {"results": [await serialize_key_summary(k) for k in rows]})
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/keys")
|
||||
@operation(summary="Generate a key", tags=["keys"],
|
||||
responses={201: json_response("Key created", schema_ref("KeyDetail")),
|
||||
400: error_response("Invalid key parameters"),
|
||||
401: error_response("Authentication required"),
|
||||
404: error_response("Vault not found"),
|
||||
409: error_response("Key already exists")})
|
||||
@require_auth(kca.oidc)
|
||||
async def generate_key(ctx: HttpContext, vault_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
try:
|
||||
params = parse_key_generation(body)
|
||||
except ValidationError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
try:
|
||||
async with _keygen_semaphore:
|
||||
generated = await asyncio.to_thread(
|
||||
key_core.generate_key,
|
||||
params["key_type"],
|
||||
params["size"],
|
||||
params["curve"],
|
||||
)
|
||||
key = await save_key(
|
||||
vault,
|
||||
generated,
|
||||
name=params["name"],
|
||||
comment=params["comment"],
|
||||
source="generated",
|
||||
)
|
||||
except ValueError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
except IntegrityError:
|
||||
await send_error(ctx, 409, "this key is already in the vault")
|
||||
return
|
||||
await send_json(ctx, 201, await serialize_key_detail(key))
|
||||
|
||||
@app.GET("/api/vaults/${vault_id}/keys/${key_id}")
|
||||
@operation(summary="Key detail", tags=["keys"],
|
||||
responses={200: json_response("Key", schema_ref("KeyDetail")),
|
||||
401: error_response("Authentication required"),
|
||||
404: error_response("Key not found")})
|
||||
@require_auth(kca.oidc)
|
||||
async def get_key(ctx: HttpContext, vault_id: str, key_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
key = await _key(vault, key_id)
|
||||
if key is None:
|
||||
await send_error(ctx, 404, "key not found")
|
||||
return
|
||||
await send_json(ctx, 200, await serialize_key_detail(key))
|
||||
|
||||
@app.PATCH("/api/vaults/${vault_id}/keys/${key_id}")
|
||||
@operation(summary="Rename a key", tags=["keys"],
|
||||
responses={200: json_response("Key updated", schema_ref("KeyDetail")),
|
||||
400: error_response("Invalid body"),
|
||||
401: error_response("Authentication required"),
|
||||
404: error_response("Key not found"),
|
||||
409: error_response("Stale version")})
|
||||
@require_auth(kca.oidc)
|
||||
async def update_key(ctx: HttpContext, vault_id: str, key_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
key = await _key(vault, key_id)
|
||||
if key is None:
|
||||
await send_error(ctx, 404, "key not found")
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
if "version" in body and int(body["version"]) != key.version:
|
||||
await send_error(ctx, 409, "the key changed since it was loaded")
|
||||
return
|
||||
if "name" in body:
|
||||
if not isinstance(body["name"], str) or not body["name"].strip():
|
||||
await send_error(ctx, 400, "name must not be empty")
|
||||
return
|
||||
key.name = body["name"].strip()[:200]
|
||||
if "comment" in body:
|
||||
if not isinstance(body["comment"], str):
|
||||
await send_error(ctx, 400, "comment must be a string")
|
||||
return
|
||||
key.comment = body["comment"][:8000]
|
||||
key.version += 1
|
||||
await key.save()
|
||||
await send_json(ctx, 200, await serialize_key_detail(key))
|
||||
|
||||
@app.DELETE("/api/vaults/${vault_id}/keys/${key_id}")
|
||||
@operation(summary="Delete a key", tags=["keys"],
|
||||
responses={204: {"description": "Deleted"},
|
||||
401: error_response("Authentication required"),
|
||||
404: error_response("Key not found")})
|
||||
@require_auth(kca.oidc)
|
||||
async def delete_key(ctx: HttpContext, vault_id: str, key_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
key = await _key(vault, key_id)
|
||||
if key is None:
|
||||
await send_error(ctx, 404, "key not found")
|
||||
return
|
||||
await Certificate.filter(key_id=key.id).update(key_id=None)
|
||||
await Request.filter(key_id=key.id).update(key_id=None)
|
||||
await key.delete()
|
||||
await ctx.send_empty(204)
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/keys/${key_id}/export")
|
||||
@operation(summary="Export a key", tags=["keys"])
|
||||
@require_auth(kca.oidc)
|
||||
async def export_key(ctx: HttpContext, vault_id: str, key_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
key = await _key(vault, key_id)
|
||||
if key is None:
|
||||
await send_error(ctx, 404, "key not found")
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
fmt = str(body.get("format") or "pem-public")
|
||||
password = body.get("password")
|
||||
if password is not None and not isinstance(password, str):
|
||||
await send_error(ctx, 400, "password must be a string")
|
||||
return
|
||||
try:
|
||||
if fmt in ("pem-private", "pem", "pem-encrypted", "der-private", "pkcs8", "pkcs8-private", "pkcs8-encrypted", "ssh-private", "jwk-private", "pvk"):
|
||||
key_obj = await load_private_key(key)
|
||||
is_private = True
|
||||
else:
|
||||
key_obj = key_core.public_key_from_der(bytes(key.public_der))
|
||||
is_private = False
|
||||
result = export_core.export_key(
|
||||
key_obj,
|
||||
fmt,
|
||||
name=key.name,
|
||||
password=password,
|
||||
is_private=is_private,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
await send_json(ctx, 200, {
|
||||
"filename": result.filename,
|
||||
"content_type": result.content_type,
|
||||
"content_base64": base64.b64encode(result.data).decode("ascii"),
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Whoami endpoint."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Dict
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
|
||||
from ..auth import PROFILE_CLAIMS, authenticated_user, require_auth
|
||||
from ..http import send_json
|
||||
from ..openapi import error_response, json_response, schema_ref
|
||||
from ..config import settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Kca
|
||||
|
||||
|
||||
def _serialize(user: Any) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {"sub": user.sub, "display_name": user.display_name}
|
||||
for claim in PROFILE_CLAIMS:
|
||||
payload[claim] = getattr(user, claim)
|
||||
return payload
|
||||
|
||||
|
||||
def register(app: KayaApp, kca: "Kca") -> None:
|
||||
@app.GET("/api/me")
|
||||
@operation(summary="Current user", tags=["auth"],
|
||||
responses={200: json_response("The current user", schema_ref("Me")),
|
||||
401: error_response("Not logged in")})
|
||||
@require_auth(kca.oidc)
|
||||
async def me(ctx: HttpContext) -> None:
|
||||
user = await authenticated_user(kca.oidc, ctx)
|
||||
assert user is not None
|
||||
payload = _serialize(user)
|
||||
payload["master_key_is_dev"] = settings.master_key_is_dev
|
||||
await send_json(ctx, 200, payload)
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Certificate signing requests: list, create, detail, export and transforms."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
from tortoise.exceptions import IntegrityError
|
||||
|
||||
from kca.core import exports as export_core
|
||||
from kca.core import keys as key_core
|
||||
from kca.core import requests as req_core
|
||||
from kca.core import templates as template_core
|
||||
|
||||
from ..auth import authenticated_user, require_auth
|
||||
from ..http import JsonRequestError, read_json, send_error, send_json
|
||||
from ..models import Key, Request, Template
|
||||
from ..openapi import error_response, json_response, schema_ref
|
||||
from ..service import (
|
||||
ValidationError,
|
||||
parse_extensions,
|
||||
parse_subject,
|
||||
parse_uuid,
|
||||
save_key,
|
||||
search_text,
|
||||
serialize_request_detail,
|
||||
serialize_request_summary,
|
||||
serialize_template_summary,
|
||||
load_private_key,
|
||||
vault_for_owner,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Kca
|
||||
|
||||
_keygen_semaphore = asyncio.Semaphore(2)
|
||||
|
||||
|
||||
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
||||
user = await authenticated_user(kca.oidc, ctx)
|
||||
assert user is not None
|
||||
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
|
||||
if vault is None:
|
||||
await send_error(ctx, 404, "vault not found")
|
||||
return vault
|
||||
|
||||
|
||||
async def _request(vault, request_id: str) -> Optional[Request]:
|
||||
uuid = parse_uuid(request_id)
|
||||
return await Request.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
|
||||
|
||||
|
||||
def register(app: KayaApp, kca: "Kca") -> None:
|
||||
@app.GET("/api/vaults/${vault_id}/requests")
|
||||
@operation(summary="List certificate signing requests", tags=["requests"])
|
||||
@require_auth(kca.oidc)
|
||||
async def list_requests(ctx: HttpContext, vault_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
rows = await Request.filter(vault_id=vault.id).order_by("name", "id")
|
||||
await send_json(ctx, 200, {"results": [await serialize_request_summary(r) for r in rows]})
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/requests")
|
||||
@operation(summary="Create a certificate signing request", tags=["requests"])
|
||||
@require_auth(kca.oidc)
|
||||
async def create_request(ctx: HttpContext, vault_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
name = str(body.get("name") or "").strip()
|
||||
if not name:
|
||||
await send_error(ctx, 400, "name is required")
|
||||
return
|
||||
comment = str(body.get("comment") or "")
|
||||
try:
|
||||
subject = parse_subject(body.get("subject") or [])
|
||||
extensions = parse_extensions(body.get("extensions"))
|
||||
if not subject.rdns:
|
||||
raise ValidationError("the subject must not be empty")
|
||||
key_model = None
|
||||
key_obj = None
|
||||
if body.get("key_id"):
|
||||
uuid = parse_uuid(str(body["key_id"]))
|
||||
key_model = await Key.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
|
||||
if key_model is None:
|
||||
raise ValidationError("key not found")
|
||||
if not key_model.has_private:
|
||||
raise ValidationError("the selected key has no private part")
|
||||
key_obj = await load_private_key(key_model)
|
||||
elif isinstance(body.get("new_key"), dict):
|
||||
new_key = body["new_key"]
|
||||
async with _keygen_semaphore:
|
||||
generated = await asyncio.to_thread(
|
||||
key_core.generate_key,
|
||||
str(new_key.get("key_type") or "rsa"),
|
||||
int(new_key.get("size") or key_core.DEFAULT_KEY_SIZE),
|
||||
new_key.get("curve"),
|
||||
)
|
||||
key_model = await save_key(
|
||||
vault,
|
||||
generated,
|
||||
name=str(new_key.get("name") or name),
|
||||
comment="Generated while creating a request",
|
||||
source="generated",
|
||||
)
|
||||
key_obj = generated
|
||||
else:
|
||||
raise ValidationError("select an existing key or generate a new one")
|
||||
csr = req_core.build_csr(
|
||||
subject=subject,
|
||||
private_key=key_obj,
|
||||
extension_spec=extensions,
|
||||
signature_hash=body.get("signature_hash"),
|
||||
attributes=body.get("attributes") or {},
|
||||
)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
info = req_core.request_info(csr)
|
||||
try:
|
||||
row = await Request.create(
|
||||
vault_id=vault.id,
|
||||
key_id=key_model.id if key_model else None,
|
||||
name=name,
|
||||
comment=comment,
|
||||
source="generated",
|
||||
subject_der=info.subject.public_bytes(),
|
||||
req_der=req_core.csr_der(csr),
|
||||
public_key_der=info.public_key_der,
|
||||
request_sha256=info.fingerprint_sha256,
|
||||
attributes=info.attributes,
|
||||
search_text=search_text(name, comment, info.subject.rfc4514_string(), info.fingerprint_sha256),
|
||||
)
|
||||
except IntegrityError:
|
||||
await send_error(ctx, 409, "this request is already in the vault")
|
||||
return
|
||||
await send_json(ctx, 201, await serialize_request_detail(row))
|
||||
|
||||
@app.GET("/api/vaults/${vault_id}/requests/${request_id}")
|
||||
@operation(summary="Request detail", tags=["requests"])
|
||||
@require_auth(kca.oidc)
|
||||
async def get_request(ctx: HttpContext, vault_id: str, request_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
row = await _request(vault, request_id)
|
||||
if row is None:
|
||||
await send_error(ctx, 404, "request not found")
|
||||
return
|
||||
await send_json(ctx, 200, await serialize_request_detail(row))
|
||||
|
||||
@app.PATCH("/api/vaults/${vault_id}/requests/${request_id}")
|
||||
@operation(summary="Update a request", tags=["requests"])
|
||||
@require_auth(kca.oidc)
|
||||
async def update_request(ctx: HttpContext, vault_id: str, request_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
row = await _request(vault, request_id)
|
||||
if row is None:
|
||||
await send_error(ctx, 404, "request not found")
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
if "version" in body and int(body["version"]) != row.version:
|
||||
await send_error(ctx, 409, "the request changed since it was loaded")
|
||||
return
|
||||
if "name" in body:
|
||||
if not isinstance(body["name"], str) or not body["name"].strip():
|
||||
await send_error(ctx, 400, "name must not be empty")
|
||||
return
|
||||
row.name = body["name"].strip()[:200]
|
||||
if "comment" in body:
|
||||
row.comment = str(body["comment"])[:8000]
|
||||
row.version += 1
|
||||
await row.save()
|
||||
await send_json(ctx, 200, await serialize_request_detail(row))
|
||||
|
||||
@app.DELETE("/api/vaults/${vault_id}/requests/${request_id}")
|
||||
@operation(summary="Delete a request", tags=["requests"])
|
||||
@require_auth(kca.oidc)
|
||||
async def delete_request(ctx: HttpContext, vault_id: str, request_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
row = await _request(vault, request_id)
|
||||
if row is None:
|
||||
await send_error(ctx, 404, "request not found")
|
||||
return
|
||||
await row.delete()
|
||||
await ctx.send_empty(204)
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/requests/${request_id}/export")
|
||||
@operation(summary="Export a request", tags=["requests"])
|
||||
@require_auth(kca.oidc)
|
||||
async def export_request(ctx: HttpContext, vault_id: str, request_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
row = await _request(vault, request_id)
|
||||
if row is None:
|
||||
await send_error(ctx, 404, "request not found")
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
try:
|
||||
result = export_core.export_request(
|
||||
req_core.parse_csr(bytes(row.req_der)),
|
||||
str(body.get("format") or "pem"),
|
||||
name=row.name,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
await send_json(ctx, 200, {
|
||||
"filename": result.filename,
|
||||
"content_type": result.content_type,
|
||||
"content_base64": base64.b64encode(result.data).decode("ascii"),
|
||||
})
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/requests/${request_id}/transform/open-key")
|
||||
@operation(summary="Extract the public key from a request", tags=["requests"])
|
||||
@require_auth(kca.oidc)
|
||||
async def extract_key(ctx: HttpContext, vault_id: str, request_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
row = await _request(vault, request_id)
|
||||
if row is None:
|
||||
await send_error(ctx, 404, "request not found")
|
||||
return
|
||||
public_key = req_core.csr_public_key(req_core.parse_csr(bytes(row.req_der)))
|
||||
key = await save_key(
|
||||
vault,
|
||||
public_key,
|
||||
name=f"{row.name} public key",
|
||||
comment=f"Extracted from request {row.name}",
|
||||
source="transformed",
|
||||
)
|
||||
await send_json(ctx, 201, {"id": str(key.id), "name": key.name})
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/requests/${request_id}/transform/template")
|
||||
@operation(summary="Create a template from a request", tags=["requests"])
|
||||
@require_auth(kca.oidc)
|
||||
async def to_template(ctx: HttpContext, vault_id: str, request_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
row = await _request(vault, request_id)
|
||||
if row is None:
|
||||
await send_error(ctx, 404, "request not found")
|
||||
return
|
||||
spec = template_core.template_from_request(
|
||||
req_core.parse_csr(bytes(row.req_der)), row.name, f"Transformed from request {row.name}"
|
||||
)
|
||||
template = await Template.create(
|
||||
vault_id=vault.id,
|
||||
name=spec["name"],
|
||||
comment=spec["comment"],
|
||||
source="transformed",
|
||||
subject=spec["subject"],
|
||||
settings=spec["settings"],
|
||||
search_text=search_text(spec["name"], spec["comment"]),
|
||||
)
|
||||
await send_json(ctx, 201, await serialize_template_summary(template))
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Global search across a vault's items."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
|
||||
from ..auth import authenticated_user, require_auth
|
||||
from ..http import extract_query_params, send_error, send_json
|
||||
from ..models import Certificate, Crl, Key, Request, Template
|
||||
from ..openapi import error_response, json_response, schema_ref
|
||||
from ..service import (
|
||||
parse_uuid,
|
||||
serialize_certificate_summary,
|
||||
serialize_crl_summary,
|
||||
serialize_key_summary,
|
||||
serialize_request_summary,
|
||||
serialize_template_summary,
|
||||
vault_for_owner,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Kca
|
||||
|
||||
|
||||
def register(app: KayaApp, kca: "Kca") -> None:
|
||||
@app.GET("/api/vaults/${vault_id}/search")
|
||||
@operation(summary="Search a vault", tags=["search"],
|
||||
responses={200: json_response("Search results", schema_ref("SearchPage")),
|
||||
401: error_response("Authentication required"),
|
||||
404: error_response("Vault not found")})
|
||||
@require_auth(kca.oidc)
|
||||
async def search(ctx: HttpContext, vault_id: str) -> None:
|
||||
user = await authenticated_user(kca.oidc, ctx)
|
||||
assert user is not None
|
||||
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
|
||||
if vault is None:
|
||||
await send_error(ctx, 404, "vault not found")
|
||||
return
|
||||
params = extract_query_params(ctx.query_string)
|
||||
query = (params.get("q", [""])[0] or "").strip().lower()
|
||||
kind = (params.get("type", [""])[0] or "").strip().lower()
|
||||
if not query:
|
||||
await send_json(ctx, 200, {"results": [], "counts": {}})
|
||||
return
|
||||
results: List[Dict[str, Any]] = []
|
||||
counts: Dict[str, int] = {}
|
||||
|
||||
if kind in ("", "all", "certificate", "certificates"):
|
||||
rows = await Certificate.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100)
|
||||
counts["certificates"] = len(rows)
|
||||
for row in rows:
|
||||
results.append({"type": "certificate", "item": await serialize_certificate_summary(row)})
|
||||
if kind in ("", "all", "key", "keys"):
|
||||
rows = await Key.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100)
|
||||
counts["keys"] = len(rows)
|
||||
for row in rows:
|
||||
results.append({"type": "key", "item": await serialize_key_summary(row)})
|
||||
if kind in ("", "all", "request", "requests"):
|
||||
rows = await Request.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100)
|
||||
counts["requests"] = len(rows)
|
||||
for row in rows:
|
||||
results.append({"type": "request", "item": await serialize_request_summary(row)})
|
||||
if kind in ("", "all", "crl", "crls"):
|
||||
rows = await Crl.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100)
|
||||
counts["crls"] = len(rows)
|
||||
for row in rows:
|
||||
results.append({"type": "crl", "item": await serialize_crl_summary(row)})
|
||||
if kind in ("", "all", "template", "templates"):
|
||||
rows = await Template.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100)
|
||||
counts["templates"] = len(rows)
|
||||
for row in rows:
|
||||
results.append({"type": "template", "item": await serialize_template_summary(row)})
|
||||
await send_json(ctx, 200, {"results": results, "counts": counts})
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Certificate/request templates, including the built-in XCA presets."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Sequence
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
|
||||
from kca.core import exports as export_core
|
||||
from kca.core import templates as template_core
|
||||
|
||||
from ..auth import authenticated_user, require_auth
|
||||
from ..http import JsonRequestError, read_json, send_error, send_json
|
||||
from ..models import Template
|
||||
from ..openapi import error_response, json_response, schema_ref
|
||||
from ..service import (
|
||||
ValidationError,
|
||||
parse_uuid,
|
||||
search_text,
|
||||
serialize_template_detail,
|
||||
serialize_template_summary,
|
||||
vault_for_owner,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Kca
|
||||
|
||||
|
||||
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
||||
user = await authenticated_user(kca.oidc, ctx)
|
||||
assert user is not None
|
||||
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
|
||||
if vault is None:
|
||||
await send_error(ctx, 404, "vault not found")
|
||||
return vault
|
||||
|
||||
|
||||
async def _template(vault, template_id: str):
|
||||
for builtin in template_core.builtin_templates():
|
||||
if builtin.id == template_id:
|
||||
return builtin.as_dict()
|
||||
uuid = parse_uuid(template_id)
|
||||
return await Template.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
|
||||
|
||||
|
||||
def _validate_template_body(body: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
name = body.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValidationError("name is required")
|
||||
subject = body.get("subject") or []
|
||||
if not isinstance(subject, list):
|
||||
raise ValidationError("subject must be a list")
|
||||
settings = body.get("settings") or {}
|
||||
if not isinstance(settings, dict):
|
||||
raise ValidationError("settings must be an object")
|
||||
return {
|
||||
"name": name.strip()[:200],
|
||||
"comment": str(body.get("comment") or "")[:8000],
|
||||
"subject": subject,
|
||||
"settings": settings,
|
||||
}
|
||||
|
||||
|
||||
def register(app: KayaApp, kca: "Kca") -> None:
|
||||
@app.GET("/api/vaults/${vault_id}/templates")
|
||||
@operation(summary="List templates", tags=["templates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def list_templates(ctx: HttpContext, vault_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
rows = await Template.filter(vault_id=vault.id).order_by("name", "id")
|
||||
results = [await serialize_template_summary(t) for t in template_core.builtin_templates()]
|
||||
results.extend([await serialize_template_summary(t) for t in rows])
|
||||
await send_json(ctx, 200, {"results": results})
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/templates")
|
||||
@operation(summary="Create a template", tags=["templates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def create_template(ctx: HttpContext, vault_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
try:
|
||||
values = _validate_template_body(body)
|
||||
except ValidationError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
row = await Template.create(
|
||||
vault_id=vault.id,
|
||||
name=values["name"],
|
||||
comment=values["comment"],
|
||||
source="generated",
|
||||
subject=values["subject"],
|
||||
settings=values["settings"],
|
||||
search_text=search_text(values["name"], values["comment"]),
|
||||
)
|
||||
await send_json(ctx, 201, await serialize_template_detail(row))
|
||||
|
||||
@app.GET("/api/vaults/${vault_id}/templates/${template_id}")
|
||||
@operation(summary="Template detail", tags=["templates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def get_template(ctx: HttpContext, vault_id: str, template_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
row = await _template(vault, template_id)
|
||||
if row is None:
|
||||
await send_error(ctx, 404, "template not found")
|
||||
return
|
||||
await send_json(ctx, 200, await serialize_template_detail(row))
|
||||
|
||||
@app.PATCH("/api/vaults/${vault_id}/templates/${template_id}")
|
||||
@operation(summary="Update a template", tags=["templates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def update_template(ctx: HttpContext, vault_id: str, template_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
row = await Template.get_or_none(id=parse_uuid(template_id), vault_id=vault.id)
|
||||
if row is None:
|
||||
await send_error(ctx, 404, "template not found (built-in templates cannot be edited)")
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
if "version" in body and int(body["version"]) != row.version:
|
||||
await send_error(ctx, 409, "the template changed since it was loaded")
|
||||
return
|
||||
try:
|
||||
values = _validate_template_body({**{
|
||||
"name": row.name,
|
||||
"comment": row.comment,
|
||||
"subject": row.subject,
|
||||
"settings": row.settings,
|
||||
}, **body})
|
||||
except ValidationError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
row.name = values["name"]
|
||||
row.comment = values["comment"]
|
||||
row.subject = values["subject"]
|
||||
row.settings = values["settings"]
|
||||
row.version += 1
|
||||
await row.save()
|
||||
await send_json(ctx, 200, await serialize_template_detail(row))
|
||||
|
||||
@app.DELETE("/api/vaults/${vault_id}/templates/${template_id}")
|
||||
@operation(summary="Delete a template", tags=["templates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def delete_template(ctx: HttpContext, vault_id: str, template_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
if template_id.startswith("builtin:"):
|
||||
await send_error(ctx, 400, "built-in templates cannot be deleted")
|
||||
return
|
||||
row = await Template.get_or_none(id=parse_uuid(template_id), vault_id=vault.id)
|
||||
if row is None:
|
||||
await send_error(ctx, 404, "template not found")
|
||||
return
|
||||
await row.delete()
|
||||
await ctx.send_empty(204)
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/templates/${template_id}/apply")
|
||||
@operation(summary="Apply a template to a certificate/request spec", tags=["templates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def apply_template(ctx: HttpContext, vault_id: str, template_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
row = await _template(vault, template_id)
|
||||
if row is None:
|
||||
await send_error(ctx, 404, "template not found")
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
template = await serialize_template_detail(row)
|
||||
target = body.get("target") or {}
|
||||
merged = template_core.apply_template(
|
||||
template,
|
||||
target,
|
||||
apply_subject=bool(body.get("apply_subject", True)),
|
||||
apply_extensions=bool(body.get("apply_extensions", True)),
|
||||
)
|
||||
await send_json(ctx, 200, merged)
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/templates/${template_id}/export")
|
||||
@operation(summary="Export a template", tags=["templates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def export_template(ctx: HttpContext, vault_id: str, template_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
row = await _template(vault, template_id)
|
||||
if row is None:
|
||||
await send_error(ctx, 404, "template not found")
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
template = await serialize_template_detail(row)
|
||||
try:
|
||||
result = export_core.export_template(
|
||||
template,
|
||||
str(body.get("format") or "xca"),
|
||||
name=str(template.get("name") or "template"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
await send_json(ctx, 200, {
|
||||
"filename": result.filename,
|
||||
"content_type": result.content_type,
|
||||
"content_base64": base64.b64encode(result.data).decode("ascii"),
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Personal access tokens for programmatic API access."""
|
||||
from __future__ import annotations
|
||||
|
||||
from logging import getLogger
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
|
||||
from ..auth import authenticated_user, require_session_auth
|
||||
from ..http import JsonRequestError, read_json, send_error, send_json
|
||||
from ..models import ApiToken
|
||||
from ..openapi import error_response, json_response, schema_ref
|
||||
from ..tokens import (
|
||||
MAX_TOKENS_PER_USER,
|
||||
TokenValidationError,
|
||||
generate_token,
|
||||
parse_token_payload,
|
||||
serialize_token,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Kca
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_uuid(value: str) -> Optional[UUID]:
|
||||
try:
|
||||
return UUID(value)
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def register(app: KayaApp, kca: "Kca") -> None:
|
||||
@app.GET("/api/tokens")
|
||||
@operation(summary="List my API tokens", tags=["auth"],
|
||||
responses={200: json_response("The caller's tokens", schema_ref("ApiTokensPage")),
|
||||
401: error_response("Authentication required")})
|
||||
@require_session_auth(kca.oidc)
|
||||
async def list_tokens(ctx: HttpContext) -> None:
|
||||
user = await authenticated_user(kca.oidc, ctx)
|
||||
assert user is not None
|
||||
rows = await ApiToken.filter(user_sub=user.sub)
|
||||
await send_json(ctx, 200, {"results": [serialize_token(token) for token in rows]})
|
||||
|
||||
@app.POST("/api/tokens")
|
||||
@operation(summary="Create an API token", tags=["auth"],
|
||||
responses={201: json_response("Token created", schema_ref("CreatedApiToken")),
|
||||
400: error_response("Invalid body or limit reached"),
|
||||
401: error_response("Authentication required")})
|
||||
@require_session_auth(kca.oidc)
|
||||
async def create_token(ctx: HttpContext) -> None:
|
||||
user = await authenticated_user(kca.oidc, ctx)
|
||||
assert user is not None
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
try:
|
||||
values = parse_token_payload(body)
|
||||
except TokenValidationError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
if await ApiToken.filter(user_sub=user.sub).count() >= MAX_TOKENS_PER_USER:
|
||||
await send_error(ctx, 400, f"you already have {MAX_TOKENS_PER_USER} tokens; revoke unused ones first")
|
||||
return
|
||||
secret, prefix, digest = generate_token()
|
||||
token = await ApiToken.create(
|
||||
user_sub=user.sub,
|
||||
name=values["name"],
|
||||
token_prefix=prefix,
|
||||
token_hash=digest,
|
||||
expires_at=values["expires_at"],
|
||||
)
|
||||
log.info("created api token sub=%s name=%r prefix=%s", user.sub, token.name, token.token_prefix)
|
||||
payload = serialize_token(token)
|
||||
payload["token"] = secret
|
||||
await send_json(ctx, 201, payload)
|
||||
|
||||
@app.DELETE("/api/tokens/${token_id}")
|
||||
@operation(summary="Revoke an API token", tags=["auth"],
|
||||
responses={204: {"description": "Revoked"},
|
||||
401: error_response("Authentication required"),
|
||||
404: error_response("No such token")})
|
||||
@require_session_auth(kca.oidc)
|
||||
async def revoke_token(ctx: HttpContext, token_id: str) -> None:
|
||||
user = await authenticated_user(kca.oidc, ctx)
|
||||
assert user is not None
|
||||
token_uuid = _parse_uuid(token_id)
|
||||
token = await ApiToken.get_or_none(id=token_uuid, user_sub=user.sub) if token_uuid else None
|
||||
if token is None:
|
||||
await send_error(ctx, 404, "token not found")
|
||||
return
|
||||
await ApiToken.filter(id=token.id).delete()
|
||||
await ctx.send_empty(204)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Vaults (XCA databases) and their settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
|
||||
from ..auth import authenticated_user, require_auth
|
||||
from ..http import JsonRequestError, read_json, send_error, send_json
|
||||
from ..models import Certificate, Crl, Key, Request, Template, Vault
|
||||
from ..openapi import error_response, json_response, schema_ref
|
||||
from ..service import (
|
||||
DEFAULT_VAULT_SETTINGS,
|
||||
ValidationError,
|
||||
iso,
|
||||
merge_settings,
|
||||
parse_uuid,
|
||||
serialize_certificate_summary,
|
||||
serialize_crl_summary,
|
||||
serialize_key_summary,
|
||||
serialize_request_summary,
|
||||
serialize_template_summary,
|
||||
serialize_vault_detail,
|
||||
serialize_vault_summary,
|
||||
vault_for_owner,
|
||||
vault_settings,
|
||||
)
|
||||
from ..core import crls as crl_core
|
||||
from ..core import templates as template_core
|
||||
from ..core import extensions as ext_core
|
||||
from ..core import keys as key_core
|
||||
from ..core import names as name_core
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Kca
|
||||
|
||||
|
||||
def _parse_vault_id(value: str):
|
||||
return parse_uuid(value)
|
||||
|
||||
|
||||
async def _vault_or_error(kca: "Kca", ctx: HttpContext, vault_id: str) -> Optional[Vault]:
|
||||
user = await authenticated_user(kca.oidc, ctx)
|
||||
assert user is not None
|
||||
vault = await vault_for_owner(user.sub, _parse_vault_id(vault_id))
|
||||
if vault is None:
|
||||
await send_error(ctx, 404, "vault not found")
|
||||
return vault
|
||||
|
||||
|
||||
def register(app: KayaApp, kca: "Kca") -> None:
|
||||
@app.GET("/api/vaults")
|
||||
@operation(summary="List my vaults", tags=["vaults"],
|
||||
responses={200: json_response("Vaults", schema_ref("VaultsPage")),
|
||||
401: error_response("Authentication required")})
|
||||
@require_auth(kca.oidc)
|
||||
async def list_vaults(ctx: HttpContext) -> None:
|
||||
user = await authenticated_user(kca.oidc, ctx)
|
||||
assert user is not None
|
||||
vaults = await Vault.filter(owner_sub=user.sub).order_by("name", "id")
|
||||
await send_json(ctx, 200, {"results": [await serialize_vault_summary(v) for v in vaults]})
|
||||
|
||||
@app.POST("/api/vaults")
|
||||
@operation(summary="Create a vault", tags=["vaults"],
|
||||
responses={201: json_response("Vault created", schema_ref("VaultDetail")),
|
||||
400: error_response("Invalid body"),
|
||||
401: error_response("Authentication required")})
|
||||
@require_auth(kca.oidc)
|
||||
async def create_vault(ctx: HttpContext) -> None:
|
||||
user = await authenticated_user(kca.oidc, ctx)
|
||||
assert user is not None
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
name = body.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
await send_error(ctx, 400, "name is required")
|
||||
return
|
||||
description = body.get("description", "")
|
||||
if not isinstance(description, str):
|
||||
await send_error(ctx, 400, "description must be a string")
|
||||
return
|
||||
try:
|
||||
settings_values = dict(DEFAULT_VAULT_SETTINGS)
|
||||
if isinstance(body.get("settings"), dict):
|
||||
settings_values = merge_settings(settings_values, body["settings"])
|
||||
except ValidationError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
vault = await Vault.create(
|
||||
owner_sub=user.sub,
|
||||
name=name.strip()[:200],
|
||||
description=description[:4000],
|
||||
settings=settings_values,
|
||||
)
|
||||
await send_json(ctx, 201, await serialize_vault_detail(vault))
|
||||
|
||||
@app.GET("/api/vaults/${vault_id}")
|
||||
@operation(summary="Vault detail", tags=["vaults"],
|
||||
responses={200: json_response("Vault", schema_ref("VaultDetail")),
|
||||
401: error_response("Authentication required"),
|
||||
404: error_response("Vault not found")})
|
||||
@require_auth(kca.oidc)
|
||||
async def get_vault(ctx: HttpContext, vault_id: str) -> None:
|
||||
vault = await _vault_or_error(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
await send_json(ctx, 200, await serialize_vault_detail(vault))
|
||||
|
||||
@app.PATCH("/api/vaults/${vault_id}")
|
||||
@operation(summary="Update a vault", tags=["vaults"],
|
||||
responses={200: json_response("Vault updated", schema_ref("VaultDetail")),
|
||||
400: error_response("Invalid body"),
|
||||
401: error_response("Authentication required"),
|
||||
404: error_response("Vault not found"),
|
||||
409: error_response("Stale version")})
|
||||
@require_auth(kca.oidc)
|
||||
async def update_vault(ctx: HttpContext, vault_id: str) -> None:
|
||||
vault = await _vault_or_error(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
if "version" in body and int(body["version"]) != vault.version:
|
||||
await send_error(ctx, 409, "the vault changed since it was loaded")
|
||||
return
|
||||
if "name" in body:
|
||||
if not isinstance(body["name"], str) or not body["name"].strip():
|
||||
await send_error(ctx, 400, "name must not be empty")
|
||||
return
|
||||
vault.name = body["name"].strip()[:200]
|
||||
if "description" in body:
|
||||
if not isinstance(body["description"], str):
|
||||
await send_error(ctx, 400, "description must be a string")
|
||||
return
|
||||
vault.description = body["description"][:4000]
|
||||
vault.version += 1
|
||||
await vault.save()
|
||||
await send_json(ctx, 200, await serialize_vault_detail(vault))
|
||||
|
||||
@app.DELETE("/api/vaults/${vault_id}")
|
||||
@operation(summary="Delete a vault", tags=["vaults"],
|
||||
responses={204: {"description": "Deleted"},
|
||||
401: error_response("Authentication required"),
|
||||
404: error_response("Vault not found")})
|
||||
@require_auth(kca.oidc)
|
||||
async def delete_vault(ctx: HttpContext, vault_id: str) -> None:
|
||||
vault = await _vault_or_error(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
await Vault.filter(id=vault.id).delete()
|
||||
await ctx.send_empty(204)
|
||||
|
||||
@app.GET("/api/vaults/${vault_id}/settings")
|
||||
@operation(summary="Vault settings", tags=["vaults"])
|
||||
@require_auth(kca.oidc)
|
||||
async def get_settings(ctx: HttpContext, vault_id: str) -> None:
|
||||
vault = await _vault_or_error(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
await send_json(ctx, 200, {"settings": vault_settings(vault), "version": vault.version})
|
||||
|
||||
@app.PATCH("/api/vaults/${vault_id}/settings")
|
||||
@operation(summary="Update vault settings", tags=["vaults"])
|
||||
@require_auth(kca.oidc)
|
||||
async def update_settings(ctx: HttpContext, vault_id: str) -> None:
|
||||
vault = await _vault_or_error(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
if "version" in body and int(body["version"]) != vault.version:
|
||||
await send_error(ctx, 409, "the vault changed since it was loaded")
|
||||
return
|
||||
patch = body.get("settings", body)
|
||||
if not isinstance(patch, dict):
|
||||
await send_error(ctx, 400, "settings must be an object")
|
||||
return
|
||||
try:
|
||||
vault.settings = merge_settings(vault_settings(vault), patch)
|
||||
except ValidationError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
vault.version += 1
|
||||
await vault.save()
|
||||
await send_json(ctx, 200, {"settings": vault_settings(vault), "version": vault.version})
|
||||
|
||||
@app.GET("/api/vaults/${vault_id}/summary")
|
||||
@operation(summary="Vault dashboard summary", tags=["vaults"])
|
||||
@require_auth(kca.oidc)
|
||||
async def vault_summary(ctx: HttpContext, vault_id: str) -> None:
|
||||
vault = await _vault_or_error(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
expiring = await Certificate.filter(
|
||||
vault_id=vault.id, not_after__gte=now, not_after__lte=now + timedelta(days=30)
|
||||
).order_by("not_after").limit(10)
|
||||
due_crls = await Crl.filter(
|
||||
vault_id=vault.id, next_update__lte=now + timedelta(days=7)
|
||||
).order_by("next_update").limit(10)
|
||||
recent_certs = await Certificate.filter(vault_id=vault.id).order_by("-created_at").limit(8)
|
||||
recent_keys = await Key.filter(vault_id=vault.id).order_by("-created_at").limit(8)
|
||||
await send_json(ctx, 200, {
|
||||
"summary": await serialize_vault_summary(vault),
|
||||
"expiring": [await serialize_certificate_summary(c) for c in expiring],
|
||||
"crls_due": [await serialize_crl_summary(c) for c in due_crls],
|
||||
"recent_certificates": [await serialize_certificate_summary(c) for c in recent_certs],
|
||||
"recent_keys": [await serialize_key_summary(k) for k in recent_keys],
|
||||
})
|
||||
|
||||
@app.GET("/api/vaults/${vault_id}/options")
|
||||
@operation(summary="Reference data for editors", tags=["vaults"])
|
||||
@require_auth(kca.oidc)
|
||||
async def options(ctx: HttpContext, vault_id: str) -> None:
|
||||
vault = await _vault_or_error(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
await send_json(ctx, 200, {
|
||||
"key_types": key_core.key_type_options(),
|
||||
"hash_algorithms": list(key_core.HASH_ALGORITHMS),
|
||||
"curves": list(key_core.EC_CURVES),
|
||||
"dn_attributes": name_core.name_attribute_options(),
|
||||
"eku": name_core.eku_options(),
|
||||
"key_usages": ext_core.KEY_USAGES,
|
||||
"revocation_reasons": crl_core.reason_options(),
|
||||
"defaults": vault_settings(vault),
|
||||
"names": {
|
||||
"extensions": ext_core.EXTENSION_NAMES,
|
||||
"general_name_types": list(ext_core.GENERAL_NAME_TYPES),
|
||||
"netscape_cert_types": [name for name, _bit in ext_core.NETSCAPE_CERT_TYPES],
|
||||
},
|
||||
"templates": [
|
||||
*[await serialize_template_summary(t) for t in template_core.builtin_templates()],
|
||||
*[await serialize_template_summary(t) for t in await Template.filter(vault_id=vault.id)],
|
||||
],
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
"""SPA shell hosting for the compiled single-page application."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from kaya.core import HttpContext
|
||||
from kaya.openapi import operation
|
||||
|
||||
from .app import app
|
||||
from .config import settings
|
||||
|
||||
_ICON_CONTENT_TYPES: Mapping[str, str] = {
|
||||
"apple-touch-icon.png": "image/png",
|
||||
"icon-192.png": "image/png",
|
||||
"icon-512.png": "image/png",
|
||||
"icon-maskable-512.png": "image/png",
|
||||
"icon.svg": "image/svg+xml",
|
||||
}
|
||||
|
||||
|
||||
async def _send_file(ctx: HttpContext, name: str, content_type: str, cache_control: str) -> None:
|
||||
path = Path(settings.static_dir) / name
|
||||
if not path.is_file():
|
||||
await ctx.send_empty(404)
|
||||
return
|
||||
body = await asyncio.to_thread(path.read_bytes)
|
||||
await ctx.send_bytes(200, body, {"content-type": (content_type,), "cache-control": (cache_control,)})
|
||||
|
||||
|
||||
async def _send_shell(ctx: HttpContext) -> None:
|
||||
index = Path(settings.static_dir) / "index.html"
|
||||
if not index.is_file():
|
||||
await ctx.send_empty(404)
|
||||
return
|
||||
body = await asyncio.to_thread(index.read_bytes)
|
||||
await ctx.send_bytes(200, body, {"content-type": ("text/html; charset=utf-8",)})
|
||||
|
||||
|
||||
@app.GET("/sw.js")
|
||||
@operation(hidden=True)
|
||||
async def service_worker(ctx: HttpContext) -> None:
|
||||
await _send_file(ctx, "sw.js", "text/javascript; charset=utf-8", "no-cache")
|
||||
|
||||
|
||||
@app.GET("/manifest.webmanifest")
|
||||
@operation(hidden=True)
|
||||
async def web_manifest(ctx: HttpContext) -> None:
|
||||
await _send_file(ctx, "manifest.webmanifest", "application/manifest+json", "no-cache")
|
||||
|
||||
|
||||
@app.GET("/icons/${name}")
|
||||
@operation(hidden=True)
|
||||
async def icon(ctx: HttpContext, name: str) -> None:
|
||||
content_type = _ICON_CONTENT_TYPES.get(name)
|
||||
if content_type is None:
|
||||
await ctx.send_empty(404)
|
||||
return
|
||||
await _send_file(ctx, f"icons/{name}", content_type, "public, max-age=86400")
|
||||
|
||||
|
||||
@app.GET("/")
|
||||
async def index(ctx: HttpContext) -> None:
|
||||
await _send_shell(ctx)
|
||||
|
||||
|
||||
@app.GET("/*", recursive=True)
|
||||
async def spa(ctx: HttpContext, _matched: object = None) -> None:
|
||||
await _send_shell(ctx)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Personal access tokens: generation, hashing and verification."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||
|
||||
from kaya.core import HttpContext
|
||||
|
||||
from .models import ApiToken, User
|
||||
|
||||
TOKEN_PREFIX = "kca_"
|
||||
PREFIX_LENGTH = 12
|
||||
TOKEN_ENTROPY_BYTES = 32
|
||||
MAX_TOKENS_PER_USER = 50
|
||||
LAST_USED_REFRESH = timedelta(minutes=1)
|
||||
MAX_TOKEN_NAME = 100
|
||||
|
||||
|
||||
class TokenValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def generate_token() -> Tuple[str, str, str]:
|
||||
secret = TOKEN_PREFIX + secrets.token_urlsafe(TOKEN_ENTROPY_BYTES)
|
||||
return secret, secret[:PREFIX_LENGTH], hash_token(secret)
|
||||
|
||||
|
||||
def hash_token(secret: str) -> str:
|
||||
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def bearer_token(ctx: HttpContext) -> Optional[str]:
|
||||
values = ctx.headers.get("authorization")
|
||||
if not values:
|
||||
return None
|
||||
parts = values[0].split(None, 1)
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
return None
|
||||
credential = parts[1].strip()
|
||||
return credential or None
|
||||
|
||||
|
||||
async def resolve_token(secret: str) -> Optional[User]:
|
||||
token = await ApiToken.get_or_none(token_hash=hash_token(secret))
|
||||
if token is None:
|
||||
return None
|
||||
if token.expires_at is not None and token.expires_at < date.today():
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
last_used = token.last_used_at
|
||||
if last_used is not None and last_used.tzinfo is None:
|
||||
last_used = last_used.replace(tzinfo=timezone.utc)
|
||||
if last_used is None or now - last_used >= LAST_USED_REFRESH:
|
||||
token.last_used_at = now
|
||||
await token.save(update_fields=["last_used_at"])
|
||||
return await User.get_or_none(sub=token.user_sub)
|
||||
|
||||
|
||||
def parse_token_payload(payload: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
name = payload.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise TokenValidationError("name is required")
|
||||
name = name.strip()
|
||||
if len(name) > MAX_TOKEN_NAME:
|
||||
raise TokenValidationError(f"name must be at most {MAX_TOKEN_NAME} characters")
|
||||
expires_at: Optional[date] = None
|
||||
raw_expiry = payload.get("expires_at")
|
||||
if raw_expiry is not None:
|
||||
if not isinstance(raw_expiry, str) or not raw_expiry:
|
||||
raise TokenValidationError("expires_at must be an ISO date (YYYY-MM-DD) or null")
|
||||
try:
|
||||
expires_at = date.fromisoformat(raw_expiry)
|
||||
except ValueError as exc:
|
||||
raise TokenValidationError("expires_at must be an ISO date (YYYY-MM-DD) or null") from exc
|
||||
if expires_at < date.today():
|
||||
raise TokenValidationError("expires_at must not be in the past")
|
||||
return {"name": name, "expires_at": expires_at}
|
||||
|
||||
|
||||
def serialize_token(token: ApiToken) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": str(token.id),
|
||||
"name": token.name,
|
||||
"prefix": token.token_prefix,
|
||||
"created_at": token.created_at.isoformat(),
|
||||
"last_used_at": token.last_used_at.isoformat() if token.last_used_at else None,
|
||||
"expires_at": token.expires_at.isoformat() if token.expires_at else None,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LAST_USED_REFRESH",
|
||||
"MAX_TOKEN_NAME",
|
||||
"MAX_TOKENS_PER_USER",
|
||||
"PREFIX_LENGTH",
|
||||
"TOKEN_ENTROPY_BYTES",
|
||||
"TOKEN_PREFIX",
|
||||
"TokenValidationError",
|
||||
"bearer_token",
|
||||
"generate_token",
|
||||
"hash_token",
|
||||
"parse_token_payload",
|
||||
"resolve_token",
|
||||
"serialize_token",
|
||||
]
|
||||
@@ -0,0 +1,145 @@
|
||||
"""A :class:`~kaya.core.KayaMixin` that drives the TortoiseORM lifecycle.
|
||||
|
||||
kaya calls ``KayaMixin.setup`` / ``shutdown`` synchronously from inside a
|
||||
running event loop. Tortoise 1.x binds database connections to a
|
||||
:class:`~tortoise.context.TortoiseContext` looked up via a contextvar, and
|
||||
kaya dispatches each HTTP request as a separate ``loop.create_task``, so a
|
||||
context set by an early request does not automatically reach later
|
||||
requests.
|
||||
|
||||
This mixin therefore:
|
||||
|
||||
1. Lazily builds a :class:`TortoiseContext` for the active event loop
|
||||
(rebuilding it if the running loop changes, which happens in tests that
|
||||
use a fresh ``asyncio.run`` per test).
|
||||
2. Per HTTP request, binds that context to the current task via the
|
||||
``_current_context`` contextvar so the handler — running in the same
|
||||
task as the ``before_request`` hook — sees an active context.
|
||||
3. Exposes :meth:`ensure_context` for background work that belongs to no
|
||||
request (used by the test suite and any future worker).
|
||||
|
||||
It deliberately avoids the global-fallback singleton
|
||||
(``_enable_global_fallback``), which Tortoise only allows to be set once
|
||||
per process and would therefore break across event loops.
|
||||
|
||||
Schema management is split by backend: in-memory sqlite databases (the
|
||||
test suite) get ``generate_schemas`` on every fresh context; Postgres
|
||||
schemas are owned by aerich migrations and must be applied externally
|
||||
(``aerich upgrade``, run by the ``db-migrate`` compose service) before
|
||||
the app serves requests.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import AbstractEventLoop, get_running_loop
|
||||
from logging import getLogger
|
||||
from typing import AbstractSet, Optional, Sequence
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from kaya.core import HttpContext, KayaApp, KayaMixin
|
||||
from tortoise.context import TortoiseContext, _current_context
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_url(database_url: str) -> str:
|
||||
"""The database URL with any credentials stripped, for logging."""
|
||||
parts = urlsplit(database_url)
|
||||
host = parts.hostname or ""
|
||||
try:
|
||||
if parts.port:
|
||||
host = f"{host}:{parts.port}"
|
||||
except ValueError:
|
||||
# Non-numeric netloc (e.g. sqlite://:memory:): keep the host only.
|
||||
pass
|
||||
return urlunsplit((parts.scheme, host, parts.path, "", ""))
|
||||
|
||||
|
||||
class TortoiseMixin(KayaMixin):
|
||||
"""Initialize and tear down a per-loop :class:`TortoiseContext`."""
|
||||
|
||||
def __init__(self,
|
||||
database_url: str,
|
||||
models_modules: Sequence[str],
|
||||
skip_paths: AbstractSet[str] = frozenset({"/api/health"})) -> None:
|
||||
self._database_url = database_url
|
||||
self._models_modules = list(models_modules)
|
||||
self._skip_paths = skip_paths
|
||||
self._ctx: "Optional[TortoiseContext]" = None
|
||||
self._init_loop: "Optional[AbstractEventLoop]" = None
|
||||
|
||||
def apply(self, app: KayaApp) -> None:
|
||||
app.add_before_request_hook(self._ensure_context)
|
||||
|
||||
def setup(self, loop: AbstractEventLoop) -> None:
|
||||
pass
|
||||
|
||||
def shutdown(self, loop: AbstractEventLoop) -> None:
|
||||
if self._init_loop is loop and self._ctx is not None:
|
||||
log.info("closing database connections")
|
||||
loop.create_task(self._ctx.close_connections())
|
||||
self._ctx = None
|
||||
self._init_loop = None
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close the current context's connections and forget it.
|
||||
|
||||
Must be called from the event loop that owns the context. When a
|
||||
loop goes away without this, its aiosqlite connections are orphaned;
|
||||
their non-daemon worker threads then block interpreter shutdown
|
||||
forever. The test suite calls this at the end of every test because
|
||||
each test runs in a fresh event loop.
|
||||
"""
|
||||
ctx = self._ctx
|
||||
self._ctx = None
|
||||
self._init_loop = None
|
||||
if ctx is not None:
|
||||
log.info("closing database connections")
|
||||
await ctx.close_connections()
|
||||
|
||||
async def _build_context(self) -> TortoiseContext:
|
||||
ctx = TortoiseContext()
|
||||
with ctx:
|
||||
await ctx.init(
|
||||
db_url=self._database_url,
|
||||
modules={"models": self._models_modules},
|
||||
)
|
||||
log.info("database context initialized (%s)", _safe_url(self._database_url))
|
||||
# Schema creation is only done for sqlite (in-memory test
|
||||
# databases). Postgres schemas are managed by aerich migrations
|
||||
# (applied by the db-migrate compose service / `aerich upgrade`).
|
||||
if self._database_url.startswith("sqlite"):
|
||||
await ctx.generate_schemas()
|
||||
log.info("sqlite schemas generated")
|
||||
return ctx
|
||||
|
||||
async def ensure_context(self) -> None:
|
||||
"""Bind this mixin's context to the calling task.
|
||||
|
||||
Background work with no request of its own calls this before
|
||||
touching the database. It is cheap when the context is already
|
||||
built for the running loop and idempotent for callers that already
|
||||
have it bound (HTTP requests).
|
||||
"""
|
||||
await self._bind()
|
||||
|
||||
async def _bind(self) -> None:
|
||||
loop = get_running_loop()
|
||||
if self._init_loop is not loop:
|
||||
if self._ctx is not None:
|
||||
# A previous test loop went away; drop its context.
|
||||
self._ctx = None
|
||||
log.debug("building a Tortoise context for a new event loop")
|
||||
self._ctx = await self._build_context()
|
||||
self._init_loop = loop
|
||||
assert self._ctx is not None
|
||||
_current_context.set(self._ctx)
|
||||
|
||||
async def _ensure_context(self, ctx: HttpContext):
|
||||
if ctx.path in self._skip_paths:
|
||||
return None
|
||||
# Only API endpoints touch the database: auth callbacks and the
|
||||
# static SPA catch-all must not pay for a Tortoise context.
|
||||
if not ctx.path.startswith("/api/"):
|
||||
return None
|
||||
await self._bind()
|
||||
return None
|
||||
Reference in New Issue
Block a user