From 382e91d87a03798b0bedbba52c5aeec1b60c0235 Mon Sep 17 00:00:00 2001 From: Mia Altieri <32723809+MiaAltieri@users.noreply.github.com> Date: Thu, 12 Jan 2023 05:16:51 -0700 Subject: [PATCH] change admin user to be named operator (#147) * change admin user to be named operator * lint + unit test correction + fetch lib * Update actions.yaml Co-authored-by: Mykola Marzhan <303592+delgod@users.noreply.github.com> --- README.md | 10 +- actions.yaml | 9 + lib/charms/mongodb/v0/mongodb.py | 2 +- .../v1/tls_certificates.py | 190 +++++++++++++++--- src/charm.py | 33 ++- tests/integration/ha_tests/helpers.py | 16 +- tests/integration/helpers.py | 4 +- tests/integration/test_charm.py | 4 +- tests/integration/tls_tests/helpers.py | 2 +- tests/unit/test_charm.py | 14 +- 10 files changed, 225 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 3794e4815..261dd5811 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This operator charm deploys and operates MongoDB on physical or virtual machines auto-delete - `boolean`; When a relation is removed, auto-delete ensures that any relevant databases associated with the relation are also removed. Set with `juju config mongodb auto-delete=`. -admin-password - `string`; The password for the database admin. Set with `juju run-action mongodb/leader set-password --wait` +operator password - `string`; The password for the database admin (named "operator"). Set with `juju run-action mongodb/leader set-password --wait` tls external key - `string`; TLS external key for encryption outside the cluster. Set with `juju run-action mongodb/0 set-tls-private-key "external-key=$(base64 -w0 external-key-0.pem)" --wait` @@ -132,13 +132,13 @@ juju remove-relation mongodb tls-certificates-operator Note: The TLS settings here are for self-signed-certificates which are not recommended for production clusters, the `tls-certificates-operator` charm offers a variety of configurations, read more on the TLS charm [here](https://charmhub.io/tls-certificates-operator) ### Password rotation -#### Internal admin user -The admin user is used internally by the Charmed MongoDB Operator, the `set-password` action can be used to rotate its password. +#### Internal operator user +The operator user is used internally by the Charmed MongoDB Operator, the `set-password` action can be used to rotate its password. ```shell -# to set a specific password for the admin user +# to set a specific password for the operator user juju run-action mongodb/leader set-password password= --wait -# to randomly generate a password for the admin user +# to randomly generate a password for the operator user juju run-action mongodb/leader set-password --wait ``` diff --git a/actions.yaml b/actions.yaml index ac7dd3ec5..0eccf4346 100644 --- a/actions.yaml +++ b/actions.yaml @@ -7,11 +7,20 @@ get-primary: get-password: description: Change the admin user's password, which is used by charm. It is for internal charm users and SHOULD NOT be used by applications. + params: + username: + type: string + description: The username, the default value 'operator'. + Possible values - operator, backup. set-password: description: Change the admin user's password, which is used by charm. It is for internal charm users and SHOULD NOT be used by applications. params: + username: + type: string + description: The username, the default value 'operator'. + Possible values - operator, backup. password: type: string description: The password will be auto-generated if this option is not specified. diff --git a/lib/charms/mongodb/v0/mongodb.py b/lib/charms/mongodb/v0/mongodb.py index ee59f5ed7..1edefbad8 100644 --- a/lib/charms/mongodb/v0/mongodb.py +++ b/lib/charms/mongodb/v0/mongodb.py @@ -35,7 +35,7 @@ logger = logging.getLogger(__name__) # List of system usernames needed for correct work on the charm. -CHARM_USERS = ["operator"] +CHARM_USERS = ["operator", "backup"] @dataclass diff --git a/lib/charms/tls_certificates_interface/v1/tls_certificates.py b/lib/charms/tls_certificates_interface/v1/tls_certificates.py index a03b318eb..11ed54451 100644 --- a/lib/charms/tls_certificates_interface/v1/tls_certificates.py +++ b/lib/charms/tls_certificates_interface/v1/tls_certificates.py @@ -126,6 +126,7 @@ def _on_certificate_revocation_request(self, event: CertificateRevocationRequest from charms.tls_certificates_interface.v1.tls_certificates import ( CertificateAvailableEvent, CertificateExpiringEvent, + CertificateRevokedEvent, TLSCertificatesRequiresV1, generate_csr, generate_private_key, @@ -151,6 +152,9 @@ def __init__(self, *args): self.framework.observe( self.certificates.on.certificate_expiring, self._on_certificate_expiring ) + self.framework.observe( + self.certificates.on.certificate_revoked, self._on_certificate_revoked + ) def _on_install(self, event) -> None: private_key_password = b"banana" @@ -211,6 +215,30 @@ def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: ) replicas_relation.data[self.app].update({"csr": new_csr.decode()}) + def _on_certificate_revoked(self, event: CertificateRevokedEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + old_csr = replicas_relation.data[self.app].get("csr") + private_key_password = replicas_relation.data[self.app].get("private_key_password") + private_key = replicas_relation.data[self.app].get("private_key") + new_csr = generate_csr( + private_key=private_key.encode(), + private_key_password=private_key_password.encode(), + subject=self.cert_subject, + ) + self.certificates.request_certificate_renewal( + old_certificate_signing_request=old_csr, + new_certificate_signing_request=new_csr, + ) + replicas_relation.data[self.app].update({"csr": new_csr.decode()}) + replicas_relation.data[self.app].pop("certificate") + replicas_relation.data[self.app].pop("ca") + replicas_relation.data[self.app].pop("chain") + self.unit.status = WaitingStatus("Waiting for new certificate") + if __name__ == "__main__": main(ExampleRequirerCharm) @@ -222,12 +250,15 @@ def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: import logging import uuid from datetime import datetime, timedelta +from ipaddress import IPv4Address from typing import Dict, List, Optional from cryptography import x509 +from cryptography.hazmat._oid import ExtensionOID from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.extensions import Extension, ExtensionNotFound from jsonschema import exceptions, validate # type: ignore[import] from ops.charm import CharmBase, CharmEvents, RelationChangedEvent, UpdateStatusEvent from ops.framework import EventBase, EventSource, Handle, Object @@ -240,7 +271,7 @@ def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 9 +LIBPATCH = 11 REQUIRER_JSON_SCHEMA = { "$schema": "http://json-schema.org/draft-04/schema#", @@ -280,7 +311,7 @@ def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: "type": "object", "title": "`tls_certificates` provider root schema", "description": "The `tls_certificates` root schema comprises the entire provider databag for this interface.", # noqa: E501 - "example": [ + "examples": [ { "certificates": [ { @@ -292,7 +323,20 @@ def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: "certificate": "-----BEGIN CERTIFICATE-----\nMIICvDCCAaQCFFPAOD7utDTsgFrm0vS4We18OcnKMA0GCSqGSIb3DQEBCwUAMCAx\nCzAJBgNVBAYTAlVTMREwDwYDVQQDDAh3aGF0ZXZlcjAeFw0yMjA3MjkyMTE5Mzha\nFw0yMzA3MjkyMTE5MzhaMBUxEzARBgNVBAMMCmJhbmFuYS5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVpcfcBOnFuyZG+A2WQzmaBI5NXgwTCfvE\neKciqRQXhzJdUkEg7eqwFrK3y9yjhoiB6q0WNAeR+nOdS/Cw7layRtGz5skOq7Aa\nN4FZHg0or30i7Rrx7afJcGJyLpxfK/OfLmJm5QEdLXV0DZp0L5vuhhEb1EUOrMaY\nGe4iwqTyg6D7fuBili9dBVn9IvNhYMVgtiqkWVLTW4ChE0LgES4oO3rQZgp4dtM5\nsp6KwHGO766UzwGnkKRizaqmLylfVusllWNPFfp6gEaxa45N70oqGUrvGSVHWeHf\nfvkhpWx+wOnu+2A5F/Yv3UNz2v4g7Vjt7V0tjL4KMV9YklpRjTh3AgMBAAEwDQYJ\nKoZIhvcNAQELBQADggEBAChjRzuba8zjQ7NYBVas89Oy7u++MlS8xWxh++yiUsV6\nWMk3ZemsPtXc1YmXorIQohtxLxzUPm2JhyzFzU/sOLmJQ1E/l+gtZHyRCwsb20fX\nmphuJsMVd7qv/GwEk9PBsk2uDqg4/Wix0Rx5lf95juJP7CPXQJl5FQauf3+LSz0y\nwF/j+4GqvrwsWr9hKOLmPdkyKkR6bHKtzzsxL9PM8GnElk2OpaPMMnzbL/vt2IAt\nxK01ZzPxCQCzVwHo5IJO5NR/fIyFbEPhxzG17QsRDOBR9fl9cOIvDeSO04vyZ+nz\n+kA2c3fNrZFAtpIlOOmFh8Q12rVL4sAjI5mVWnNEgvI=\n-----END CERTIFICATE-----\n", # noqa: E501 } ] - } + }, + { + "certificates": [ + { + "ca": "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n", # noqa: E501 + "chain": [ + "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n" # noqa: E501, W505 + ], + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\n-----END CERTIFICATE REQUEST-----\n", # noqa: E501 + "certificate": "-----BEGIN CERTIFICATE-----\nMIICvDCCAaQCFFPAOD7utDTsgFrm0vS4We18OcnKMA0GCSqGSIb3DQEBCwUAMCAx\nCzAJBgNVBAYTAlVTMREwDwYDVQQDDAh3aGF0ZXZlcjAeFw0yMjA3MjkyMTE5Mzha\nFw0yMzA3MjkyMTE5MzhaMBUxEzARBgNVBAMMCmJhbmFuYS5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVpcfcBOnFuyZG+A2WQzmaBI5NXgwTCfvE\neKciqRQXhzJdUkEg7eqwFrK3y9yjhoiB6q0WNAeR+nOdS/Cw7layRtGz5skOq7Aa\nN4FZHg0or30i7Rrx7afJcGJyLpxfK/OfLmJm5QEdLXV0DZp0L5vuhhEb1EUOrMaY\nGe4iwqTyg6D7fuBili9dBVn9IvNhYMVgtiqkWVLTW4ChE0LgES4oO3rQZgp4dtM5\nsp6KwHGO766UzwGnkKRizaqmLylfVusllWNPFfp6gEaxa45N70oqGUrvGSVHWeHf\nfvkhpWx+wOnu+2A5F/Yv3UNz2v4g7Vjt7V0tjL4KMV9YklpRjTh3AgMBAAEwDQYJ\nKoZIhvcNAQELBQADggEBAChjRzuba8zjQ7NYBVas89Oy7u++MlS8xWxh++yiUsV6\nWMk3ZemsPtXc1YmXorIQohtxLxzUPm2JhyzFzU/sOLmJQ1E/l+gtZHyRCwsb20fX\nmphuJsMVd7qv/GwEk9PBsk2uDqg4/Wix0Rx5lf95juJP7CPXQJl5FQauf3+LSz0y\nwF/j+4GqvrwsWr9hKOLmPdkyKkR6bHKtzzsxL9PM8GnElk2OpaPMMnzbL/vt2IAt\nxK01ZzPxCQCzVwHo5IJO5NR/fIyFbEPhxzG17QsRDOBR9fl9cOIvDeSO04vyZ+nz\n+kA2c3fNrZFAtpIlOOmFh8Q12rVL4sAjI5mVWnNEgvI=\n-----END CERTIFICATE-----\n", # noqa: E501 + "revoked": True, + } + ] + }, ], "properties": { "certificates": { @@ -320,6 +364,10 @@ def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: "$id": "#/properties/certificates/items/chain/items", }, }, + "revoked": { + "$id": "#/properties/certificates/items/revoked", + "type": "boolean", + }, }, "additionalProperties": True, }, @@ -409,6 +457,44 @@ def restore(self, snapshot: dict): self.certificate = snapshot["certificate"] +class CertificateRevokedEvent(EventBase): + """Charm Event triggered when a TLS certificate is revoked.""" + + def __init__( + self, + handle: Handle, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: List[str], + revoked: bool, + ): + super().__init__(handle) + self.certificate = certificate + self.certificate_signing_request = certificate_signing_request + self.ca = ca + self.chain = chain + self.revoked = revoked + + def snapshot(self) -> dict: + """Returns snapshot.""" + return { + "certificate": self.certificate, + "certificate_signing_request": self.certificate_signing_request, + "ca": self.ca, + "chain": self.chain, + "revoked": self.revoked, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.certificate_signing_request = snapshot["certificate_signing_request"] + self.ca = snapshot["ca"] + self.chain = snapshot["chain"] + self.revoked = snapshot["revoked"] + + class CertificateCreationRequestEvent(EventBase): """Charm Event triggered when a TLS certificate is required.""" @@ -548,7 +634,7 @@ def generate_certificate( ca_key: bytes, ca_key_password: Optional[bytes] = None, validity: int = 365, - alt_names: list = None, + alt_names: Optional[List[str]] = None, ) -> bytes: """Generates a TLS certificate based on a CSR. @@ -558,7 +644,7 @@ def generate_certificate( ca_key (bytes): CA private key ca_key_password: CA private key password validity (int): Certificate validity (in days) - alt_names: Certificate Subject alternative names + alt_names (list): List of alt names to put on cert - prefer putting SANs in CSR Returns: bytes: Certificate @@ -577,13 +663,36 @@ def generate_certificate( .not_valid_before(datetime.utcnow()) .not_valid_after(datetime.utcnow() + timedelta(days=validity)) ) + + extensions_list = csr_object.extensions + san_ext: Optional[x509.Extension] = None if alt_names: - names = [x509.DNSName(n) for n in alt_names] + full_sans_dns = alt_names.copy() + try: + loaded_san_ext = csr_object.extensions.get_extension_for_class( + x509.SubjectAlternativeName + ) + full_sans_dns.extend(loaded_san_ext.value.get_values_for_type(x509.DNSName)) + except ExtensionNotFound: + pass + finally: + san_ext = Extension( + ExtensionOID.SUBJECT_ALTERNATIVE_NAME, + False, + x509.SubjectAlternativeName([x509.DNSName(name) for name in full_sans_dns]), + ) + if not extensions_list: + extensions_list = x509.Extensions([san_ext]) + + for extension in extensions_list: + if extension.value.oid == ExtensionOID.SUBJECT_ALTERNATIVE_NAME and san_ext: + extension = san_ext + certificate_builder = certificate_builder.add_extension( - x509.SubjectAlternativeName(names), - critical=False, + extension.value, + critical=extension.critical, ) - certificate_builder._version = x509.Version.v1 + certificate_builder._version = x509.Version.v3 cert = certificate_builder.sign(private_key, hashes.SHA256()) # type: ignore[arg-type] return cert.public_bytes(serialization.Encoding.PEM) @@ -653,11 +762,14 @@ def generate_csr( private_key: bytes, subject: str, add_unique_id_to_subject_name: bool = True, - organization: str = None, - email_address: str = None, - country_name: str = None, + organization: Optional[str] = None, + email_address: Optional[str] = None, + country_name: Optional[str] = None, private_key_password: Optional[bytes] = None, sans: Optional[List[str]] = None, + sans_oid: Optional[List[str]] = None, + sans_ip: Optional[List[str]] = None, + sans_dns: Optional[List[str]] = None, additional_critical_extensions: Optional[List] = None, ) -> bytes: """Generates a CSR using private key and subject. @@ -672,7 +784,11 @@ def generate_csr( email_address (str): Email address. country_name (str): Country Name. private_key_password (bytes): Private key password - sans (list): List of subject alternative names + sans (list): Use sans_dns - this will be deprecated in a future release + List of DNS subject alternative names (keeping it for now for backward compatibility) + sans_oid (list): List of registered ID SANs + sans_dns (list): List of DNS subject alternative names (similar to the arg: sans) + sans_ip (list): List of IP subject alternative names additional_critical_extensions (list): List if critical additional extension objects. Object must be a x509 ExtensionType. @@ -693,13 +809,23 @@ def generate_csr( if country_name: subject_name.append(x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country_name)) csr = x509.CertificateSigningRequestBuilder(subject_name=x509.Name(subject_name)) + + _sans: List[x509.GeneralName] = [] + if sans_oid: + _sans.extend([x509.RegisteredID(x509.ObjectIdentifier(san)) for san in sans_oid]) + if sans_ip: + _sans.extend([x509.IPAddress(IPv4Address(san)) for san in sans_ip]) if sans: - csr = csr.add_extension( - x509.SubjectAlternativeName([x509.DNSName(san) for san in sans]), critical=False - ) + _sans.extend([x509.DNSName(san) for san in sans]) + if sans_dns: + _sans.extend([x509.DNSName(san) for san in sans_dns]) + if _sans: + csr = csr.add_extension(x509.SubjectAlternativeName(set(_sans)), critical=False) + if additional_critical_extensions: for extension in additional_critical_extensions: csr = csr.add_extension(extension, critical=True) + signed_certificate = csr.sign(signing_key, hashes.SHA256()) # type: ignore[arg-type] return signed_certificate.public_bytes(serialization.Encoding.PEM) @@ -717,6 +843,7 @@ class CertificatesRequirerCharmEvents(CharmEvents): certificate_available = EventSource(CertificateAvailableEvent) certificate_expiring = EventSource(CertificateExpiringEvent) certificate_expired = EventSource(CertificateExpiredEvent) + certificate_revoked = EventSource(CertificateRevokedEvent) class TLSCertificatesProvidesV1(Object): @@ -778,8 +905,8 @@ def _add_certificate( def _remove_certificate( self, relation_id: int, - certificate: str = None, - certificate_signing_request: str = None, + certificate: Optional[str] = None, + certificate_signing_request: Optional[str] = None, ) -> None: """Removes certificate from a given relation based on user provided certificate or csr. @@ -834,7 +961,11 @@ def revoke_all_certificates(self) -> None: This method is meant to be used when the Root CA has changed. """ for relation in self.model.relations[self.relationship_name]: - relation.data[self.model.app]["certificates"] = json.dumps([]) + provider_relation_data = _load_relation_data(relation.data[self.charm.app]) + provider_certificates = copy.deepcopy(provider_relation_data.get("certificates", [])) + for certificate in provider_certificates: + certificate["revoked"] = True + relation.data[self.model.app]["certificates"] = json.dumps(provider_certificates) def set_relation_certificate( self, @@ -1164,12 +1295,21 @@ def _on_relation_changed(self, event: RelationChangedEvent) -> None: ] for certificate in self._provider_certificates: if certificate["certificate_signing_request"] in requirer_csrs: - self.on.certificate_available.emit( - certificate_signing_request=certificate["certificate_signing_request"], - certificate=certificate["certificate"], - ca=certificate["ca"], - chain=certificate["chain"], - ) + if certificate.get("revoked", False): + self.on.certificate_revoked.emit( + certificate_signing_request=certificate["certificate_signing_request"], + certificate=certificate["certificate"], + ca=certificate["ca"], + chain=certificate["chain"], + revoked=True, + ) + else: + self.on.certificate_available.emit( + certificate_signing_request=certificate["certificate_signing_request"], + certificate=certificate["certificate"], + ca=certificate["ca"], + chain=certificate["chain"], + ) def _on_update_status(self, event: UpdateStatusEvent) -> None: """Triggered on update status event. diff --git a/src/charm.py b/src/charm.py index 7d42ee852..c9a994056 100755 --- a/src/charm.py +++ b/src/charm.py @@ -23,6 +23,7 @@ get_create_user_cmd, ) from charms.mongodb.v0.mongodb import ( + CHARM_USERS, MongoDBConfiguration, MongoDBConnection, NotReadyError, @@ -108,8 +109,8 @@ def _generate_passwords(self) -> None: The same keyFile and admin password on all members needed, hence it is generated once and share between members via the app data. """ - if not self.get_secret("app", "password"): - self.set_secret("app", "password", generate_password()) + if not self.get_secret("app", "operator-password"): + self.set_secret("app", "operator-password", generate_password()) if not self.get_secret("app", "keyfile"): self.set_secret("app", "keyfile", generate_keyfile()) @@ -408,7 +409,15 @@ def _on_get_primary_action(self, event: ops.charm.ActionEvent): def _on_get_password(self, event: ops.charm.ActionEvent) -> None: """Returns the password for the user as an action response.""" - event.set_results({"admin-password": self.get_secret("app", "password")}) + username = event.params.get("username", "operator") + if username not in CHARM_USERS: + event.fail( + f"The action can be run only for users used by the charm:" + f" {', '.join(CHARM_USERS)} not {username}" + ) + return + + event.set_results({f"{username}-password": self.get_secret("app", f"{username}-password")}) def _on_set_password(self, event: ops.charm.ActionEvent) -> None: """Set the password for the admin user.""" @@ -417,13 +426,21 @@ def _on_set_password(self, event: ops.charm.ActionEvent) -> None: event.fail("The action can be run only on leader unit.") return + username = event.params.get("username", "operator") + if username not in CHARM_USERS: + event.fail( + f"The action can be run only for users used by the charm:" + f" {', '.join(CHARM_USERS)} not {username}" + ) + return + new_password = generate_password() if "password" in event.params: new_password = event.params["password"] with MongoDBConnection(self.mongodb_config) as mongo: try: - mongo.set_user_password("admin", new_password) + mongo.set_user_password(username, new_password) except NotReadyError: event.fail( "Failed changing the password: Not all members healthy or finished initial sync." @@ -433,8 +450,8 @@ def _on_set_password(self, event: ops.charm.ActionEvent) -> None: event.fail(f"Failed changing the password: {e}") return - self.set_secret("app", "password", new_password) - event.set_results({"admin-password": self.get_secret("app", "password")}) + self.set_secret("app", f"{username}-password", new_password) + event.set_results({f"{username}-password": new_password}) def _open_port_tcp(self, port: int) -> None: """Open the given port. @@ -696,8 +713,8 @@ def mongodb_config(self) -> MongoDBConfiguration: return MongoDBConfiguration( replset=self.app.name, database="admin", - username="admin", - password=self.get_secret("app", "password"), + username="operator", + password=self.get_secret("app", "operator-password"), hosts=set(self._unit_ips), roles={"default"}, tls_external=external_ca is not None, diff --git a/tests/integration/ha_tests/helpers.py b/tests/integration/ha_tests/helpers.py index 3086c9161..6fff9c43e 100644 --- a/tests/integration/ha_tests/helpers.py +++ b/tests/integration/ha_tests/helpers.py @@ -54,7 +54,7 @@ def replica_set_client(replica_ips: List[str], password: str, app=APP_NAME) -> M hosts = ["{}:{}".format(replica_ip, PORT) for replica_ip in replica_ips] hosts = ",".join(hosts) - replica_set_uri = f"mongodb://admin:" f"{password}@" f"{hosts}/admin?replicaSet={app}" + replica_set_uri = f"mongodb://operator:" f"{password}@" f"{hosts}/admin?replicaSet={app}" return MongoClient(replica_set_uri) @@ -91,7 +91,7 @@ def unit_uri(ip_address: str, password, app=APP_NAME) -> str: password: password of database. app: name of application which has the cluster. """ - return f"mongodb://admin:" f"{password}@" f"{ip_address}:{PORT}/admin?replicaSet={app}" + return f"mongodb://operator:" f"{password}@" f"{ip_address}:{PORT}/admin?replicaSet={app}" async def get_password(ops_test: OpsTest, app, down_unit=None) -> str: @@ -108,7 +108,7 @@ async def get_password(ops_test: OpsTest, app, down_unit=None) -> str: action = await ops_test.model.units.get(f"{app}/{unit_id}").run_action("get-password") action = await action.wait() - return action.results["admin-password"] + return action.results["operator-password"] async def fetch_primary( @@ -250,7 +250,7 @@ async def clear_db_writes(ops_test: OpsTest) -> bool: password = await get_password(ops_test, app) hosts = [unit.public_address for unit in ops_test.model.applications[app].units] hosts = ",".join(hosts) - connection_string = f"mongodb://admin:{password}@{hosts}/admin?replicaSet={app}" + connection_string = f"mongodb://operator:{password}@{hosts}/admin?replicaSet={app}" client = MongoClient(connection_string) db = client["new-db"] @@ -275,7 +275,7 @@ async def start_continous_writes(ops_test: OpsTest, starting_number: int) -> Non password = await get_password(ops_test, app) hosts = [unit.public_address for unit in ops_test.model.applications[app].units] hosts = ",".join(hosts) - connection_string = f"mongodb://admin:{password}@{hosts}/admin?replicaSet={app}" + connection_string = f"mongodb://operator:{password}@{hosts}/admin?replicaSet={app}" # run continuous writes in the background. subprocess.Popen( @@ -303,7 +303,7 @@ async def stop_continous_writes(ops_test: OpsTest, down_unit=None) -> int: password = await get_password(ops_test, app, down_unit) hosts = [unit.public_address for unit in ops_test.model.applications[app].units] hosts = ",".join(hosts) - connection_string = f"mongodb://admin:{password}@{hosts}/admin?replicaSet={app}" + connection_string = f"mongodb://operator:{password}@{hosts}/admin?replicaSet={app}" client = MongoClient(connection_string) db = client["new-db"] @@ -321,7 +321,7 @@ async def count_writes(ops_test: OpsTest, down_unit=None) -> int: password = await get_password(ops_test, app, down_unit) hosts = [unit.public_address for unit in ops_test.model.applications[app].units] hosts = ",".join(hosts) - connection_string = f"mongodb://admin:{password}@{hosts}/admin?replicaSet={app}" + connection_string = f"mongodb://operator:{password}@{hosts}/admin?replicaSet={app}" client = MongoClient(connection_string) db = client["new-db"] @@ -338,7 +338,7 @@ async def secondary_up_to_date(ops_test: OpsTest, unit_ip, expected_writes) -> b """ app = await app_name(ops_test) password = await get_password(ops_test, app) - connection_string = f"mongodb://admin:{password}@{unit_ip}:{PORT}/admin?" + connection_string = f"mongodb://operator:{password}@{unit_ip}:{PORT}/admin?" client = MongoClient(connection_string, directConnection=True) try: diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 1343c3bbe..67b094da0 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -23,7 +23,7 @@ def unit_uri(ip_address: str, password, app=APP_NAME) -> str: password: password of database. app: name of application which has the cluster. """ - return f"mongodb://admin:" f"{password}@" f"{ip_address}:{PORT}/admin?replicaSet={app}" + return f"mongodb://operator:" f"{password}@" f"{ip_address}:{PORT}/admin?replicaSet={app}" async def get_password(ops_test: OpsTest, app=APP_NAME) -> str: @@ -38,7 +38,7 @@ async def get_password(ops_test: OpsTest, app=APP_NAME) -> str: action = await ops_test.model.units.get(f"{app}/{unit_id}").run_action("get-password") action = await action.wait() - return action.results["admin-password"] + return action.results["operator-password"] @retry( diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 49ec1927d..4c3fc8e18 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -134,7 +134,7 @@ async def test_set_password_action(ops_test: OpsTest) -> None: unit = await find_unit(ops_test, leader=True) action = await unit.run_action("set-password") action = await action.wait() - new_password = action.results["admin-password"] + new_password = action.results["operator-password"] assert new_password != old_password new_password_reported = await get_password(ops_test) assert new_password == new_password_reported @@ -152,7 +152,7 @@ async def test_set_password_action(ops_test: OpsTest) -> None: old_password = await get_password(ops_test) action = await unit.run_action("set-password", **{"password": "safe_pass"}) action = await action.wait() - new_password = action.results["admin-password"] + new_password = action.results["operator-password"] assert new_password != old_password new_password_reported = await get_password(ops_test) assert "safe_pass" == new_password_reported diff --git a/tests/integration/tls_tests/helpers.py b/tests/integration/tls_tests/helpers.py index 6d67c4b54..aaf88335a 100644 --- a/tests/integration/tls_tests/helpers.py +++ b/tests/integration/tls_tests/helpers.py @@ -23,7 +23,7 @@ async def mongo_tls_command(ops_test: OpsTest) -> str: replica_set_hosts = [unit.public_address for unit in ops_test.model.applications[app].units] password = await get_password(ops_test, app) hosts = ",".join(replica_set_hosts) - replica_set_uri = f"mongodb://admin:" f"{password}@" f"{hosts}/admin?replicaSet={app}" + replica_set_uri = f"mongodb://operator:" f"{password}@" f"{hosts}/admin?replicaSet={app}" return ( f"mongo '{replica_set_uri}' --eval 'rs.status()'" diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 0a4eae4d2..f4df4f797 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -631,15 +631,15 @@ def test_start_init_user_after_second_call(self, run, config): def test_set_password(self, connection): """Tests that a new admin password is generated and is returned to the user.""" self.harness.set_leader(True) - original_password = self.harness.charm.app_peer_data["password"] + original_password = self.harness.charm.app_peer_data["operator-password"] action_event = mock.Mock() action_event.params = {} self.harness.charm._on_set_password(action_event) - new_password = self.harness.charm.app_peer_data["password"] + new_password = self.harness.charm.app_peer_data["operator-password"] # verify app data is updated and results are reported to user self.assertNotEqual(original_password, new_password) - action_event.set_results.assert_called_with({"admin-password": new_password}) + action_event.set_results.assert_called_with({"operator-password": new_password}) @patch_network_get(private_address="1.1.1.1") @patch("charm.MongoDBConnection") @@ -649,18 +649,18 @@ def test_set_password_provided(self, connection): action_event = mock.Mock() action_event.params = {"password": "canonical123"} self.harness.charm._on_set_password(action_event) - new_password = self.harness.charm.app_peer_data["password"] + new_password = self.harness.charm.app_peer_data["operator-password"] # verify app data is updated and results are reported to user self.assertEqual("canonical123", new_password) - action_event.set_results.assert_called_with({"admin-password": "canonical123"}) + action_event.set_results.assert_called_with({"operator-password": "canonical123"}) @patch_network_get(private_address="1.1.1.1") @patch("charm.MongoDBConnection") def test_set_password_failure(self, connection): """Tests failure to reset password does not update app data and failure is reported.""" self.harness.set_leader(True) - original_password = self.harness.charm.app_peer_data["password"] + original_password = self.harness.charm.app_peer_data["operator-password"] action_event = mock.Mock() action_event.params = {} @@ -669,7 +669,7 @@ def test_set_password_failure(self, connection): exception ) self.harness.charm._on_set_password(action_event) - current_password = self.harness.charm.app_peer_data["password"] + current_password = self.harness.charm.app_peer_data["operator-password"] # verify passwords are not updated. self.assertEqual(current_password, original_password)