From da9f35a133b9663ce86fed2fcc416b2fb217862c Mon Sep 17 00:00:00 2001 From: Mathias Ertl Date: Sat, 28 Oct 2023 10:25:08 +0200 Subject: [PATCH] Add ability to use the API on an individual basis --- ca/django_ca/admin.py | 8 +- ca/django_ca/api/endpoints.py | 6 +- ca/django_ca/api/utils.py | 2 +- ca/django_ca/management/commands/edit_ca.py | 7 +- ca/django_ca/management/commands/import_ca.py | 6 + ca/django_ca/management/commands/init_ca.py | 7 +- ca/django_ca/management/mixins.py | 20 +- ca/django_ca/managers.py | 6 + .../0036_certificateauthority_api_enabled.py | 21 +++ ca/django_ca/models.py | 7 + ca/django_ca/tests/admin/test_admin_ca.py | 7 +- ca/django_ca/tests/api/conftest.py | 32 +++- ca/django_ca/tests/api/test_list_cas.py | 16 +- ca/django_ca/tests/api/test_sign_cert.py | 12 -- ca/django_ca/tests/api/test_update_ca.py | 31 ++- ca/django_ca/tests/api/test_view_ca.py | 11 -- ca/django_ca/tests/api/test_view_cert.py | 13 +- ca/django_ca/tests/commands/test_edit_ca.py | 20 ++ ca/django_ca/tests/commands/test_import_ca.py | 7 + ca/django_ca/tests/commands/test_init_ca.py | 20 +- ca/django_ca/tests/test_managers.py | 14 +- docs/source/changelog.rst | 11 +- docs/source/conf.py | 1 + docs/source/index.rst | 4 +- docs/source/quickstart_docker_compose.rst | 2 +- docs/source/rest_api.rst | 176 +++++++++++++++++- requirements/requirements-docs.txt | 1 + 27 files changed, 374 insertions(+), 94 deletions(-) create mode 100644 ca/django_ca/migrations/0036_certificateauthority_api_enabled.py diff --git a/ca/django_ca/admin.py b/ca/django_ca/admin.py index 2bbb71370..85a3f80a5 100644 --- a/ca/django_ca/admin.py +++ b/ca/django_ca/admin.py @@ -425,15 +425,18 @@ def get_fieldsets( # type: ignore[override] if obj is None: # pragma: no cover # we never add certificate authorities, so it's never None return fieldsets + # Mark certificate policies as read-only if the configured extension is to complex for the widget. sign_certificate_policies = obj.sign_certificate_policies if sign_certificate_policies and not certificate_policies_is_simple(sign_certificate_policies.value): detail_fields = fieldsets[1][1]["fields"] sign_certificate_policies_index = detail_fields.index("sign_certificate_policies") detail_fields[sign_certificate_policies_index] = "sign_certificate_policies_readonly" + api_index = 1 if ca_settings.CA_ENABLE_ACME: + api_index = 2 fieldsets.insert( - 2, + 1, ( _("ACME"), { @@ -447,6 +450,9 @@ def get_fieldsets( # type: ignore[override] ), ) + if ca_settings.CA_ENABLE_REST_API: + fieldsets.insert(api_index, (_("API"), {"fields": ["api_enabled"]})) + return fieldsets def get_readonly_fields( # type: ignore[override] diff --git a/ca/django_ca/api/endpoints.py b/ca/django_ca/api/endpoints.py index e3ffeb99c..12fa768f8 100644 --- a/ca/django_ca/api/endpoints.py +++ b/ca/django_ca/api/endpoints.py @@ -62,7 +62,7 @@ def list_certificate_authorities( request: WSGIRequest, filters: CertificateAuthorityFilterSchema = Query(...) ) -> CertificateAuthorityQuerySet: """Retrieve a list of currently usable certificate authorities.""" - qs = CertificateAuthority.objects.enabled() + qs = CertificateAuthority.objects.enabled().exclude(api_enabled=False) if filters.expired is False: qs = qs.valid() return qs @@ -148,7 +148,9 @@ def sign_certificate(request: WSGIRequest, serial: str, data: SignCertificateSch ) def get_certificate_order(request: WSGIRequest, serial: str, slug: str) -> CertificateOrder: """Retrieve information about the certificate order identified by `slug`.""" - return CertificateOrder.objects.get(certificate_authority__serial=serial, slug=slug) + return CertificateOrder.objects.get( + certificate_authority__serial=serial, certificate_authority__api_enabled=True, slug=slug + ) @api.get( diff --git a/ca/django_ca/api/utils.py b/ca/django_ca/api/utils.py index a1d8bfda6..fbedc3792 100644 --- a/ca/django_ca/api/utils.py +++ b/ca/django_ca/api/utils.py @@ -35,7 +35,7 @@ def get_certificate_authority(serial: str, expired: bool = False) -> CertificateAuthority: """Get a certificate authority from the given serial.""" - qs = CertificateAuthority.objects.enabled() + qs = CertificateAuthority.objects.enabled().exclude(api_enabled=False) if expired is False: qs = qs.valid() diff --git a/ca/django_ca/management/commands/edit_ca.py b/ca/django_ca/management/commands/edit_ca.py index 572201e23..8f93f4c76 100644 --- a/ca/django_ca/management/commands/edit_ca.py +++ b/ca/django_ca/management/commands/edit_ca.py @@ -40,6 +40,7 @@ def add_arguments(self, parser: CommandParser) -> None: self.add_ca(parser, "ca", allow_disabled=True) self.add_acme_group(parser) self.add_ocsp_group(parser) + self.add_rest_api_group(parser) self.add_ca_args(parser) group = parser.add_mutually_exclusive_group() @@ -98,7 +99,7 @@ def handle( # pylint: disable=too-many-arguments ca.terms_of_service = options["tos"] # Set ACME options - if ca_settings.CA_ENABLE_ACME: # pragma: no branch; never False because parser throws error already + if ca_settings.CA_ENABLE_ACME: # pragma: no branch; never False b/c parser throws error already for param in ["acme_enabled", "acme_registration", "acme_requires_contact"]: if options[param] is not None: setattr(ca, param, options[param]) @@ -108,6 +109,10 @@ def handle( # pylint: disable=too-many-arguments raise CommandError(f"{acme_profile}: Profile is not defined.") ca.acme_profile = acme_profile + if ca_settings.CA_ENABLE_REST_API: # pragma: no branch; never False b/c parser throws error already + if (api_enabled := options.get("api_enabled")) is not None: + ca.api_enabled = api_enabled + # Set OCSP responder options if ocsp_responder_key_validity is not None: ca.ocsp_responder_key_validity = ocsp_responder_key_validity diff --git a/ca/django_ca/management/commands/import_ca.py b/ca/django_ca/management/commands/import_ca.py index 828655a91..65c4895e6 100644 --- a/ca/django_ca/management/commands/import_ca.py +++ b/ca/django_ca/management/commands/import_ca.py @@ -65,6 +65,7 @@ def add_arguments(self, parser: CommandParser) -> None: self.add_acme_group(parser) self.add_ocsp_group(parser) + self.add_rest_api_group(parser) self.add_ca_args(parser) parser.add_argument("name", help="Human-readable name of the CA") @@ -150,6 +151,11 @@ def handle( # pylint: disable=too-many-locals,too-many-arguments if acme_profile := options["acme_profile"]: ca.acme_profile = acme_profile + # Set API options + if ca_settings.CA_ENABLE_REST_API: # pragma: no branch; never False b/c parser throws error already + if (api_enabled := options.get("api_enabled")) is not None: + ca.api_enabled = api_enabled + # load public key try: pem_loaded = x509.load_pem_x509_certificate(pem_data) diff --git a/ca/django_ca/management/commands/init_ca.py b/ca/django_ca/management/commands/init_ca.py index b538dd972..0873d9d62 100644 --- a/ca/django_ca/management/commands/init_ca.py +++ b/ca/django_ca/management/commands/init_ca.py @@ -215,6 +215,7 @@ def add_arguments(self, parser: CommandParser) -> None: self.add_acme_group(parser) self.add_ocsp_group(parser) + self.add_rest_api_group(parser) self.add_authority_information_access_group(parser, ("--ca-ocsp-url",), ("--ca-issuer-url",)) self.add_basic_constraints_group(parser) @@ -445,7 +446,7 @@ def handle( # pylint: disable=too-many-arguments,too-many-locals,too-many-branc if options[opt] is not None: kwargs[opt] = options[opt] - if ca_settings.CA_ENABLE_ACME: # pragma: no branch; never False because parser throws error already + if ca_settings.CA_ENABLE_ACME: # pragma: no branch; never False b/c parser throws error already # These settings are only there if ACME is enabled for opt in ["acme_enabled", "acme_registration", "acme_requires_contact"]: if options[opt] is not None: @@ -456,6 +457,10 @@ def handle( # pylint: disable=too-many-arguments,too-many-locals,too-many-branc raise CommandError(f"{acme_profile}: Profile is not defined.") kwargs["acme_profile"] = acme_profile + if ca_settings.CA_ENABLE_REST_API: # pragma: no branch; never False b/c parser throws error already + if (api_enabled := options.get("api_enabled")) is not None: + kwargs["api_enabled"] = api_enabled + try: ca = CertificateAuthority.objects.init( name=name, diff --git a/ca/django_ca/management/mixins.py b/ca/django_ca/management/mixins.py index bddb2db7d..417e0acd2 100644 --- a/ca/django_ca/management/mixins.py +++ b/ca/django_ca/management/mixins.py @@ -202,7 +202,7 @@ def add_general_args(self, parser: CommandParser, default: Optional[str] = "") - return group def add_acme_group(self, parser: CommandParser) -> None: - """Add arguments for ACMEv2.""" + """Add arguments for ACMEv2 (if enabled).""" if not ca_settings.CA_ENABLE_ACME: return @@ -279,6 +279,24 @@ def add_ocsp_group(self, parser: CommandParser) -> None: help="How long (*in seconds*) OCSP responses are valid (default: 86400).", ) + def add_rest_api_group(self, parser: CommandParser) -> None: + """Add arguments for the REST API (if enabled).""" + if not ca_settings.CA_ENABLE_REST_API: + return + + group = parser.add_argument_group("API Access") + enable_group = group.add_mutually_exclusive_group() + enable_group.add_argument( + "--api-enable", + dest="api_enabled", + action="store_true", + default=None, + help="Enable API support.", + ) + enable_group.add_argument( + "--api-disable", dest="api_enabled", action="store_false", help="Disable API support." + ) + def add_ca_args(self, parser: ActionsContainer) -> None: """Add CA arguments.""" group = parser.add_argument_group( diff --git a/ca/django_ca/managers.py b/ca/django_ca/managers.py index fceb094c3..378358fcf 100644 --- a/ca/django_ca/managers.py +++ b/ca/django_ca/managers.py @@ -258,6 +258,7 @@ def init( sign_certificate_policies: Optional[x509.Extension[x509.CertificatePolicies]] = None, ocsp_responder_key_validity: Optional[int] = None, ocsp_response_validity: Optional[int] = None, + api_enabled: Optional[bool] = None, ) -> "CertificateAuthority": """Create a new certificate authority. @@ -340,6 +341,8 @@ def init( How long (in days) OCSP responder keys should be valid. ocsp_response_validity : int, optional How long (in seconds) OCSP responses should be valid. + api_enabled : bool, optional + If the REST API shall be enabled. Raises ------ @@ -456,6 +459,7 @@ def init( sign_certificate_policies=sign_certificate_policies, ocsp_responder_key_validity=ocsp_responder_key_validity, ocsp_response_validity=ocsp_response_validity, + api_enabled=api_enabled, ) private_key = generate_private_key(key_size, key_type, elliptic_curve) @@ -516,6 +520,8 @@ def init( ca.ocsp_responder_key_validity = ocsp_responder_key_validity if ocsp_response_validity is not None: ca.ocsp_response_validity = ocsp_response_validity + if api_enabled is not None: + ca.api_enabled = api_enabled ca.update_certificate(certificate) diff --git a/ca/django_ca/migrations/0036_certificateauthority_api_enabled.py b/ca/django_ca/migrations/0036_certificateauthority_api_enabled.py new file mode 100644 index 000000000..96dad1bba --- /dev/null +++ b/ca/django_ca/migrations/0036_certificateauthority_api_enabled.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.6 on 2023-10-28 08:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("django_ca", "0035_certificateorder"), + ] + + operations = [ + migrations.AddField( + model_name="certificateauthority", + name="api_enabled", + field=models.BooleanField( + default=False, + help_text="Whether it is possible to use the API for this CA.", + verbose_name="Enable API", + ), + ), + ] diff --git a/ca/django_ca/models.py b/ca/django_ca/models.py index 048993608..749635741 100644 --- a/ca/django_ca/models.py +++ b/ca/django_ca/models.py @@ -603,6 +603,13 @@ class CertificateAuthority(X509CertMixin): ) # CAA record and website are general fields + # API fields + api_enabled = models.BooleanField( + default=False, + verbose_name=_("Enable API"), + help_text=_("Whether it is possible to use the API for this CA."), + ) + _key = None def key(self, password: Optional[Union[str, bytes]] = None) -> CertificateIssuerPrivateKeyTypes: diff --git a/ca/django_ca/tests/admin/test_admin_ca.py b/ca/django_ca/tests/admin/test_admin_ca.py index 56dfb47ae..f88936a1f 100644 --- a/ca/django_ca/tests/admin/test_admin_ca.py +++ b/ca/django_ca/tests/admin/test_admin_ca.py @@ -37,10 +37,15 @@ class CertificateAuthorityAdminViewTestCase(StandardAdminViewTestCaseMixin[Certi ) @override_settings(CA_ENABLE_ACME=False) - def test_change_view_with_acme(self) -> None: + def test_change_view_without_acme(self) -> None: """Basic tests but with ACME support disabled.""" self.test_change_view() + @override_settings(CA_ENABLE_REST_API=False) + def test_change_view_without_api(self) -> None: + """Basic tests but with API support disabled.""" + self.test_change_view() + def test_complex_sign_certificate_policies(self) -> None: """Test that complex Certificate Policy extensions are read-only.""" ca = self.cas["root"] diff --git a/ca/django_ca/tests/api/conftest.py b/ca/django_ca/tests/api/conftest.py index e7d605211..4488c5ba4 100644 --- a/ca/django_ca/tests/api/conftest.py +++ b/ca/django_ca/tests/api/conftest.py @@ -33,7 +33,7 @@ ListResponse = List[DetailResponse] -@pytest.fixture() +@pytest.fixture def api_user(user: User, api_permission: Tuple[Type[Model], str]) -> User: """Extend user fixture to add required permission.""" content_type = ContentType.objects.get_for_model(api_permission[0]) @@ -42,7 +42,7 @@ def api_user(user: User, api_permission: Tuple[Type[Model], str]) -> User: return user -@pytest.fixture() +@pytest.fixture def api_client(client: Client, api_user: User) -> Client: """HTTP client with HTTP basic authentication for the user.""" credentials = base64.b64encode(api_user.username.encode("utf-8") + b":password").decode() @@ -50,6 +50,14 @@ def api_client(client: Client, api_user: User) -> Client: return client +@pytest.fixture +def root(root: CertificateAuthority) -> CertificateAuthority: + """Extend root fixture to enable API access.""" + root.api_enabled = True + root.save() + return root + + @pytest.fixture def root_response(root: CertificateAuthority) -> DetailResponse: """Fixture for the expected response schema for the root CA.""" @@ -104,6 +112,8 @@ class APIPermissionTestBase: """Base class for testing permission handling in API views.""" path: str + expected_disabled_status_code = HTTPStatus.NOT_FOUND + expected_disabled_response: Any = {"detail": "Not Found"} def request(self, client: Client) -> HttpResponse: """Make a default request to the view under test (non-GET requests must override this).""" @@ -131,3 +141,21 @@ def test_user_with_no_permissions(self, user: User, api_client: Client) -> None: response = self.request(api_client) assert response.status_code == HTTPStatus.FORBIDDEN, response.content assert response.json() == {"detail": "Forbidden"}, response.json() + + def test_disabled_ca(self, api_client: Client, root: CertificateAuthority) -> None: + """Test that disabling the API access for the CA really disables it.""" + root.enabled = False + root.save() + + response = self.request(api_client) + assert response.status_code == self.expected_disabled_status_code, response.content + assert response.json() == self.expected_disabled_response + + def test_disabled_api_access(self, api_client: Client, root: CertificateAuthority) -> None: + """Test that disabling the API access for the CA really disables it.""" + root.api_enabled = False + root.save() + + response = self.request(api_client) + assert response.status_code == self.expected_disabled_status_code, response.content + assert response.json() == self.expected_disabled_response diff --git a/ca/django_ca/tests/api/test_list_cas.py b/ca/django_ca/tests/api/test_list_cas.py index ea81419a8..25f707bb0 100644 --- a/ca/django_ca/tests/api/test_list_cas.py +++ b/ca/django_ca/tests/api/test_list_cas.py @@ -65,10 +65,12 @@ def test_list_view(api_client: Client, expected_response: ListResponse) -> None: assert response.json() == expected_response, response.json() +@pytest.mark.usefixtures("root") @freeze_time(TIMESTAMPS["everything_expired"]) def test_expired_certificate_authorities_are_excluded(api_client: Client) -> None: """Test that expired CAs are excluded by default.""" response = request(api_client) + assert CertificateAuthority.objects.count() > 0 # just to be sure that there would be some assert response.status_code == HTTPStatus.OK, response.content assert response.json() == [], response.json() @@ -81,18 +83,10 @@ def test_expired_filter(api_client: Client, expected_response: ListResponse) -> assert response.json() == expected_response, response.json() -@freeze_time(TIMESTAMPS["everything_valid"]) -def test_disabled_ca(api_client: Client, root: CertificateAuthority) -> None: - """Test that a disabled CA is *not* included.""" - root.enabled = False - root.save() - - response = request(api_client) - assert response.status_code == HTTPStatus.OK, response.content - assert response.json() == [], response.json() - - class TestPermissions(APIPermissionTestBase): """Test permissions for this view.""" path = path + + expected_disabled_status_code = HTTPStatus.OK + expected_disabled_response = [] diff --git a/ca/django_ca/tests/api/test_sign_cert.py b/ca/django_ca/tests/api/test_sign_cert.py index c67604450..5cf2be2c7 100644 --- a/ca/django_ca/tests/api/test_sign_cert.py +++ b/ca/django_ca/tests/api/test_sign_cert.py @@ -507,18 +507,6 @@ def test_expired_ca(api_client: Client) -> None: assert response.json() == {"detail": "Not Found"}, response.json() -@pytest.mark.usefixtures("tmpcadir") -@freeze_time(TIMESTAMPS["everything_valid"]) -def test_disabled_ca(api_client: Client, root: CertificateAuthority) -> None: - """Test that you cannot sign a certificate for a disabled CA.""" - root.enabled = False - root.save() - - response = request(api_client, {"csr": CERT_DATA["root-cert"]["csr"]["pem"], "subject": default_subject}) - assert response.status_code == HTTPStatus.NOT_FOUND, response.content - assert response.json() == {"detail": "Not Found"}, response.json() - - class TestPermissions(APIPermissionTestBase): """Test permissions for this view.""" diff --git a/ca/django_ca/tests/api/test_update_ca.py b/ca/django_ca/tests/api/test_update_ca.py index b2d6a0772..d7e03e9af 100644 --- a/ca/django_ca/tests/api/test_update_ca.py +++ b/ca/django_ca/tests/api/test_update_ca.py @@ -178,22 +178,6 @@ def test_update_expired_ca( assert response.json() == expected_response, response.json() -@freeze_time(TIMESTAMPS["everything_valid"]) -def test_disabled_ca(root: CertificateAuthority, api_client: Client, payload: Dict[str, Any]) -> None: - """Test that a disabled CA is *not* updatable.""" - root.enabled = False - root.save() - - response = request(api_client, payload) - assert response.status_code == HTTPStatus.NOT_FOUND, response.content - assert response.json() == {"detail": "Not Found"}, response.json() - - # Make sure that fields where not updated in the database - refetched_root: CertificateAuthority = CertificateAuthority.objects.get(pk=root.pk) - assert root.terms_of_service == refetched_root.terms_of_service - assert root.website == refetched_root.website - - class TestPermissions(APIPermissionTestBase): """Test permissions for this view.""" @@ -201,3 +185,18 @@ class TestPermissions(APIPermissionTestBase): def request(self, client: Client) -> HttpResponse: return request(client, {"ocsp_responder_key_validity": 10}) + + def test_disabled_ca(self, api_client: Client, root: CertificateAuthority) -> None: + super().test_disabled_ca(api_client, root) + + # Make sure that fields where not updated in the database + root.refresh_from_db() + assert root.ocsp_responder_key_validity == 3 + + def test_disabled_api_access(self, api_client: Client, root: CertificateAuthority) -> None: + """Test that disabling the API access for the CA really disables it.""" + super().test_disabled_api_access(api_client, root) + + # Make sure that fields where not updated in the database + root.refresh_from_db() + assert root.ocsp_responder_key_validity == 3 diff --git a/ca/django_ca/tests/api/test_view_ca.py b/ca/django_ca/tests/api/test_view_ca.py index aa26a2e3e..d646b2bb4 100644 --- a/ca/django_ca/tests/api/test_view_ca.py +++ b/ca/django_ca/tests/api/test_view_ca.py @@ -53,17 +53,6 @@ def test_view_expired_ca(api_client: Client, root_response: Dict[str, Any]) -> N assert response.json() == root_response, response.json() -@freeze_time(TIMESTAMPS["everything_valid"]) -def test_disabled_ca(root: CertificateAuthority, api_client: Client) -> None: - """Test that a disabled CA is *not* viewable.""" - root.enabled = False - root.save() - - response = api_client.get(path) - assert response.status_code == HTTPStatus.NOT_FOUND, response.content - assert response.json() == {"detail": "Not Found"}, response.json() - - class TestPermissions(APIPermissionTestBase): """Test permissions for this view.""" diff --git a/ca/django_ca/tests/api/test_view_cert.py b/ca/django_ca/tests/api/test_view_cert.py index b5a7aea8a..5020e93bf 100644 --- a/ca/django_ca/tests/api/test_view_cert.py +++ b/ca/django_ca/tests/api/test_view_cert.py @@ -22,7 +22,7 @@ import pytest from freezegun import freeze_time -from django_ca.models import Certificate, CertificateAuthority +from django_ca.models import Certificate from django_ca.tests.api.conftest import APIPermissionTestBase from django_ca.tests.base.constants import CERT_DATA, TIMESTAMPS @@ -54,17 +54,6 @@ def test_expired_certificate(api_client: Client, root_cert_response: Dict[str, A assert response.json() == root_cert_response, response.json() -@freeze_time(TIMESTAMPS["everything_valid"]) -def test_disabled_ca(root: CertificateAuthority, api_client: Client) -> None: - """Test that certificates for a disabled can *not* be viewed.""" - root.enabled = False - root.save() - - response = api_client.get(path) - assert response.status_code == HTTPStatus.NOT_FOUND, response.content - assert response.json() == {"detail": "Not Found"}, response.json() - - class TestPermissions(APIPermissionTestBase): """Test permissions for this view.""" diff --git a/ca/django_ca/tests/commands/test_edit_ca.py b/ca/django_ca/tests/commands/test_edit_ca.py index c96e60bd1..14d102886 100644 --- a/ca/django_ca/tests/commands/test_edit_ca.py +++ b/ca/django_ca/tests/commands/test_edit_ca.py @@ -167,6 +167,26 @@ def test_acme_arguments(self) -> None: self.assertEqual(excm.exception.args, (2,)) self.assertIs(self.ca.acme_requires_contact, True) # state unchanged + @override_tmpcadir() + def test_rest_api_arguments(self) -> None: + """Test REST API arguments.""" + # Test initial state + self.assertIs(self.ca.api_enabled, False) + + # change all settings + self.edit_ca("--api-enable") + self.assertIs(self.ca.api_enabled, True) + + # Try mutually exclusive arguments + with self.assertRaisesRegex(SystemExit, r"^2$") as excm: + self.edit_ca("--api-enable", "--api-disable") + self.assertEqual(excm.exception.args, (2,)) + self.assertIs(self.ca.api_enabled, True) # state unchanged + + # change all settings + self.edit_ca("--api-disable") + self.assertIs(self.ca.api_enabled, False) + @override_tmpcadir() def test_ocsp_responder_arguments(self) -> None: """Test ACME arguments.""" diff --git a/ca/django_ca/tests/commands/test_import_ca.py b/ca/django_ca/tests/commands/test_import_ca.py index f2476b603..6506b4de3 100644 --- a/ca/django_ca/tests/commands/test_import_ca.py +++ b/ca/django_ca/tests/commands/test_import_ca.py @@ -101,6 +101,7 @@ def test_basic(self) -> None: self.assertIs(ca.acme_registration, True) self.assertEqual(ca.acme_profile, ca_settings.CA_DEFAULT_PROFILE) self.assertIs(ca.acme_requires_contact, True) + self.assertIs(ca.api_enabled, False) @override_tmpcadir() @freeze_time(TIMESTAMPS["everything_valid"]) @@ -237,6 +238,12 @@ def test_acme_arguments(self) -> None: self.assertIs(ca.acme_requires_contact, False) self.assertIs(ca.acme_registration, False) + @override_tmpcadir() + def test_rest_api_arguments(self) -> None: + """Test REST API arguments.""" + ca = self.import_ca("--api-enable") + self.assertIs(ca.api_enabled, True) + @override_tmpcadir() def test_ocsp_responder_arguments(self) -> None: """Test OCSP responder arguments.""" diff --git a/ca/django_ca/tests/commands/test_init_ca.py b/ca/django_ca/tests/commands/test_init_ca.py index 9a34481d5..eb81380d2 100644 --- a/ca/django_ca/tests/commands/test_init_ca.py +++ b/ca/django_ca/tests/commands/test_init_ca.py @@ -603,9 +603,20 @@ def test_acme_arguments(self) -> None: self.assertEqual(ca.acme_profile, "client") self.assertIs(ca.acme_requires_contact, False) - @override_tmpcadir(CA_MIN_KEY_SIZE=1024, CA_ENABLE_ACME=False) - def test_disabled_acme_arguments(self) -> None: - """Test that ACME options don't work when ACME is disabled.""" + @override_tmpcadir(CA_MIN_KEY_SIZE=1024, CA_ENABLE_REST_API=True) + def test_api_arguments(self) -> None: + """Test REST API arguments.""" + ca = self.init_ca_e2e( + "Test CA", + "/CN=api.example.com", + "--api-enable", + ) + + self.assertIs(ca.api_enabled, True) + + @override_tmpcadir(CA_MIN_KEY_SIZE=1024, CA_ENABLE_ACME=False, CA_ENABLE_REST_API=False) + def test_disabled_arguments(self) -> None: + """Test that ACME/REST API options don't work when feature is disabled.""" with self.assertSystemExit(2): self.cmd_e2e(["init_ca", "Test CA", "/CN=example.com", "--acme-enable"]) @@ -624,6 +635,9 @@ def test_disabled_acme_arguments(self) -> None: with self.assertSystemExit(2): self.cmd_e2e(["init_ca", "Test CA", "/CN=example.com", "--acme-profile=client"]) + with self.assertSystemExit(2): + self.cmd_e2e(["init_ca", "Test CA", "/CN=example.com", "--api-enable"]) + @override_tmpcadir() def test_unknown_acme_profile(self) -> None: """Test naming an unknown profile.""" diff --git a/ca/django_ca/tests/test_managers.py b/ca/django_ca/tests/test_managers.py index 17d685362..d8f11daec 100644 --- a/ca/django_ca/tests/test_managers.py +++ b/ca/django_ca/tests/test_managers.py @@ -352,13 +352,21 @@ def test_acme_parameters(self) -> None: name = "acme" with self.assertCreateCASignals(): ca = CertificateAuthority.objects.init( - name, self.subject, acme_enabled=False, acme_profile="client", acme_requires_contact=False + name, self.subject, acme_enabled=True, acme_profile="client", acme_requires_contact=False ) self.assertProperties(ca, name, self.subject) - self.assertFalse(ca.acme_enabled) + self.assertTrue(ca.acme_enabled) self.assertEqual(ca.acme_profile, "client") self.assertFalse(ca.acme_requires_contact) - ca.key().public_key() # just access private key to make sure we can load it + + @override_tmpcadir() + def test_api_parameters(self) -> None: + """Test parameters for the REST API.""" + name = "api" + with self.assertCreateCASignals(): + ca = CertificateAuthority.objects.init(name, self.subject, api_enabled=True) + self.assertProperties(ca, name, self.subject) + self.assertTrue(ca.api_enabled) def test_unknown_profile(self) -> None: """Test creating a certificate authority with a profile that doesn't exist.""" diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 9ce2e6ec0..6731670a7 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -22,13 +22,16 @@ Backwards incompatible changes * Support for non-standard algorithm names in profile settings was removed. * Drop support for ``Django==4.1``, ``cryptography==40.x``, ``acme==1.25.0`` and ``celery==5.2.x``. -API changes -=========== +REST API changes +================ -.. NOTE:: The API is still experimental and API endpoints will change without notice. +.. NOTE:: The :doc:`rest_api` is still experimental and endpoints will change without notice. * Certificate issuance is now asynchronous, similar to how certificates are issued via ACME. This enables - using CAs where the private key is not directly available to the webserver. + using CAs where the private key is not directly available to the web server. +* The REST API must now be enabled explicitly for each certificate authority. This can be done via the admin + interface or the ``--enable-api`` flag for :command:`manage.py init_ca`, :command:`manage.py edit_ca` and + :command:`manage.py import_ca`. .. _changelog-1.26.0: diff --git a/docs/source/conf.py b/docs/source/conf.py index d07edbbe9..35e023153 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -80,6 +80,7 @@ # Enable Celery task docs: https://docs.celeryproject.org/en/latest/userguide/sphinx.html "celery.contrib.sphinx", "numpydoc", + "sphinx_inline_tabs", "sphinx_jinja", "sphinxcontrib.openapi", "sphinxcontrib.jquery", diff --git a/docs/source/index.rst b/docs/source/index.rst index a456d82c0..a97ae35f0 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -23,8 +23,8 @@ Welcome to django-ca's documentation! ... as Django app ... from source - ... with docker - ... with docker compose + ... with Docker + ... with Docker Compose .. toctree:: :maxdepth: 1 diff --git a/docs/source/quickstart_docker_compose.rst b/docs/source/quickstart_docker_compose.rst index ce472af0f..fa8ac3584 100644 --- a/docs/source/quickstart_docker_compose.rst +++ b/docs/source/quickstart_docker_compose.rst @@ -1,5 +1,5 @@ ############################## -Quickstart with docker compose +Quickstart with Docker Compose ############################## .. _docker-compose: diff --git a/docs/source/rest_api.rst b/docs/source/rest_api.rst index 32100e93d..ca9ef7703 100644 --- a/docs/source/rest_api.rst +++ b/docs/source/rest_api.rst @@ -35,15 +35,48 @@ Enable the API To enable the API, you need to set the :ref:`settings-ca-enable-rest-api` setting to ``True``. -**************************** -Authentication/Authorization -**************************** +You must also enable API access individually for every certificate authority. The exact invocation depends on +how you installed **django-ca**: -The API uses standard Django users with HTTP Basic Authentication for *authentication* and Django permissions -are used for *authorization*. +.. tab:: Django app -The easiest way to add an API user is via the admin interface in the browser. Different permissions are -required per endpoint: + .. code-block:: console + + user@host:~$ python manage.py list_cas # Get CA serial + E4:7C:17:... - Root + user@host:~$ python manage.py edit_ca --api-enable E4:7C:17:... + +.. tab:: from source + + .. code-block:: console + + user@host:~$ django-ca list_cas # Get CA serial + E4:7C:17:... - Root + user@host:~$ django-ca edit_ca --api-enable E4:7C:17:... + +.. tab:: with Docker + + .. code-block:: console + + user@host:~$ docker exec backend manage list_cas # Get CA serial + E4:7C:17:... - Root + user@host:~$ docker exec backend manage edit_ca --api-enable E4:7C:17:... + +.. tab:: with Docker Compose + + .. code-block:: console + + user@host:~/ca/$ docker compose exec backend manage list_cas # Get CA serial + E4:7C:17:... - Root + user@host:~/ca/$ docker compose exec backend manage edit_ca --api-enable E4:7C:17:... + +****************** +Create an API user +****************** + +The API uses the built in Django users and permissions and HTTP Basic Authentication. + +The easiest way to add a user is via the Django admin interface. The following permissions are required: ================================ ============================================================================= Required permission endpoints @@ -51,7 +84,8 @@ Required permission endpoints Can view Certificate Authority * ``GET /api/ca/`` - List available certificate authorities * ``GET /api/ca/{serial}/`` - View certificate authority Can change Certificate Authority * ``PUT /api/ca/{serial}/`` - Update certificate authority -Can sign Certificate ``POST /api/ca/{serial}/sign/`` - Sign a certificate +Can sign Certificate * ``POST /api/ca/{serial}/sign/`` - Sign a certificate + * ``GET /api/ca/{serial}/orders/{order}/`` - Certificate order information Can view Certificate * ``GET /ca/{serial}/certs/`` - List certificates * ``GET /ca/{serial}/certs/{certificate_serial}/`` - View certificate Can revoke Certificate ``POST /ca/{serial}/revoke/{certificate_serial}/`` - Revoke certificate @@ -66,13 +100,137 @@ create users, so you have to create them via :command:`manage.py shell` with our >>> from django_ca.api.utils import create_api_user >>> create_api_user('api-username', password='api-password') +********** +Quickstart +********** + +The API is available under ``/django_ca/api/`` by default, view the documentation under +``/django_ca/api/docs`` (the ``/django_ca/`` prefix can be removed/modified with the +:ref:`settings-ca-url-path` setting). + +In the following subsections you can learn how to use the API to issue a new certificate. + +Create a CSR +============ + +The first step to retrieve a certificate is to create a private key and a certificate signing request (CSR). +Note that the contents of the CSR (e.g. the subject) is **not** used, as the information is provided in the +request itself. + +.. tab:: curl + + .. code-block:: console + + user@host:~$ openssl genrsa -out priv.pem 4096 + user@host:~$ openssl req -new -key priv.pem -out csr.pem -utf8 -batch -subj '/' + +.. tab:: Python + + .. code-block:: python + + >>> # Generate a private key and simplest possible CSR. See also: + >>> # https://cryptography.io/en/latest/x509/tutorial/ + >>> from cryptography import x509 + >>> from cryptography.hazmat.primitives import hashes, serialization + >>> from cryptography.hazmat.primitives.asymmetric import rsa + >>> from cryptography.x509.oid import NameOID + >>> key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + >>> csr = x509.CertificateSigningRequestBuilder().subject_name( + ... x509.Name([]) # NOTE: The subject in the CSR is not used + ... ).sign(key, hashes.SHA256()) + +Retrieve the serial of the CA +============================= + +If you don't know the serial of the CA, you can retrieve it using one simple API endpoint: + +.. tab:: curl + + .. code-block:: console + + user@host:~$ curl -u user https://ca.example.com/django_ca/api/ca/ + [{"serial": "E47C17...", ...}, ...] + +.. tab:: Python + + Generate a CSR using cryptography and retrieve a new certificate for ``example.com`` using requests. + + .. code-block:: python + + >>> import requests + >>> url = "https://ca.example.com/django_ca/api/ca/" + >>> auth = ("user", "password") + >>> serial = requests.get(url, auth=auth).json()[0]["serial"] + +Create/poll certificate order +============================= + +Request that the certificate authority issues a new certificate by creating a certificate order. You then poll +the order until the certificate is issued: + +.. tab:: curl + + .. code-block:: console + + user@host:~$ curl \ + > -u user \ + > -H "Content-Type: application/json" \ + > -d "{\"csr\": \"`awk '{printf "%s\\\\n", $0}' csr.pem`\", \"subject\": [{\"oid\": \"2.5.4.3\", \"value\": \"example.com\"}]}" \ + > https://ca.example.com/django_ca/api/ca/E47C17.../sign/ + {"slug": "wj5ryHjWx4OT", "status": "pending", "serial": null, ...} + user@host:~$ curl -u user https://ca.example.com/django_ca/api/ca/E47C17.../orders/wj5ryHjWx4OT/ + {"slug": "wj5ryHjWx4OT", "status": "issued", "serial": "3D6CA7...", ...} + +.. tab:: Python + + .. code-block:: python + + >>> order = requests.post( + ... f"{url}{serial}/sign/", + ... auth=auth, + ... json={ + ... "csr": csr.public_bytes(serialization.Encoding.PEM).decode('utf-8'), + ... "subject": [ + ... { + ... "oid": NameOID.COMMON_NAME.dotted_string, + ... "value": "example.com" + ... }, + ... ] + ... } + ... ).json() + >>> status = order["status"] # equals "pending" + >>> + >>> # Poll the order until finished (BEWARE: in this simplicity, this may loop indefinitely!) + >>> order_url = f"{url}{serial}/orders/{order['slug']}/" + >>> while status == "pending": + ... order = requests.get(order_url, auth=auth).json() + ... status = order["status"] + +Retrieve certificate +==================== + +Once the certificate is issued, it is time to retrieve it: + +.. tab:: curl + + .. code-block:: console + + user@host:~$ curl -u user https://ca.example.com/django_ca/api/ca/E47C17.../certs/3D6CA7.../ + {"serial": "3D6CA7...", "pem": "-----BEGIN CERTIFICATE-----\n...", ...} + +.. tab:: Python + + .. code-block:: python + + >>> pem = requests.get(f"{url}{serial}/certs/{order['serial']}/", auth=auth).json()["pem"] + ***************** API documentation ***************** You can always view the API documentation of your current **django-ca** version by viewing -https://example.com/django-ca/api/docs/. You can also download the current :download:`openapi.json +https://example.com/django_ca/api/docs. You can also download the current :download:`openapi.json ` directly. Below is the documentation for the current version (note that responses are currently not rendered due to diff --git a/requirements/requirements-docs.txt b/requirements/requirements-docs.txt index 7e2f68f2e..8d65af760 100644 --- a/requirements/requirements-docs.txt +++ b/requirements/requirements-docs.txt @@ -4,6 +4,7 @@ # https://github.com/readthedocs/sphinx_rtd_theme/issues/1323 doc8==0.11.2 numpydoc==1.6.0 +sphinx-inline-tabs==2023.4.21 sphinx-jinja==2.0.2 sphinx_rtd_theme==1.3.0 sphinxcontrib-openapi==0.8.1