diff --git a/README.md b/README.md index 79aaf7b..88e79d5 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,32 @@ environments: regex: true ``` +#### Accounts + +An `environment` can declare one or more PowerDNS `accounts` to grant +dynamic access to every zone tagged with one of those account values in +PowerDNS. + +This is additive: it works alongside the static `zones` list, or on its +own. Zones matched via `accounts` get read/write permissions within the +zone — no admin (so create/delete zone, update zone metadata are not +allowed), and no CryptoKey access. + +```yaml +... +environments: + - name: "Test1" + accounts: + - "tenant-a" + - "tenant-b" +``` + +> Account-based access is resolved against PowerDNS on every request, so +> re-tagging a zone in PowerDNS is reflected immediately. Each +> account-based zone access costs one extra upstream call to fetch the +> zone's `account` field. The `/info/allowed` and `/info/zone-allowed` +> endpoints also reflect account-based access. + #### Global read Global `read` permissions can be defined under an `environment`. diff --git a/config-example.yml b/config-example.yml index 6b80e2a..108a841 100644 --- a/config-example.yml +++ b/config-example.yml @@ -8,3 +8,5 @@ environments: zones: - name: zone1.example.com - name: zone2.example.com + accounts: + - example-account diff --git a/powerdns_api_proxy/config.py b/powerdns_api_proxy/config.py index 35e8aa2..a2372de 100644 --- a/powerdns_api_proxy/config.py +++ b/powerdns_api_proxy/config.py @@ -21,6 +21,7 @@ ProxyConfigZone, RRSETRequest, ) +from powerdns_api_proxy.pdns import PDNSConnector, handle_pdns_response from powerdns_api_proxy.utils import check_record_in_regex, check_zones_equal @@ -112,6 +113,10 @@ def get_only_pdns_zones_allowed( for zone in pdns_zones: if check_pdns_zone_allowed(environment, zone["name"]): filtered.append(zone) + continue + zone_account = zone.get("account") or "" + if zone_account and zone_account in environment.accounts: + filtered.append(zone) return filtered @@ -129,6 +134,90 @@ def check_pdns_zone_allowed(environment: ProxyConfigEnvironment, zone: str) -> b return False +async def get_zone_account_from_pdns( + pdns: PDNSConnector, server_id: str, zone_id: str +) -> str | None: + """ + Fetch a zone's `account` field from PowerDNS. + + The zone id is normalized to canonical form (trailing dot) because + PowerDNS returns an empty stub for non-canonical names instead of + looking up the real zone. + + Returns the account string, or None if the zone is missing or the + upstream response is not parseable as a dict. + """ + canonical_zone = zone_id if zone_id.endswith(".") else f"{zone_id}." + resp = await pdns.get(f"/api/v1/servers/{server_id}/zones/{canonical_zone}") + pdns_response = await handle_pdns_response(resp) + if not pdns_response.is_success: + logger.info( + f"Account lookup for zone '{zone_id}' on server '{server_id}' " + f"failed: upstream status {pdns_response.status_code}" + ) + return None + if not isinstance(pdns_response.data, dict): + logger.info( + f"Account lookup for zone '{zone_id}' got non-dict response: " + f"{type(pdns_response.data).__name__}" + ) + return None + account = pdns_response.data.get("account") + logger.info( + f"PowerDNS reports zone '{zone_id}' account = '{account}'" + ) + return account if account else None + + +async def resolve_zone_for_environment( + environment: ProxyConfigEnvironment, + zone: str, + pdns: PDNSConnector, + server_id: str, +) -> ProxyConfigZone: + """ + Resolve a zone for an environment, allowing two access paths: + + 1. Static config: the zone is matched by the environment's `zones` list. + 2. Account-based: the environment declares one or more `accounts`, + and the zone's `account` field in PowerDNS matches one of them. + + The static path is consulted first. The account path issues an extra + upstream call to read the zone's metadata; it is intentionally + uncached so access reflects PowerDNS state at request time. + + Account-matched zones get RW permissions within the zone (no admin, + no cryptokeys) via a synthetic ProxyConfigZone. + + Raises ZoneNotAllowedException if neither path grants access. + """ + try: + return environment.get_zone_if_allowed(zone) + except ZoneNotAllowedException: + pass + + if not environment.accounts: + raise ZoneNotAllowedException() + + logger.info( + f"Static zones do not allow '{zone}' for environment " + f"'{environment.name}'; checking accounts {environment.accounts}" + ) + account = await get_zone_account_from_pdns(pdns, server_id, zone) + if account and account in environment.accounts: + logger.info( + f"Zone '{zone}' granted to environment '{environment.name}' " + f"via account '{account}'" + ) + return ProxyConfigZone(name=zone) + + logger.info( + f"Zone '{zone}' not granted via accounts: PowerDNS account " + f"'{account}' not in environment.accounts={environment.accounts}" + ) + raise ZoneNotAllowedException() + + def check_pdns_zone_admin(environment: ProxyConfigEnvironment, zone: str) -> bool: try: env_zone = environment.get_zone_if_allowed(zone) diff --git a/powerdns_api_proxy/models.py b/powerdns_api_proxy/models.py index fdb697b..a58e678 100644 --- a/powerdns_api_proxy/models.py +++ b/powerdns_api_proxy/models.py @@ -56,6 +56,7 @@ class ProxyConfigEnvironment(BaseModel): name: str token_sha512: str zones: list[ProxyConfigZone] = [] + accounts: list[str] = [] global_read_only: bool = False global_search: bool = False global_cryptokeys: bool = False @@ -92,6 +93,7 @@ def __hash__(self): + str(self.global_search) + str(self.global_tsigkeys) + str(self.zones) + + str(self.accounts) ) @lru_cache(maxsize=10000) diff --git a/powerdns_api_proxy/proxy.py b/powerdns_api_proxy/proxy.py index 7566bb8..03df5e2 100644 --- a/powerdns_api_proxy/proxy.py +++ b/powerdns_api_proxy/proxy.py @@ -20,6 +20,7 @@ get_environment_for_token, get_only_pdns_zones_allowed, load_config, + resolve_zone_for_environment, ) from powerdns_api_proxy.exceptions import ( RessourceNotAllowedException, @@ -31,6 +32,7 @@ from powerdns_api_proxy.logging import logger from powerdns_api_proxy.metrics import http_requests_total_environment from powerdns_api_proxy.models import ( + ProxyConfigZone, ResponseAllowed, ResponseZoneAllowed, ) @@ -146,7 +148,22 @@ async def get_allowed_ressources(X_API_Key: str = Header()): """Retrieve allowed requests for the given token.""" logger.info("Checking allowed ressources for given api key") environment = get_environment_for_token(config, X_API_Key) - return ResponseAllowed(zones=environment.zones) + zones = list(environment.zones) + + if environment.accounts: + resp = await pdns.get("/api/v1/servers/localhost/zones") + pdns_response = await handle_pdns_response(resp) + pdns_response.raise_for_error() + if isinstance(pdns_response.data, list): + for zone_data in pdns_response.data: + zone_account = zone_data.get("account") or "" + if not zone_account or zone_account not in environment.accounts: + continue + if check_pdns_zone_allowed(environment, zone_data["name"]): + continue + zones.append(ProxyConfigZone(name=zone_data["name"])) + + return ResponseAllowed(zones=zones) @router_proxy.get( @@ -160,10 +177,12 @@ async def get_zone_allowed(zone: str, X_API_Key: str = Header()): """ logger.debug("Checking if zone is allowed for given api key") environment = get_environment_for_token(config, X_API_Key) - if not check_pdns_zone_allowed(environment, zone): + try: + zone_config = await resolve_zone_for_environment( + environment, zone, pdns, "localhost" + ) + except ZoneNotAllowedException: return ResponseZoneAllowed(zone=zone, allowed=False) - - zone_config = environment.get_zone_if_allowed(zone) return ResponseZoneAllowed(zone=zone, allowed=True, config=zone_config) @@ -301,9 +320,11 @@ async def get_zone_metadata( """ environment = get_environment_for_token(config, X_API_Key) - if not check_pdns_zone_allowed(environment, zone_id): + try: + await resolve_zone_for_environment(environment, zone_id, pdns, server_id) + except ZoneNotAllowedException: logger.info(f"Zone {zone_id} not allowed for environment {environment.name}") - raise ZoneNotAllowedException() + raise resp = await pdns.get( f"/api/v1/servers/{server_id}/zones/{zone_id}", params=dict(request.query_params), @@ -357,10 +378,11 @@ async def update_zone_rrset( """ logger.debug(f"Update RRSet request for {zone_id}") environment = get_environment_for_token(config, X_API_Key) - if not check_pdns_zone_allowed(environment, zone_id): + try: + zone = await resolve_zone_for_environment(environment, zone_id, pdns, server_id) + except ZoneNotAllowedException: logger.info(f"Zone {zone_id} not allowed for environment {environment.name}") - raise ZoneNotAllowedException() - zone = environment.get_zone_if_allowed(zone_id) + raise ensure_rrsets_request_allowed(zone, await request.json()) resp = await pdns.patch( f"/api/v1/servers/{server_id}/zones/{zone_id}", @@ -404,9 +426,11 @@ async def zone_notification(server_id: str, zone_id: str, X_API_Key: str = Heade """ environment = get_environment_for_token(config, X_API_Key) - if not check_pdns_zone_allowed(environment, zone_id): + try: + await resolve_zone_for_environment(environment, zone_id, pdns, server_id) + except ZoneNotAllowedException: logger.info(f"Zone {zone_id} not allowed for environment {environment.name}") - raise ZoneNotAllowedException() + raise resp = await pdns.put(f"/api/v1/servers/{server_id}/zones/{zone_id}/notify") pdns_response = await handle_pdns_response(resp) status_code = pdns_response.raise_for_error() @@ -428,9 +452,11 @@ async def zone_rectification( """ environment = get_environment_for_token(config, X_API_Key) - if not check_pdns_zone_allowed(environment, zone_id): + try: + await resolve_zone_for_environment(environment, zone_id, pdns, server_id) + except ZoneNotAllowedException: logger.info(f"Zone {zone_id} not allowed for environment {environment.name}") - raise ZoneNotAllowedException() + raise resp = await pdns.put(f"/api/v1/servers/{server_id}/zones/{zone_id}/rectify") pdns_response = await handle_pdns_response(resp) status_code = pdns_response.raise_for_error() diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 91fe89c..c252d93 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -1,5 +1,7 @@ +import asyncio import os from copy import deepcopy +from unittest.mock import AsyncMock import pytest from fastapi import HTTPException @@ -16,6 +18,8 @@ ensure_rrsets_request_allowed, get_environment_for_token, get_only_pdns_zones_allowed, + get_zone_account_from_pdns, + resolve_zone_for_environment, token_defined, ) from powerdns_api_proxy.models import ( @@ -681,3 +685,259 @@ def test_global_read_only_with_explicit_zones_keeps_zone_permissions(): assert len(env._zones_lookup) == 2 assert "example.com" in env._zones_lookup assert "readonly.com" in env._zones_lookup + + +# Account-based zone access tests + + +def test_environment_accepts_accounts_field(): + env = ProxyConfigEnvironment( + name="account env", + token_sha512=dummy_proxy_environment_token_sha512, + accounts=["tenant-a", "tenant-b"], + ) + assert env.accounts == ["tenant-a", "tenant-b"] + assert env.zones == [] + + +def test_environment_accounts_default_empty(): + env = ProxyConfigEnvironment( + name="env", + token_sha512=dummy_proxy_environment_token_sha512, + ) + assert env.accounts == [] + + +def test_get_only_pdns_zones_allowed_includes_account_match(): + pdns_zones = [ + {"name": "static.example.com.", "account": ""}, + {"name": "account-zone.example.com.", "account": "tenant-a"}, + {"name": "other.example.com.", "account": "tenant-b"}, + {"name": "unrelated.example.com.", "account": "tenant-c"}, + ] + env = ProxyConfigEnvironment( + name="env", + token_sha512=dummy_proxy_environment_token_sha512, + zones=[ProxyConfigZone(name="static.example.com.")], + accounts=["tenant-a", "tenant-b"], + ) + allowed = get_only_pdns_zones_allowed(env, pdns_zones) + names = {z["name"] for z in allowed} + assert names == { + "static.example.com.", + "account-zone.example.com.", + "other.example.com.", + } + + +def test_get_only_pdns_zones_allowed_empty_account_not_matched(): + """An empty `accounts` entry on the env must not match zones with no account.""" + pdns_zones = [ + {"name": "noaccount.example.com.", "account": ""}, + {"name": "noaccount2.example.com."}, # missing key entirely + ] + env = ProxyConfigEnvironment( + name="env", + token_sha512=dummy_proxy_environment_token_sha512, + accounts=[""], + ) + allowed = get_only_pdns_zones_allowed(env, pdns_zones) + assert allowed == [] + + +def test_resolve_zone_for_environment_static_hit(): + env = ProxyConfigEnvironment( + name="env", + token_sha512=dummy_proxy_environment_token_sha512, + zones=[ProxyConfigZone(name="static.example.com.")], + accounts=["tenant-a"], + ) + pdns = AsyncMock() + zone = asyncio.run( + resolve_zone_for_environment(env, "static.example.com.", pdns, "localhost") + ) + assert zone.name == "static.example.com." + pdns.get.assert_not_called() + + +def test_resolve_zone_for_environment_account_hit(monkeypatch): + env = ProxyConfigEnvironment( + name="env", + token_sha512=dummy_proxy_environment_token_sha512, + accounts=["tenant-a"], + ) + + async def fake_account(pdns, server_id, zone_id): + assert zone_id == "account-zone.example.com." + assert server_id == "localhost" + return "tenant-a" + + monkeypatch.setattr( + "powerdns_api_proxy.config.get_zone_account_from_pdns", fake_account + ) + + pdns = AsyncMock() + zone = asyncio.run( + resolve_zone_for_environment( + env, "account-zone.example.com.", pdns, "localhost" + ) + ) + assert zone.name == "account-zone.example.com." + # synthetic zone is RW, no admin, no cryptokeys + assert zone.all_records is True + assert zone.admin is False + assert zone.cryptokeys is False + assert zone.read_only is False + + +def test_resolve_zone_for_environment_account_miss(monkeypatch): + env = ProxyConfigEnvironment( + name="env", + token_sha512=dummy_proxy_environment_token_sha512, + accounts=["tenant-a"], + ) + + async def fake_account(pdns, server_id, zone_id): + return "tenant-b" + + monkeypatch.setattr( + "powerdns_api_proxy.config.get_zone_account_from_pdns", fake_account + ) + + pdns = AsyncMock() + with pytest.raises(HTTPException) as err: + asyncio.run( + resolve_zone_for_environment(env, "other.example.com.", pdns, "localhost") + ) + assert err.value.status_code == ZoneNotAllowedException().status_code + + +def test_resolve_zone_for_environment_no_accounts_configured(): + """No static match and no accounts configured -> not allowed, no upstream call.""" + env = ProxyConfigEnvironment( + name="env", + token_sha512=dummy_proxy_environment_token_sha512, + zones=[ProxyConfigZone(name="static.example.com.")], + ) + pdns = AsyncMock() + with pytest.raises(HTTPException): + asyncio.run( + resolve_zone_for_environment(env, "other.example.com.", pdns, "localhost") + ) + pdns.get.assert_not_called() + + +def test_resolve_zone_for_environment_pdns_returns_no_account(monkeypatch): + """Zone exists in PowerDNS but has no account set -> not allowed.""" + env = ProxyConfigEnvironment( + name="env", + token_sha512=dummy_proxy_environment_token_sha512, + accounts=["tenant-a"], + ) + + async def fake_account(pdns, server_id, zone_id): + return None + + monkeypatch.setattr( + "powerdns_api_proxy.config.get_zone_account_from_pdns", fake_account + ) + + pdns = AsyncMock() + with pytest.raises(HTTPException): + asyncio.run( + resolve_zone_for_environment(env, "other.example.com.", pdns, "localhost") + ) + + +def test_get_zone_account_from_pdns_success(monkeypatch): + pdns = AsyncMock() + pdns.get.return_value = object() # placeholder; handle_pdns_response is patched + + class FakePDNSResponse: + is_success = True + data = {"account": "tenant-a", "name": "z.example.com."} + + async def fake_handle(resp): + return FakePDNSResponse() + + monkeypatch.setattr("powerdns_api_proxy.config.handle_pdns_response", fake_handle) + + account = asyncio.run( + get_zone_account_from_pdns(pdns, "localhost", "z.example.com.") + ) + assert account == "tenant-a" + pdns.get.assert_awaited_once() + + +def test_get_zone_account_from_pdns_empty_returns_none(monkeypatch): + pdns = AsyncMock() + pdns.get.return_value = object() + + class FakePDNSResponse: + is_success = True + data = {"account": "", "name": "z.example.com."} + + async def fake_handle(resp): + return FakePDNSResponse() + + monkeypatch.setattr("powerdns_api_proxy.config.handle_pdns_response", fake_handle) + + account = asyncio.run( + get_zone_account_from_pdns(pdns, "localhost", "z.example.com.") + ) + assert account is None + + +def test_get_zone_account_from_pdns_normalizes_trailing_dot(monkeypatch): + """PowerDNS expects canonical names with trailing dot; normalize before query.""" + seen_paths = [] + + async def fake_get(path, params=None): + seen_paths.append(path) + return object() + + pdns = AsyncMock() + pdns.get = AsyncMock(side_effect=fake_get) + + class FakePDNSResponse: + is_success = True + status_code = 200 + data = {"account": "tenant-a", "name": "z.example.com."} + + async def fake_handle(resp): + return FakePDNSResponse() + + monkeypatch.setattr("powerdns_api_proxy.config.handle_pdns_response", fake_handle) + + account = asyncio.run( + get_zone_account_from_pdns(pdns, "localhost", "z.example.com") + ) + assert account == "tenant-a" + assert seen_paths == ["/api/v1/servers/localhost/zones/z.example.com."] + + seen_paths.clear() + account = asyncio.run( + get_zone_account_from_pdns(pdns, "localhost", "z.example.com.") + ) + assert account == "tenant-a" + assert seen_paths == ["/api/v1/servers/localhost/zones/z.example.com."] + + +def test_get_zone_account_from_pdns_error_returns_none(monkeypatch): + pdns = AsyncMock() + pdns.get.return_value = object() + + class FakePDNSResponse: + is_success = False + status_code = 404 + data = "Not Found" + + async def fake_handle(resp): + return FakePDNSResponse() + + monkeypatch.setattr("powerdns_api_proxy.config.handle_pdns_response", fake_handle) + + account = asyncio.run( + get_zone_account_from_pdns(pdns, "localhost", "missing.example.com.") + ) + assert account is None diff --git a/tests/unit/proxy_test.py b/tests/unit/proxy_test.py index 3bd697a..48c1126 100644 --- a/tests/unit/proxy_test.py +++ b/tests/unit/proxy_test.py @@ -1,3 +1,4 @@ +import hashlib import os from typing import Generator from unittest.mock import AsyncMock, patch @@ -192,3 +193,146 @@ def test_api_delete_wrong_token(path, fixture_patch_dummy_config, fixture_patch_ @pytest.mark.parametrize("path", delete_routes) def test_api_delete_missing_token(path, fixture_patch_dummy_config, fixture_patch_pdns): _token_missing_request(client, "DELETE", path) + + +# Info endpoints with account-based access + + +dummy_account_env_token = "tenantenvtoken-XXXXXXXXXXXXXXXXXXXXXX" +dummy_account_env_token_sha512 = hashlib.sha512( + dummy_account_env_token.encode() +).hexdigest() + +dummy_account_env = ProxyConfigEnvironment( + name="Account Env", + token_sha512=dummy_account_env_token_sha512, + zones=[ProxyConfigZone(name="static.example.com.")], + accounts=["tenant-a"], +) +dummy_account_config = ProxyConfig( + pdns_api_token="blaaa", + pdns_api_url="bluub", + environments=[dummy_account_env], +) + + +def _make_pdns_mock(zones_list): + """Return an AsyncMock for PDNSConnector that yields the given zones.""" + pdns_mock = AsyncMock() + pdns_mock.get.return_value = object() # placeholder; handle_pdns_response patched + + class FakePDNSResponse: + is_success = True + data = zones_list + + def raise_for_error(self): + return 200 + + async def fake_handle(resp): + return FakePDNSResponse() + + return pdns_mock, fake_handle + + +def test_info_allowed_includes_account_matched_zones(): + pdns_zones = [ + {"name": "static.example.com.", "account": ""}, + {"name": "account-zone.example.com.", "account": "tenant-a"}, + {"name": "other.example.com.", "account": "tenant-b"}, + ] + pdns_mock, fake_handle = _make_pdns_mock(pdns_zones) + + with ( + patch( + "powerdns_api_proxy.config.load_config", return_value=dummy_account_config + ), + patch("powerdns_api_proxy.proxy.config", dummy_account_config), + patch("powerdns_api_proxy.proxy.pdns", pdns_mock), + patch("powerdns_api_proxy.proxy.handle_pdns_response", fake_handle), + ): + answer = client.get( + "/info/allowed", headers={"X-API-Key": dummy_account_env_token} + ) + assert answer.status_code == 200 + names = {z["name"] for z in answer.json()["zones"]} + # static zone plus account-matched zone (but not the wrong-account one) + assert names == {"static.example.com.", "account-zone.example.com."} + + +def test_info_allowed_no_accounts_skips_upstream(): + """If env has no accounts, /info/allowed must not call PowerDNS.""" + env_no_accounts = ProxyConfigEnvironment( + name="Static Env", + token_sha512=dummy_account_env_token_sha512, + zones=[ProxyConfigZone(name="static.example.com.")], + ) + config_no_accounts = ProxyConfig( + pdns_api_token="blaaa", + pdns_api_url="bluub", + environments=[env_no_accounts], + ) + pdns_mock = AsyncMock() + with ( + patch("powerdns_api_proxy.config.load_config", return_value=config_no_accounts), + patch("powerdns_api_proxy.proxy.config", config_no_accounts), + patch("powerdns_api_proxy.proxy.pdns", pdns_mock), + ): + answer = client.get( + "/info/allowed", headers={"X-API-Key": dummy_account_env_token} + ) + assert answer.status_code == 200 + pdns_mock.get.assert_not_called() + + +def test_info_zone_allowed_via_account(monkeypatch): + async def fake_account(pdns, server_id, zone_id): + return "tenant-a" + + monkeypatch.setattr( + "powerdns_api_proxy.config.get_zone_account_from_pdns", fake_account + ) + + pdns_mock = AsyncMock() + with ( + patch( + "powerdns_api_proxy.config.load_config", return_value=dummy_account_config + ), + patch("powerdns_api_proxy.proxy.config", dummy_account_config), + patch("powerdns_api_proxy.proxy.pdns", pdns_mock), + ): + answer = client.get( + "/info/zone-allowed?zone=account-zone.example.com.", + headers={"X-API-Key": dummy_account_env_token}, + ) + body = answer.json() + assert answer.status_code == 200 + assert body["allowed"] is True + assert body["zone"] == "account-zone.example.com." + assert body["config"]["name"] == "account-zone.example.com." + assert body["config"]["all_records"] is True + assert body["config"]["admin"] is False + + +def test_info_zone_allowed_account_miss(monkeypatch): + async def fake_account(pdns, server_id, zone_id): + return "wrong-tenant" + + monkeypatch.setattr( + "powerdns_api_proxy.config.get_zone_account_from_pdns", fake_account + ) + + pdns_mock = AsyncMock() + with ( + patch( + "powerdns_api_proxy.config.load_config", return_value=dummy_account_config + ), + patch("powerdns_api_proxy.proxy.config", dummy_account_config), + patch("powerdns_api_proxy.proxy.pdns", pdns_mock), + ): + answer = client.get( + "/info/zone-allowed?zone=other.example.com.", + headers={"X-API-Key": dummy_account_env_token}, + ) + body = answer.json() + assert answer.status_code == 200 + assert body["allowed"] is False