- Introduced tests for frontend URL generation in `test_client_links.py` to ensure all links in emails are reachable and correctly formatted. - Added authorization tests for deleting attachments in `test_attachment_delete_authz.py` for leads and opportunities, ensuring proper access control based on user roles and organization boundaries. - Updated `LeadAttachmentView` and `OpportunityAttachmentView` to use `get_scoped_or_404` for safer object retrieval, preventing unauthorized access to attachments across organizations. - Improved documentation regarding the organization API key and personal access tokens, clarifying their scopes and security implications. - Enhanced frontend token management to support new scope enforcement, allowing users to specify access levels when creating tokens.
146 lines
5.9 KiB
Python
146 lines
5.9 KiB
Python
"""What the organization API key is allowed to do.
|
|
|
|
The key is one per tenant, never expires, and resolves to an arbitrary active
|
|
ADMIN profile. That made it a permanent unrevokable admin session for the org,
|
|
which is what these tests now pin shut: it reads, it does not write, and it
|
|
cannot reach another credential.
|
|
|
|
Both the middleware and `common.external_auth.APIKeyAuthentication` resolve this
|
|
key independently, so the deny cases are asserted through a real request (which
|
|
exercises the middleware) and the auth class is exercised directly, because a
|
|
guard on one copy and not the other is no guard.
|
|
"""
|
|
|
|
import pytest
|
|
from django.test import override_settings
|
|
from rest_framework.exceptions import AuthenticationFailed
|
|
from rest_framework.test import APIClient, APIRequestFactory
|
|
|
|
from common.external_auth import APIKeyAuthentication
|
|
from common.models import PersonalAccessToken
|
|
|
|
|
|
def _key_client(org):
|
|
client = APIClient()
|
|
client.credentials(HTTP_TOKEN=org.api_key)
|
|
return client
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestOrgApiKeyIsReadOnly:
|
|
def test_key_can_read(self, org_a, admin_profile):
|
|
assert _key_client(org_a).get("/api/leads/").status_code == 200
|
|
|
|
def test_key_cannot_create(self, org_a, admin_profile):
|
|
from leads.models import Lead
|
|
|
|
before = Lead.objects.count()
|
|
resp = _key_client(org_a).post(
|
|
"/api/leads/", {"title": "Via key", "first_name": "A", "last_name": "B"}
|
|
)
|
|
assert resp.status_code == 403
|
|
assert Lead.objects.count() == before
|
|
|
|
def test_key_cannot_delete(self, org_a, admin_profile):
|
|
from leads.models import Lead
|
|
|
|
lead = Lead.objects.create(
|
|
title="Survivor", first_name="Sur", last_name="Vivor", org=org_a
|
|
)
|
|
assert _key_client(org_a).delete(f"/api/leads/{lead.id}/").status_code == 403
|
|
assert Lead.objects.filter(id=lead.id).exists()
|
|
|
|
def test_key_cannot_patch(self, org_a, admin_profile):
|
|
from leads.models import Lead
|
|
|
|
lead = Lead.objects.create(
|
|
title="Unchanged", first_name="Un", last_name="Changed", org=org_a
|
|
)
|
|
resp = _key_client(org_a).patch(f"/api/leads/{lead.id}/", {"title": "Changed"})
|
|
assert resp.status_code == 403
|
|
lead.refresh_from_db()
|
|
assert lead.title == "Unchanged"
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestOrgApiKeyCannotReachCredentials:
|
|
def test_key_cannot_read_itself(self, org_a, admin_profile):
|
|
"""Reading itself is harmless; rotating itself is a denial of service.
|
|
|
|
Both go through one path, and there is no case for a key that manages
|
|
keys, so both are refused.
|
|
"""
|
|
assert _key_client(org_a).get("/api/org/api-key/").status_code == 403
|
|
|
|
def test_key_cannot_rotate_itself(self, org_a, admin_profile):
|
|
before = org_a.api_key
|
|
assert _key_client(org_a).post("/api/org/api-key/").status_code == 403
|
|
org_a.refresh_from_db()
|
|
assert org_a.api_key == before
|
|
|
|
def test_key_cannot_mint_a_personal_token(self, org_a, admin_profile):
|
|
"""The escalation: a leaked org key mints a PAT owned by a real admin.
|
|
|
|
The key is read-only, so this is already refused as a write. It is
|
|
asserted separately because the deny-list must hold even if the
|
|
read-only limit is ever relaxed for some endpoint.
|
|
"""
|
|
before = PersonalAccessToken.objects.count()
|
|
resp = _key_client(org_a).post("/api/profile/tokens/", {"name": "from-key"})
|
|
assert resp.status_code == 403
|
|
assert PersonalAccessToken.objects.count() == before
|
|
|
|
def test_key_cannot_list_tokens(self, org_a, admin_profile):
|
|
assert _key_client(org_a).get("/api/profile/tokens/").status_code == 403
|
|
|
|
def test_key_cannot_read_org_token_oversight(self, org_a, admin_profile):
|
|
assert _key_client(org_a).get("/api/org/tokens/").status_code == 403
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestOrgApiKeyKillSwitch:
|
|
@override_settings(ORG_API_KEY_AUTH_ENABLED=False)
|
|
def test_disabled_refuses_even_reads(self, org_a, admin_profile):
|
|
assert _key_client(org_a).get("/api/leads/").status_code == 403
|
|
|
|
@override_settings(ORG_API_KEY_AUTH_ENABLED=True)
|
|
def test_enabled_is_the_default_behaviour(self, org_a, admin_profile):
|
|
assert _key_client(org_a).get("/api/leads/").status_code == 200
|
|
|
|
def test_a_jwt_session_is_unaffected_by_the_switch(self, admin_client):
|
|
"""The switch keys on the credential, not on the endpoint or the person."""
|
|
with override_settings(ORG_API_KEY_AUTH_ENABLED=False):
|
|
assert admin_client.get("/api/leads/").status_code == 200
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestAuthClassCarriesTheSameLimits:
|
|
"""The DRF auth class is a second, independent copy of the key resolution."""
|
|
|
|
def setup_method(self):
|
|
self.factory = APIRequestFactory()
|
|
self.auth = APIKeyAuthentication()
|
|
|
|
def test_read_authenticates(self, org_a, admin_profile, admin_user):
|
|
request = self.factory.get("/api/leads/", HTTP_TOKEN=org_a.api_key)
|
|
user, _ = self.auth.authenticate(request)
|
|
assert user == admin_user
|
|
|
|
def test_write_is_refused(self, org_a, admin_profile):
|
|
request = self.factory.post("/api/leads/", HTTP_TOKEN=org_a.api_key)
|
|
with pytest.raises(AuthenticationFailed):
|
|
self.auth.authenticate(request)
|
|
|
|
def test_credential_path_is_refused(self, org_a, admin_profile):
|
|
request = self.factory.get("/api/org/api-key/", HTTP_TOKEN=org_a.api_key)
|
|
with pytest.raises(AuthenticationFailed):
|
|
self.auth.authenticate(request)
|
|
|
|
@override_settings(ORG_API_KEY_AUTH_ENABLED=False)
|
|
def test_kill_switch_is_refused(self, org_a, admin_profile):
|
|
request = self.factory.get("/api/leads/", HTTP_TOKEN=org_a.api_key)
|
|
with pytest.raises(AuthenticationFailed):
|
|
self.auth.authenticate(request)
|
|
|
|
def test_no_token_header_defers(self):
|
|
assert self.auth.authenticate(self.factory.get("/api/leads/")) is None
|