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:
2026-10-23 02:06:55 +00:00
commit 4f3a632f4b
109 changed files with 21048 additions and 0 deletions
+5
View File
@@ -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.
+66
View File
@@ -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",
}
},
}
+110
View File
@@ -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
+192
View File
@@ -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",
]
+237
View File
@@ -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()
+73
View File
@@ -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)
+49
View File
@@ -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)
+332
View File
@@ -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",
]
+192
View File
@@ -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
+70
View File
@@ -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)
+107
View File
@@ -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