fix(api): persist per-provider secret normalization and drop M365 scope creep

This commit is contained in:
Lydia Vilchez committed 2026-09-01 15:15:26 +02:00
1 parent ef62731c0a
commit 721a80921b
4 files changed
+174 -31

No files matched your search

+8 -12
View File
@@ -21369,9 +21369,8 @@ components:
where the application is registered.
certificate_content:
type: string
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
and its matching private key for certificate-based authentication.
maxLength: 68268
description: The certificate content in base64 format for certificate-based
authentication.
required:
- client_id
- tenant_id
@@ -23520,9 +23519,8 @@ components:
the application is registered.
certificate_content:
type: string
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
and its matching private key for certificate-based authentication.
maxLength: 68268
description: The certificate content in base64 format for certificate-based
authentication.
required:
- client_id
- tenant_id
@@ -23987,9 +23985,8 @@ components:
where the application is registered.
certificate_content:
type: string
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
and its matching private key for certificate-based authentication.
maxLength: 68268
description: The certificate content in base64 format for certificate-based
authentication.
required:
- client_id
- tenant_id
@@ -24476,9 +24473,8 @@ components:
the application is registered.
certificate_content:
type: string
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
and its matching private key for certificate-based authentication.
maxLength: 68268
description: The certificate content in base64 format for certificate-based
authentication.
required:
- client_id
- tenant_id
+147
View File
@@ -3444,6 +3444,153 @@ current-context: test-context
provider_secret.refresh_from_db()
assert "region" not in provider_secret.secret
@staticmethod
def _azure_certificate_bundle_base64():
import base64
from datetime import UTC, datetime, timedelta
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Prowler")])
certificate = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(subject)
.public_key(private_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.now(UTC))
.not_valid_after(datetime.now(UTC) + timedelta(days=1))
.sign(private_key, hashes.SHA256())
)
bundle = certificate.public_bytes(
serialization.Encoding.PEM
) + private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
return base64.b64encode(bundle).decode("ascii")
def test_provider_secrets_create_azure_persists_whitespace_stripped_certificate(
self,
authenticated_client,
azure_provider,
):
# `AzureProviderSecret.validate_certificate_content` strips whitespace
# inside the base64 payload. The outer write path must reassign the
# validator's normalized output so the value that reaches Fernet
# storage matches what the SDK will later decode.
clean_b64 = self._azure_certificate_bundle_base64()
wrapped_b64 = (
" "
+ "\r\n".join(clean_b64[i : i + 64] for i in range(0, len(clean_b64), 64))
+ " "
)
data = {
"data": {
"type": "provider-secrets",
"attributes": {
"name": "Azure Cert Secret",
"secret_type": ProviderSecret.TypeChoices.STATIC,
"secret": {
"client_id": "87654321-4321-4321-4321-210987654321",
"tenant_id": "12345678-1234-1234-1234-123456789012",
"certificate_content": wrapped_b64,
},
},
"relationships": {
"provider": {
"data": {"type": "providers", "id": str(azure_provider.id)}
}
},
}
}
response = authenticated_client.post(
reverse("providersecret-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED, response.content
provider_secret = ProviderSecret.objects.get(id=response.json()["data"]["id"])
# Persisted value is the whitespace-stripped payload — proves the
# per-provider validator's return value is what Fernet encrypted,
# not the raw upload.
assert provider_secret.secret["certificate_content"] == clean_b64
def test_provider_secrets_update_azure_persists_whitespace_stripped_certificate(
self,
authenticated_client,
azure_provider,
):
create_response = authenticated_client.post(
reverse("providersecret-list"),
data=json.dumps(
{
"data": {
"type": "provider-secrets",
"attributes": {
"name": "Azure Cert Secret",
"secret_type": ProviderSecret.TypeChoices.STATIC,
"secret": {
"client_id": "87654321-4321-4321-4321-210987654321",
"tenant_id": "12345678-1234-1234-1234-123456789012",
"certificate_content": self._azure_certificate_bundle_base64(),
},
},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": str(azure_provider.id),
}
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert create_response.status_code == status.HTTP_201_CREATED
provider_secret = ProviderSecret.objects.get(
id=create_response.json()["data"]["id"]
)
clean_b64 = self._azure_certificate_bundle_base64()
wrapped_b64 = "\r\n".join(
clean_b64[i : i + 64] for i in range(0, len(clean_b64), 64)
)
response = authenticated_client.patch(
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
data=json.dumps(
{
"data": {
"type": "provider-secrets",
"id": str(provider_secret.id),
"attributes": {
"secret": {
"client_id": "87654321-4321-4321-4321-210987654321",
"tenant_id": "12345678-1234-1234-1234-123456789012",
"certificate_content": wrapped_b64,
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK, response.content
provider_secret.refresh_from_db()
assert provider_secret.secret["certificate_content"] == clean_b64
@pytest.mark.parametrize(
"attributes, error_code, error_pointer",
(
@@ -169,7 +169,7 @@ from rest_framework_json_api import serializers
},
"certificate_content": {
"type": "string",
"description": "Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate and its matching private key for certificate-based authentication.",
"description": "The certificate content in base64 format for certificate-based authentication.",
},
},
"required": [
+18 -18
View File
@@ -1871,20 +1871,16 @@ class M365ProviderSecret(serializers.Serializer):
tenant_id = serializers.CharField()
user = serializers.EmailField(required=False)
password = serializers.CharField(required=False)
certificate_content = serializers.CharField(
required=False, max_length=_MAX_CERTIFICATE_CONTENT_LENGTH
)
certificate_content = serializers.CharField(required=False)
def validate(self, attrs):
if attrs.get("client_secret") and attrs.get("certificate_content"):
raise serializers.ValidationError(
"You cannot provide both client_secret and certificate_content.",
code="m365-credential-mutex",
"You cannot provide both client_secret and certificate_content."
)
if not attrs.get("client_secret") and not attrs.get("certificate_content"):
raise serializers.ValidationError(
"You must provide either client_secret or certificate_content.",
code="m365-credential-required",
"You must provide either client_secret or certificate_content."
)
return super().validate(attrs)
@@ -1893,13 +1889,13 @@ class M365ProviderSecret(serializers.Serializer):
if certificate_content:
try:
base64.b64decode(certificate_content, validate=True)
# `base64.b64decode(validate=True)` raises `binascii.Error`; the
# legacy alias `ValueError` is preserved for older builds.
except (binascii.Error, ValueError) as e:
# DRF field validators are already keyed to `certificate_content`,
# so raising a dict here would double-nest the JSON:API pointer.
raise serializers.ValidationError(
f"The provided certificate content is not valid base64 encoded data: {str(e)}",
except Exception as e:
raise ValidationError(
{
"certificate_content": [
f"The provided certificate content is not valid base64 encoded data: {str(e)}"
]
},
code="m365-certificate-content",
)
return certificate_content
@@ -2167,8 +2163,11 @@ class ProviderSecretCreateSerializer(
validated_secret = self.validate_secret_based_on_provider(
provider.provider, secret_type, secret
)
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
validated_attrs["secret"] = validated_secret
# Persist the per-provider validator's normalized output (e.g.
# `AzureProviderSecret` strips whitespace inside `certificate_content`).
# Previously this reassignment only happened for OCI, so every
# per-provider validator's normalization was discarded on write.
validated_attrs["secret"] = validated_secret
return validated_attrs
@@ -2203,8 +2202,9 @@ class ProviderSecretUpdateSerializer(BaseWriteProviderSecretSerializer):
validated_secret = self.validate_secret_based_on_provider(
provider.provider, secret_type, secret
)
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
validated_attrs["secret"] = validated_secret
# Persist the per-provider validator's normalized output — same
# rationale as `ProviderSecretCreateSerializer.validate`.
validated_attrs["secret"] = validated_secret
return validated_attrs