Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
34b8eaf
fix: verify_csr compared a rendered string, so a crafted subject slip…
benjamsf Sep 12, 2026
641c136
chore: bump version
benjamsf Sep 12, 2026
92bcd8a
fix: verify_csr matched the callsign as a substring
benjamsf Sep 12, 2026
78def9a
chore: bump version
benjamsf Sep 12, 2026
9eb21b6
chore: remove useless comments
benjamsf Sep 12, 2026
959ae22
Merge branch 'fix/verify-csr-exact-subject' of github.com:pvarki/pyth…
benjamsf Sep 12, 2026
d6c368d
feat: let an MDM agent complete device enrollments an admin planned
benjamsf Sep 12, 2026
099cc62
chore: bump version
benjamsf Sep 12, 2026
ebd05aa
fix: reserve MDM agent CNs against being taken as callsigns
benjamsf Sep 13, 2026
8908ab6
feat: mark devices planned for MDM in the enrollment list
benjamsf Sep 13, 2026
e79d633
test: two devices cannot both claim one planned callsign
benjamsf Sep 13, 2026
f0b009f
fix: a device request may not choose what it is issued
benjamsf Sep 14, 2026
19727eb
fix: require the per-enrolment code, not just the callsign
benjamsf Sep 15, 2026
d6d0df8
feat: list the approval code for devices, not for people
benjamsf Sep 18, 2026
8ece58a
feat: tell the admin UI what to configure the MDM with
benjamsf Sep 19, 2026
0a4a89f
Merge branch 'main' into feat/mdm-list-codes
benjamsf Oct 9, 2026
4646a5e
style: let ruff format decide the wrapping
benjamsf Oct 9, 2026
8b7a927
fix: install the locked versions in the production image
benjamsf Oct 9, 2026
f502565
test: carry the enrolment proof the way an MDM does
benjamsf Oct 9, 2026
e420c3b
test: give the CLI subprocess tests a realistic timeout
benjamsf Oct 9, 2026
cb4e01e
fix: name the postgres DBAPI instead of inheriting the default
benjamsf Oct 10, 2026
6cd96de
fix: drop the MDM settings endpoint
benjamsf Oct 27, 2026
57cf766
fix: refuse MDM callsigns the proof cannot carry
benjamsf Oct 27, 2026
aee8046
Merge branch 'main' into feat/mdm-list-codes
benjamsf Oct 28, 2026
80ea0a4
feat: let an admin open MDM enrolment for a while
benjamsf Oct 28, 2026
3662b2a
fix: accept the enrolment proof from exactly one attribute
benjamsf Oct 28, 2026
676d476
feat: let the MDM name the devices it enrols
benjamsf Oct 29, 2026
eb04ade
feat: take hyphens and 32 characters in MDM device names
benjamsf Oct 29, 2026
7daa723
fix: reserve service names in any case
benjamsf Oct 31, 2026
deabfd7
fix: refuse device keys anybody could recover
benjamsf Oct 31, 2026
39d772f
style: format the weak key test
benjamsf Oct 31, 2026
6d8d4e6
feat: give every MDM enrolment window a new challenge
benjamsf Oct 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .bumpversion.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tool.bumpversion]
current_version = "1.19.4+261027"
current_version = "1.20.0+260912"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)\\+(?P<build>\\d+)"
serialize = ["{major}.{minor}.{patch}+{build}"]
search = "{current_version}"
Expand Down
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,10 @@ RUN --mount=type=ssh apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/* \
&& chmod a+x /docker-entrypoint.sh \
&& WHEELFILE=`echo /tmp/wheelhouse/rasenmaeher_api-*.whl` \
&& pip3 install --find-links=/tmp/wheelhouse/ "$WHEELFILE"[all] \
# --no-index, or --find-links is merely additive and pip re-resolves against PyPI: the
# wheelhouse is built from `uv export --frozen`, but pip would happily pick a newer sqlmodel
# that widens its SQLAlchemy range, and the image then ships versions the lock never saw.
&& pip3 install --no-index --find-links=/tmp/wheelhouse/ "$WHEELFILE"[all] \
&& rm -rf /tmp/wheelhouse/ \
# Do whatever else you need to
&& true
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "rasenmaeher_api"
version = "1.19.4+261027"
version = "1.20.0+260912"
description = "python-rasenmaeher-api"
authors = [
{ name = "Aciid", email = "703382+Aciid@users.noreply.github.com" },
Expand Down
2 changes: 1 addition & 1 deletion src/rasenmaeher_api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""python-rasenmaeher-api"""

__version__ = "1.19.4+261027" # NOTE Use `bump-my-version` to bump versions correctly
__version__ = "1.20.0+260912" # NOTE Use `bump-my-version` to bump versions correctly
2 changes: 2 additions & 0 deletions src/rasenmaeher_api/db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .engine import EngineWrapper
from .enrollments import Enrollment, EnrollmentPool, EnrollmentState
from .logincodes import LoginCode
from .mdmenrolment import MdmEnrolment
from .nonces import SeenToken
from .people import Person, Role

Expand All @@ -15,6 +16,7 @@
"EnrollmentPool",
"EnrollmentState",
"LoginCode",
"MdmEnrolment",
"Person",
"Role",
"SeenToken",
Expand Down
7 changes: 6 additions & 1 deletion src/rasenmaeher_api/db/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,13 @@
class DBConfig:
"""DB config dataclass, functools etc used to avoid import-time side-effects"""

# Name the DBAPI rather than leaving it to SQLAlchemy's default for postgresql://, which moved
# from psycopg2 to psycopg3 in SQLAlchemy 2.1. We ship psycopg2, so the bare name stops
# resolving the moment anything builds us against 2.1.
driver: str = field(
default_factory=cast(Callable[..., str], functools.partial(config, "RM_DATABASE_DRIVER", default="postgresql"))
default_factory=cast(
Callable[..., str], functools.partial(config, "RM_DATABASE_DRIVER", default="postgresql+psycopg2")
)
)
host: str | None = field(default_factory=functools.partial(config, "RM_DATABASE_HOST", default=None))
port: int = field(
Expand Down
3 changes: 2 additions & 1 deletion src/rasenmaeher_api/db/dbinit.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@
from .enrollments import Enrollment, EnrollmentPool
from .issuedcerts import IssuedCert
from .logincodes import LoginCode
from .mdmenrolment import MdmEnrolment
from .nonces import SeenToken
from .people import Person, Role

_ = (Person, Role, EnrollmentPool, Enrollment, SeenToken, LoginCode, IssuedCert)
_ = (Person, Role, EnrollmentPool, Enrollment, SeenToken, LoginCode, IssuedCert, MdmEnrolment)
LOGGER = logging.getLogger(__name__)


Expand Down
7 changes: 4 additions & 3 deletions src/rasenmaeher_api/db/enrollments.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from .base import ORMBaseModel
from .engine import EngineWrapper
from .errors import CallsignReserved, Deleted, ForbiddenOperation, NotFound, PoolInactive
from .people import Person
from .people import Person, reserved_callsign

LOGGER = logging.getLogger(__name__)
CODE_ALPHABET = string.ascii_uppercase + string.digits
Expand Down Expand Up @@ -281,8 +281,9 @@ async def create_for_callsign(
csr: str | None = None,
) -> "Enrollment":
"""Create a new one with random code for the callsign"""
if callsign in RMSettings.singleton().valid_product_cns:
raise CallsignReserved("Using product CNs as callsigns is forbidden")
reason = reserved_callsign(callsign)
if reason:
raise CallsignReserved(reason)
if csr and not verify_csr(csr, callsign):
raise CallsignReserved("CSR CN must match callsign")
with EngineWrapper.get_session() as session:
Expand Down
91 changes: 91 additions & 0 deletions src/rasenmaeher_api/db/mdmenrolment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Whether devices may enrol through an MDM right now, and the challenge they must bring"""

import datetime
import hmac
import logging
import secrets

import sqlalchemy as sa
from sqlalchemy.exc import IntegrityError
from sqlmodel import Field, select

from .base import ORMBaseModel
from .engine import EngineWrapper
from .errors import ForbiddenOperation

LOGGER = logging.getLogger(__name__)


def new_challenge() -> str:
"""Hex, because the challenge travels as an X.509 attribute and some clients encode it as a
PrintableString, which has no room for "_"."""
return secrets.token_hex(16)


class MdmEnrolment(ORMBaseModel, table=True):
"""The one row saying when the MDM enrolment window closes

Closed until an admin opens it, and it closes itself when the window runs out, so a deployment
nobody is watching does not go on taking enrolments. Every window gets a new challenge: while
it is open the challenge is all that stands between the public SCEP endpoint and a new
identity, and every device enrolled so far holds the old one. So opening means putting the new
challenge in the MDM.
"""

__tablename__ = "mdmenrolment"

# One row, and the unique key makes the database refuse a second one when workers race to
# create it.
slot: str = Field(default="mdm", nullable=False, unique=True)
challenge: str = Field(nullable=False)
open_until: datetime.datetime | None = Field(
default=None, sa_column=sa.Column(sa.DateTime(timezone=True), nullable=True)
)

@classmethod
async def current(cls) -> "MdmEnrolment":
"""The row, made with a fresh challenge and closed the first time anybody asks"""
with EngineWrapper.get_session() as session:
obj = session.exec(select(cls)).first()
if obj:
return obj
obj = cls(challenge=new_challenge())
session.add(obj)
try:
session.commit()
except IntegrityError:
session.rollback()
return session.exec(select(cls)).one()
session.refresh(obj)
return obj

@property
def is_open(self) -> bool:
"""Is the window open right now"""
return self.open_until is not None and self.open_until > datetime.datetime.now(datetime.UTC)

def accepts(self, challenge: str | None) -> bool:
"""Is this the challenge, compared without leaking how much of it matched"""
if not challenge:
return False
return hmac.compare_digest(challenge.encode("utf-8"), self.challenge.encode("utf-8"))

async def open_for(self, hours: int) -> "MdmEnrolment":
"""Open the window for this many hours from now, or close it with 0

Opening a closed window makes a new challenge. Extending an open one keeps it, because the
devices the MDM is enrolling at that moment were handed it.
"""
until = datetime.datetime.now(datetime.UTC) + datetime.timedelta(hours=hours) if hours else None
with EngineWrapper.get_session() as session:
if until and not self.is_open:
self.challenge = new_challenge()
self.open_until = until
session.add(self)
session.commit()
session.refresh(self)
return self

async def delete(self) -> bool:
"""The row is the setting; closing the window is what turns enrolment off"""
raise ForbiddenOperation("Deletion of the MDM enrolment setting is not allowed")
26 changes: 22 additions & 4 deletions src/rasenmaeher_api/db/people.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,25 @@
LOGGER = logging.getLogger(__name__)


def reserved_callsign(callsign: str) -> str | None:
"""Why this callsign belongs to a service rather than a person, or None if it does not

Compared without case: lookups fold it, and Keycloak and Matrix lowercase the name, so
RASENMAEHER is the same account as rasenmaeher everywhere downstream.
"""
cnf = RMSettings.singleton()
folded = callsign.lower()
if folded in {cnname.lower() for cnname in cnf.valid_product_cns}:
return "Using product CNs as callsigns is forbidden"
if folded == cnf.mtls_client_cert_cn.lower():
return "Using the rasenmaeher service CN as a callsign is forbidden"
if folded in {cnname.lower() for cnname in cnf.mdm_agent_cn_set}:
# A certificate with this CN is accepted as the MDM enrolment agent on sight, so issuing
# one to a person would let them enrol devices.
return "Using MDM agent CNs as callsigns is forbidden"
return None


class Person(ORMBaseModel, table=True):
"""People, pk is UUID and comes from basemodel

Expand Down Expand Up @@ -99,10 +118,9 @@ async def create_with_cert(
if csrpem and not verify_csr(csrpem, callsign):
raise CallsignReserved("CSR CN must match callsign")
cnf = RMSettings.singleton()
if callsign in cnf.valid_product_cns:
raise CallsignReserved("Using product CNs as callsigns is forbidden")
if callsign == cnf.mtls_client_cert_cn:
raise CallsignReserved("Using the rasenmaeher service CN as a callsign is forbidden")
reason = reserved_callsign(callsign)
if reason:
raise CallsignReserved(reason)
try:
await Person.by_callsign(callsign)
raise CallsignReserved()
Expand Down
17 changes: 17 additions & 0 deletions src/rasenmaeher_api/rmsettings.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ def log_level_int(self) -> int:
ocsp_ca_secret_namespace: str = "cert-manager"
ocsp_response_validity: int = 3600 # seconds from now until nextUpdate

# Shared secret used by the Traefik callsign-validity plugin to auth to the
# internal websocket. Optional — if unset, the websocket accepts any caller
# (suitable for in-cluster-only Service exposure during local dev).
callsign_validity_secret: str | None = None

# Service identities allowed to complete device enrolments an admin planned with mdm=true,
# comma separated. Empty by default: until a deployment names its MDM agent here, the agent
# path in POST /enrollment/accept does not exist. Deliberately NOT the kraftwerk product CNs:
# those are exempt from role checks, can have any CSR signed and cannot be revoked at the
# edge, which is far more than completing a planned enrolment needs.
mdm_agent_cns: str = ""

persistent_data_dir: str = "/data/persistent"

# mtls
Expand Down Expand Up @@ -177,6 +189,11 @@ def deployment_name(self) -> str:
LOGGER.warning("DNS name not defined")
return "undefined"

@property
def mdm_agent_cn_set(self) -> set[str]:
"""CNs allowed to act as an MDM enrolment agent"""
return {cnname.strip() for cnname in self.mdm_agent_cns.split(",") if cnname.strip()}

@property
def valid_product_cns(self) -> list[str]:
"""Get valid CNs for productapi certs"""
Expand Down
50 changes: 49 additions & 1 deletion src/rasenmaeher_api/web/api/enrollment/schema.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Schema for enrollment."""

import datetime
from typing import Any

from libpvarki.schemas.generic import OperationResultResponse
from pydantic import BaseModel, ConfigDict, Field


Expand Down Expand Up @@ -191,7 +193,20 @@ class EnrollmentAcceptIn(BaseModel):

callsign: str = Field(description="Callsign to approve")
approvecode: str = Field(
description="Approval code for the callsign, this must have been delivered by the person to be enrolled"
description=(
"Approval code for the callsign, this must have been delivered by the person to be enrolled. "
"Not used when the MDM agent enrols a device: there is no human to deliver it, "
"and the agent has authenticated with its own certificate."
),
default="",
)
csr: str | None = Field(
description=("CSR the device generated, PEM, its CN the name the MDM gave it. Only for the MDM agent."),
default=None,
)
challenge: str | None = Field(
description="The SCEP challenge the device sent. Only for an MDM agent, which passes it on unchecked.",
default=None,
)


Expand All @@ -210,6 +225,20 @@ class EnrollmentAcceptOut(BaseModel):
callsign: str


class EnrollmentAcceptResultOut(OperationResultResponse):
"""Result of accepting an enrollment

Additive superset of the plain result: an MDM agent gets the issued certificate back here
rather than fetching it afterwards. It has no user credential to fetch it with, and in a
meshed deployment its own service identity would win over any bearer token anyway.
"""

certificate: str | None = Field(
description="Issued certificate, PEM. Only set when an MDM agent completed the enrolment.",
default=None,
)


class EnrollmentPromoteIn(BaseModel):
"""Enrollment promote in schema"""

Expand Down Expand Up @@ -433,3 +462,22 @@ class EnrollmentInviteCodeDeleteIn(BaseModel):
)

invite_code: str


class MdmSettingsOut(BaseModel):
"""What an operator configures the MDM with, and whether devices may enrol now"""

#: Where the MDM sends each device's certificate request
scep_url: str
#: The SCEP challenge, new for every window. Keep it to the MDM
challenge: str
#: When the enrolment window closes, or None while it is closed
open_until: datetime.datetime | None


class MdmSettingsIn(BaseModel):
"""Open the enrolment window for some hours, or close it"""

model_config = ConfigDict(extra="forbid")

hours: int = Field(description="Hours to keep enrolment open from now, 0 closes it", ge=0, le=168)
Loading
Loading