Skip to content

Commit

Permalink
add pydantic classes for Admissions extension
Browse files Browse the repository at this point in the history
  • Loading branch information
mathiasertl committed Nov 30, 2024
1 parent 355708c commit 0d49e99
Show file tree
Hide file tree
Showing 11 changed files with 459 additions and 55 deletions.
5 changes: 0 additions & 5 deletions ca/django_ca/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,13 +455,11 @@ def validate_ca_use_celery(cls, value: Any) -> Any:
def check_ca_key_backends(self) -> "SettingsModel":
"""Set the default key backend if not set, and validate that the default key backend is configured."""
if not self.CA_KEY_BACKENDS:
# pylint: disable-next=unsupported-assignment-operation # pylint this this is a Field()
self.CA_KEY_BACKENDS[self.CA_DEFAULT_KEY_BACKEND] = KeyBackendConfigurationModel(
BACKEND=constants.DEFAULT_STORAGE_BACKEND,
OPTIONS={"storage_alias": self.CA_DEFAULT_STORAGE_ALIAS},
)

# pylint: disable-next=unsupported-membership-test # pylint this this is a Field()
elif self.CA_DEFAULT_KEY_BACKEND not in self.CA_KEY_BACKENDS:
raise ValueError(f"{self.CA_DEFAULT_KEY_BACKEND}: The default key backend is not configured.")
return self
Expand All @@ -470,21 +468,18 @@ def check_ca_key_backends(self) -> "SettingsModel":
def check_ca_ocsp_key_backends(self) -> "SettingsModel":
"""Set the default OCSP key backend if not set, and validate that the default is configured."""
if not self.CA_OCSP_KEY_BACKENDS:
# pylint: disable-next=unsupported-assignment-operation # pylint this this is a Field()
self.CA_OCSP_KEY_BACKENDS[self.CA_DEFAULT_OCSP_KEY_BACKEND] = KeyBackendConfigurationModel(
BACKEND=constants.DEFAULT_OCSP_KEY_BACKEND,
OPTIONS={"storage_alias": self.CA_DEFAULT_STORAGE_ALIAS},
)

# pylint: disable-next=unsupported-membership-test # pylint this this is a Field()
elif self.CA_DEFAULT_OCSP_KEY_BACKEND not in self.CA_OCSP_KEY_BACKENDS:
raise ValueError(f"{self.CA_DEFAULT_KEY_BACKEND}: The default key backend is not configured.")
return self

@model_validator(mode="after")
def check_ca_default_profile(self) -> "SettingsModel":
"""Validate that the default profile is also configured."""
# pylint: disable-next=unsupported-membership-test # pylint this this is a Field()
if self.CA_DEFAULT_PROFILE not in self.CA_PROFILES:
raise ValueError(f"{self.CA_DEFAULT_PROFILE}: CA_DEFAULT_PROFILE is not defined as a profile.")
return self
Expand Down
2 changes: 1 addition & 1 deletion ca/django_ca/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
class ExtensionOID(_ExtensionOID):
"""Extend the ExtensionOID object with any OIDs not known to cryptography."""

if CRYPTOGRAPHY_VERSION < (44, 0): # pragma: only cryptography<4cccccbhlnnivu4.0
if CRYPTOGRAPHY_VERSION < (44, 0): # pragma: cryptography<44 branch
ADMISSIONS = x509.ObjectIdentifier("1.3.36.8.3.3")


Expand Down
137 changes: 135 additions & 2 deletions ca/django_ca/pydantic/extension_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@

import base64
from datetime import datetime
from typing import Annotated, Any, Literal, NoReturn, Optional, Union
from typing import TYPE_CHECKING, Annotated, Any, Literal, NoReturn, Optional, Union

from annotated_types import MaxLen, MinLen
from pydantic import AfterValidator, Base64Bytes, BeforeValidator, ConfigDict, Field, model_validator

from cryptography import x509
Expand All @@ -28,14 +29,146 @@
from django_ca.pydantic.base import CryptographyModel
from django_ca.pydantic.general_name import GeneralNameModel
from django_ca.pydantic.name import NameModel
from django_ca.pydantic.type_aliases import NonEmptyOrderedSet, OIDType
from django_ca.pydantic.type_aliases import Base64EncodedBytes, NonEmptyOrderedSet, OIDType
from django_ca.typehints import DistributionPointReasons, LogEntryTypes

if TYPE_CHECKING: # pragma: only cryptography<44
# NOTE: we can use bases directly once instances are supported in every versoin
NamingAuthorityBase = CryptographyModel[x509.NamingAuthority]
ProfessionInfoBase = CryptographyModel[x509.ProfessionInfo]
AdmissionBase = CryptographyModel[x509.Admission]
AdmissionsValueModelBase = CryptographyModel[x509.Admissions]
else:
NamingAuthorityBase = ProfessionInfoBase = AdmissionBase = AdmissionsValueModelBase = CryptographyModel

_NOTICE_REFERENCE_DESCRIPTION = (
"A NoticeReferenceModel consists of an optional *organization* and an optional list of *notice_numbers*."
)


class NamingAuthorityModel(NamingAuthorityBase): # pragma: only cryptography>=44.0
"""Pydantic model wrapping :py:class:`~cg:cryptography.x509.NamingAuthority`.
.. NOTE:: This class will not be able to produce a cryptography instance when using ``cryptography<44``.
.. versionadded:: 2.1.0
"""

model_config = ConfigDict(from_attributes=True)

id: Optional[OIDType] = None
url: Optional[Annotated[str, MaxLen(128)]] = None
text: Optional[Annotated[str, MaxLen(128)]] = None

@property
def cryptography(self) -> "x509.NamingAuthority":
"""Convert to a :py:class:`~cg:cryptography.x509.NamingAuthority` instance."""
oid = None
if self.id is not None:
oid = x509.ObjectIdentifier(self.id)
return x509.NamingAuthority(id=oid, url=self.url, text=self.text)


class ProfessionInfoModel(ProfessionInfoBase): # pragma: only cryptography>=44.0
"""Pydantic model wrapping :py:class:`~cg:cryptography.x509.ProfessionInfo`.
.. NOTE:: This class will not be able to produce a cryptography instance when using ``cryptography<44``.
.. versionadded:: 2.1.0
"""

model_config = ConfigDict(from_attributes=True)

naming_authority: Optional[NamingAuthorityModel] = None
profession_items: Annotated[list[Annotated[str, MaxLen(128)]], MinLen(1)]
profession_oids: Optional[list[OIDType]] = None
registration_number: Optional[Annotated[str, MaxLen(128)]] = None
add_profession_info: Optional[Base64EncodedBytes] = None

@property
def cryptography(self) -> "x509.ProfessionInfo":
naming_authority = profession_oids = None
if self.naming_authority is not None:
naming_authority = self.naming_authority.cryptography
if self.profession_oids is not None:
profession_oids = [x509.ObjectIdentifier(oid) for oid in self.profession_oids]

return x509.ProfessionInfo(
naming_authority=naming_authority,
profession_items=self.profession_items,
profession_oids=profession_oids,
registration_number=self.registration_number,
add_profession_info=self.add_profession_info,
)

@model_validator(mode="after")
def check_consistency(self) -> "ProfessionInfoModel":
"""Verify that profession_oids has the same length as profession_items if set."""
if self.profession_oids is not None and len(self.profession_items) != len(self.profession_oids):
raise ValueError("if present, profession_oids must have the same length as profession_items.")
return self


class AdmissionModel(AdmissionBase): # pragma: only cryptography>=44.0
"""Pydantic model wrapping :py:class:`~cg:cryptography.x509.Admission`.
.. NOTE:: This class will not be able to produce a cryptography instance when using ``cryptography<44``.
.. versionadded:: 2.1.0
"""

model_config = ConfigDict(from_attributes=True)

admission_authority: Optional[GeneralNameModel] = None
naming_authority: Optional[NamingAuthorityModel] = None
profession_infos: Annotated[list[ProfessionInfoModel], MinLen(1)]

@property
def cryptography(self) -> "x509.Admission":
admission_authority = naming_authority = None
if self.admission_authority is not None:
admission_authority = self.admission_authority.cryptography
if self.naming_authority is not None:
naming_authority = self.naming_authority.cryptography

return x509.Admission(
admission_authority=admission_authority,
naming_authority=naming_authority,
profession_infos=[pi.cryptography for pi in self.profession_infos],
)


class AdmissionsValueModel(AdmissionsValueModelBase): # pragma: only cryptography>=44.0
"""Pydantic model wrapping :py:class:`~cg:cryptography.x509.Admissions`.
.. NOTE:: This class will not be able to produce a cryptography instance when using ``cryptography<44``.
.. versionadded:: 2.1.0
"""

model_config = ConfigDict(from_attributes=True)

authority: Optional[GeneralNameModel] = None
admissions: list[AdmissionModel] = Field(default_factory=list)

@property
def cryptography(self) -> "x509.Admissions":
authority = None
if self.authority is not None:
authority = self.authority.cryptography
return x509.Admissions(
authority=authority, admissions=[admission.cryptography for admission in self.admissions]
)

@model_validator(mode="before")
@classmethod
def parse_cryptography(cls, data: Any) -> Any:
"""Parse cryptography instance."""
if isinstance(data, x509.Admissions):
return {"authority": data.authority, "admissions": data._admissions} # pylint: disable=protected-access
return data


class AccessDescriptionModel(CryptographyModel[x509.AccessDescription]):
"""Pydantic model wrapping :py:class:`~cg:cryptography.x509.AccessDescription`.
Expand Down
34 changes: 32 additions & 2 deletions ca/django_ca/pydantic/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,14 @@
from pydantic_core.core_schema import ValidationInfo

from cryptography import x509
from cryptography.x509.oid import AuthorityInformationAccessOID, ExtensionOID, SubjectInformationAccessOID
from cryptography.x509.oid import AuthorityInformationAccessOID, SubjectInformationAccessOID

from django_ca.constants import EXTENSION_DEFAULT_CRITICAL, KEY_USAGE_NAMES
from django_ca.constants import EXTENSION_DEFAULT_CRITICAL, KEY_USAGE_NAMES, ExtensionOID
from django_ca.pydantic import validators
from django_ca.pydantic.base import CryptographyModel
from django_ca.pydantic.extension_attributes import (
AccessDescriptionModel,
AdmissionsValueModel,
AuthorityKeyIdentifierValueModel,
BasicConstraintsValueModel,
DistributionPointModel,
Expand Down Expand Up @@ -128,6 +129,7 @@

from pydantic.main import IncEx


###############
# Base models #
###############
Expand Down Expand Up @@ -320,6 +322,31 @@ def extension_type(self) -> NoReturn: # pragma: no cover
# ExtensionType models #
########################

if TYPE_CHECKING:
AdmissionsModelBase = ExtensionModel[x509.Admissions]
else:
AdmissionsModelBase = ExtensionModel


class AdmissionsModel(AdmissionsModelBase): # pragma: only cryptography>=44.0
"""Pydantic model for a :py:class:`~cg:cryptography.x509.Admissions` extension.
.. NOTE:: This class will not be able to produce a cryptography instance when using ``cryptography<44``.
.. versionadded:: 2.1.0
"""

model_config = ConfigDict(from_attributes=True)

type: Literal["admissions"] = Field(default="admissions", repr=False)
critical: bool = EXTENSION_DEFAULT_CRITICAL[ExtensionOID.ADMISSIONS]
value: AdmissionsValueModel

@property
def extension_type(self) -> "x509.Admissions":
"""Convert to a :py:class:`~cg:cryptography.x509.AuthorityKeyIdentifier` instance."""
return self.value.cryptography


class AuthorityInformationAccessModel(InformationAccessBaseModel[x509.AuthorityInformationAccess]):
"""Pydantic model for a :py:class:`~cg:cryptography.x509.AuthorityInformationAccess` extension.
Expand Down Expand Up @@ -924,6 +951,7 @@ def extension_type(self) -> x509.UnrecognizedExtension:

EXTENSION_MODEL_OIDS: "MappingProxyType[type[ExtensionModel[Any]], x509.ObjectIdentifier]" = MappingProxyType(
{
AdmissionsModel: ExtensionOID.ADMISSIONS,
AuthorityInformationAccessModel: ExtensionOID.AUTHORITY_INFORMATION_ACCESS,
AuthorityKeyIdentifierModel: ExtensionOID.AUTHORITY_KEY_IDENTIFIER,
BasicConstraintsModel: ExtensionOID.BASIC_CONSTRAINTS,
Expand Down Expand Up @@ -970,6 +998,7 @@ def validate_cryptography_extensions(v: Any, info: ValidationInfo) -> Any:
ConfigurableExtensionModel = Annotated[
Annotated[
Union[
AdmissionsModel,
AuthorityInformationAccessModel,
CertificatePoliciesModel,
CRLDistributionPointsModel,
Expand All @@ -992,6 +1021,7 @@ def validate_cryptography_extensions(v: Any, info: ValidationInfo) -> Any:
CertificateExtensionModel = Annotated[
Annotated[
Union[
AdmissionsModel,
AuthorityInformationAccessModel,
AuthorityKeyIdentifierModel,
BasicConstraintsModel,
Expand Down
2 changes: 0 additions & 2 deletions ca/django_ca/pydantic/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,4 @@ def get_extensions(self) -> list[ConfigurableExtension]:
if self.extensions is None:
return []
extensions = [ext.cryptography for ext in self.extensions]

# TYPEHINT NOTE: list has Extension[A] | Extension[B], but value has Extension[A | B].
return extensions
9 changes: 6 additions & 3 deletions ca/django_ca/tests/extensions/test_extension_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,16 @@ def pytest_generate_tests(metafunc: Any) -> None:
def assert_parsed(key: str, serialized: Any, extension_type: CertificateExtensionType, name: str) -> None:
"""Assert that the given `serialized` value parses to the given `extension_type`."""
oid = extension_type.oid
ext = x509.Extension(oid=oid, critical=EXTENSION_DEFAULT_CRITICAL[oid], value=extension_type)
ext = cast(
CertificateExtension,
x509.Extension(oid=oid, critical=EXTENSION_DEFAULT_CRITICAL[oid], value=extension_type),
)
assert parse_extension(key, {"value": serialized}) == ext, name

ext = x509.Extension(oid=oid, critical=True, value=extension_type)
ext = x509.Extension(oid=oid, critical=True, value=extension_type) # type: ignore[assignment]
assert parse_extension(key, {"value": serialized, "critical": True}) == ext, name

ext = cast(CertificateExtension, x509.Extension(oid=oid, critical=False, value=extension_type))
ext = x509.Extension(oid=oid, critical=False, value=extension_type) # type: ignore[assignment]
assert parse_extension(key, {"value": serialized, "critical": False}) == ext, name

assert parse_extension(key, ext) is ext
Expand Down
5 changes: 4 additions & 1 deletion ca/django_ca/tests/pydantic/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ def assert_cryptography_model(
"""Test that a cryptography model matches the expected value."""
model = model_class(**parameters)
assert model.cryptography == expected
assert model == model_class.model_validate(expected), (model, model_class.model_validate(expected))
print(1, expected)
print(2, model)
print(3, model_class.model_validate(expected))
assert model == model_class.model_validate(expected), (model, expected)
assert model == model_class.model_validate_json(model.model_dump_json()) # test JSON serialization
return model # for any further tests on the model

Expand Down
Loading

0 comments on commit 0d49e99

Please sign in to comment.