Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
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.3+260916"
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.3+260916"
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.3+260916" # NOTE Use `bump-my-version` to bump versions correctly
__version__ = "1.20.0+260912" # NOTE Use `bump-my-version` to bump versions correctly
82 changes: 74 additions & 8 deletions src/rasenmaeher_api/db/enrollments.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
import uuid
import warnings
from collections.abc import AsyncGenerator
from typing import Any
from typing import Any, cast

from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql import func
from sqlmodel import Field, select
from sqlalchemy.engine import CursorResult
from sqlalchemy.sql import func, update
from sqlmodel import Field, col, select

from ..rmsettings import RMSettings
from ..web.api.utils.csr_utils import verify_csr
Expand Down Expand Up @@ -157,7 +158,7 @@ class Enrollment(ORMBaseModel, table=True):
approvecode: str = Field(nullable=False, index=True, unique=True)
callsign: str = Field(nullable=False, index=True, unique=True)
decided_on: datetime.datetime = Field(nullable=True, default=None)
decided_by: uuid.UUID = Field(
decided_by: uuid.UUID | None = Field(
foreign_key=f"{ORMBaseModel.__table_args__['schema']}.users.pk", nullable=True, default=None
)
person: uuid.UUID = Field(
Expand All @@ -178,12 +179,74 @@ async def by_pk_or_callsign(cls, inval: str | uuid.UUID) -> "Enrollment":
except ValueError:
return await cls.by_callsign(str(inval))

async def approve(self, approver: Person) -> Person:
"""Creates the person record, their certs etc"""
@property
def planned_for_mdm(self) -> bool:
"""Was this enrollment planned by an admin for a device that enrols through an MDM

Set by POST /enrollment/init with mdm=true. Such a row waits for the device to present its
own CSR through an MDM agent; approving it by hand would generate a keypair on the server
and spend the callsign, so /enrollment/accept refuses that.
"""
return bool(self.extra) and self.extra.get("mdm") is True

async def claim_with_csr(self, csrpem: str) -> bool:
"""Attach a device CSR to this pending enrollment, once

Returns False if somebody got there first. The check and the write are one statement on
purpose: two devices racing for the same planned callsign must not both proceed, and the
loser has to find out before any certificate is issued.

NOTE: refresh (or re-read) the instance before calling approve(); approve() reads self.csr
off the in-memory object and would otherwise take the "no CSR" branch and generate a
keypair here on the server.
"""
with EngineWrapper.get_session() as session:
statement = (
update(Enrollment)
.where(
col(Enrollment.pk) == self.pk,
col(Enrollment.csr).is_(None),
col(Enrollment.state) == EnrollmentState.PENDING,
col(Enrollment.decided_by).is_(None),
)
.values(csr=csrpem)
)
result = cast(CursorResult[Any], session.execute(statement))
session.commit()
if result.rowcount != 1:
LOGGER.warning(f"Enrollment {self.pk} was already claimed")
return False
self.csr = csrpem
return True

async def release_claim(self) -> None:
"""Undo claim_with_csr

Issuing can fail after the claim (the CA is a network call), and a claimed row with no
certificate is a callsign nobody can ever use. Releasing lets the device's next attempt
run. It does not help if a Person row was already committed -- see the caller.
"""
with EngineWrapper.get_session() as session:
statement = (
update(Enrollment)
.where(col(Enrollment.pk) == self.pk, col(Enrollment.state) == EnrollmentState.PENDING)
.values(csr=None)
)
session.execute(statement)
session.commit()
self.csr = None

async def approve(self, approver: Person | None) -> Person:
"""Creates the person record, their certs etc

approver is None when an MDM agent completed a device enrolment the admin had already
planned: the agent is a service identity, not a Person, and decided_by is nullable. The
audit trail carries the agent's CN instead.
"""
with EngineWrapper.get_session() as session:
person = await Person.create_with_cert(self.callsign, extra=self.extra, csrpem=self.csr)
self.state = EnrollmentState.APPROVED
self.decided_by = approver.pk
self.decided_by = approver.pk if approver else None
self.decided_on = datetime.datetime.now(datetime.UTC)
self.person = person.pk
session.add(self)
Expand Down Expand Up @@ -281,8 +344,11 @@ 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:
cnf = RMSettings.singleton()
if callsign in cnf.valid_product_cns:
raise CallsignReserved("Using product CNs as callsigns is forbidden")
if callsign.lower() in {cnname.lower() for cnname in cnf.mdm_agent_cn_set}:
raise CallsignReserved("Using MDM agent CNs as callsigns is forbidden")
if csr and not verify_csr(csr, callsign):
raise CallsignReserved("CSR CN must match callsign")
with EngineWrapper.get_session() as session:
Expand Down
4 changes: 4 additions & 0 deletions src/rasenmaeher_api/db/people.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ async def create_with_cert(
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")
if callsign.lower() 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 hand them every planned device's identity.
raise CallsignReserved("Using MDM agent CNs as callsigns is forbidden")
try:
await Person.by_callsign(callsign)
raise CallsignReserved()
Expand Down
24 changes: 24 additions & 0 deletions src/rasenmaeher_api/rmsettings.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,25 @@ 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 = ""

# The SCEP challenge the MDM is configured with, so the admin UI can show what to type there.
# Deliberately not a secret: it sits in the MDM's own configuration, the MDM hands it to every
# device, and it is a nuisance filter and nothing more. What actually decides whether a device
# may have a callsign is the per-enrolment code in its certificate request. Empty unless the
# deployment passes it in, and then the UI says where to look instead.
mdm_scep_challenge: str = ""

persistent_data_dir: str = "/data/persistent"

# mtls
Expand Down Expand Up @@ -177,6 +196,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
49 changes: 47 additions & 2 deletions src/rasenmaeher_api/web/api/enrollment/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from typing import Any

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


Expand Down Expand Up @@ -117,6 +118,13 @@ class EnrollmentInitIn(BaseModel):

callsign: str = Field(description="Callsign to create enrollment for")
csr: str | None = Field(description="CSR for mTLS key in PEM format", default=None)
mdm: bool = Field(
description=(
"Mark this as a device enrolment planned for an MDM. The device supplies its own CSR later and an "
"MDM agent completes the enrolment; no JWT is issued to the caller and approving it by hand is refused."
),
default=False,
)


class EnrollmentInitOut(BaseModel):
Expand All @@ -136,7 +144,10 @@ class EnrollmentInitOut(BaseModel):
callsign: str = Field(description="Callsign for which the enrollment got initialized")
approvecode: str = Field(description="Code used to approve the enrollment, must be delivered to an admin")
jwt: str = Field(
description="JWT that allows client to check enrollment approval status and fetc mTLS certs when approved"
description=(
"JWT that allows client to check enrollment approval status and fetc mTLS certs when approved. "
"Empty for enrolments planned for an MDM: nobody should be holding a device's credential."
)
)


Expand Down Expand Up @@ -191,7 +202,18 @@ 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 an MDM agent completes a planned device enrolment: 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. Only for an MDM agent completing an enrolment planned with mdm=true."
),
default=None,
)


Expand All @@ -210,6 +232,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 +469,12 @@ class EnrollmentInviteCodeDeleteIn(BaseModel):
)

invite_code: str


class MdmSettingsOut(BaseModel):
"""What an operator has to type into whatever MDM their unit runs"""

#: Where the MDM sends each device's certificate request
scep_url: str
#: The SCEP challenge, or empty if this deployment has not passed it through
challenge: str
Loading
Loading