From 27b053d919ddd6adb8fd799626771e2183b8879a Mon Sep 17 00:00:00 2001 From: Teun Ouwehand Date: Tue, 24 Mar 2026 13:21:44 +0100 Subject: [PATCH] feat: add allowed_record_types and enforce append_only as superset check Two new per-zone options in the proxy config: **allowed_record_types** (list[str]) Restricts which DNS record types a token may write in a zone. An empty list (the default) means all types are permitted. **append_only** (bool) Prevents any write from removing existing records: - DELETE changesets are rejected with HTTP 403. - REPLACE changesets are verified against the live PowerDNS state. The incoming records must be a strict superset of what is currently in PowerDNS for that name+type. Any missing record results in a 403. Both flags can be combined. Example: a token that may only ever add TXT records globally and can never remove any: - name: "append-txt-only" token_sha512: "..." global_read_only: true zones: - name: ".*" regex: true allowed_record_types: - "TXT" append_only: true Implementation notes: - check_rrset_allowed() enforces allowed_record_types and blocks DELETE for append_only zones (pure, no I/O). - check_append_only_records_intact() is a new pure helper that compares existing vs incoming records by content and returns False if any would be lost. - ensure_rrsets_request_allowed() gains an optional existing_rrsets param. When zone.append_only is True, proxy.py fetches the current zone from PowerDNS and passes the rrsets list in; all policy decisions stay in config.py. - 20 new unit tests covering allowed_record_types, append_only, combined behaviour, and all edge cases of the superset check. - README updated with documentation for both new zone options. --- README.md | 52 ++++++ powerdns_api_proxy/config.py | 66 ++++++- powerdns_api_proxy/models.py | 7 + powerdns_api_proxy/proxy.py | 20 +- tests/unit/config_test.py | 345 +++++++++++++++++++++++++++++++++++ 5 files changed, 485 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 79aaf7b..e9dfc6c 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,58 @@ environments: acme: true ``` +##### Allowed record types + +Under a `zone` the option `allowed_record_types` can be set to a list of DNS record types. + +Only the listed types may be written. Any attempt to write a different type is rejected with `HTTP 403`. +An empty list (the default) means all record types are permitted. + +```yaml +... +environments: + - name: "Test1" + zones: + - name: "example.com" + allowed_record_types: + - "TXT" + - "AAAA" +``` + +##### Append only + +Under a `zone` the option `append_only: true` can be set. + +This prevents any write from removing existing records: + +- `DELETE` changesets are rejected with `HTTP 403`. +- `REPLACE` changesets are verified against the live PowerDNS state. The incoming records must contain **all records that are currently in PowerDNS** for that name and type. Any missing record results in `HTTP 403`. + +```yaml +... +environments: + - name: "Test1" + zones: + - name: "example.com" + append_only: true +``` + +`allowed_record_types` and `append_only` can be combined. The example below creates a token that can read all zones and may only ever add TXT records — never update or remove any: + +```yaml +... +environments: + - name: "append-txt-only" + token_sha512: "..." + global_read_only: true + zones: + - name: ".*" + regex: true + allowed_record_types: + - "TXT" + append_only: true +``` + ##### Admin Under a `zone` `admin` rights can be defined. diff --git a/powerdns_api_proxy/config.py b/powerdns_api_proxy/config.py index fc52dfa..8ebd6c1 100644 --- a/powerdns_api_proxy/config.py +++ b/powerdns_api_proxy/config.py @@ -152,6 +152,16 @@ def check_rrset_allowed(zone: ProxyConfigZone, rrset: RRSET) -> bool: if zone.read_only: return False + if zone.append_only and rrset.get("changetype") == "DELETE": + logger.debug(f"RRSET {rrset['name']} not allowed: DELETE changetype blocked by append_only") + return False + + if zone.allowed_record_types and rrset.get("type") not in zone.allowed_record_types: + logger.debug( + f"RRSET {rrset['name']} not allowed: type {rrset.get('type')} not in allowed_record_types {zone.allowed_record_types}" + ) + return False + if zone.all_records: return True @@ -173,6 +183,41 @@ def check_rrset_allowed(zone: ProxyConfigZone, rrset: RRSET) -> bool: return False +def check_append_only_records_intact( + existing_rrsets: list[dict], incoming_rrset: RRSET +) -> bool: + """ + For append_only zones, verify that a REPLACE changeset does not remove any + existing records from the RRset. + + Finds the existing RRset matching name+type and checks that every existing + record content is still present in the incoming records list. + + Returns True if the incoming records are a superset of existing records + (i.e. nothing is removed), False otherwise. + """ + name = incoming_rrset["name"] + rtype = incoming_rrset["type"] + + existing = next( + (r for r in existing_rrsets if r["name"] == name and r["type"] == rtype), + None, + ) + if existing is None: + return True # no existing RRset for this name+type, nothing can be lost + + existing_contents = {rec["content"] for rec in existing.get("records", [])} + incoming_contents = {rec["content"] for rec in incoming_rrset.get("records", [])} + + missing = existing_contents - incoming_contents + if missing: + logger.debug( + f"RRSET {name} append_only violation: existing records would be removed: {missing}" + ) + return False + return True + + def check_acme_record_allowed(zone: ProxyConfigZone, rrset: RRSET) -> bool: if zone.all_records: logger.debug("ACME challenge allowed, because all records are allowed") @@ -210,8 +255,16 @@ def check_pdns_tsigkeys_allowed(environment: ProxyConfigEnvironment) -> bool: return False -def ensure_rrsets_request_allowed(zone: ProxyConfigZone, request: RRSETRequest) -> bool: - """Raises HTTPException if RRSET is not allowed""" +def ensure_rrsets_request_allowed( + zone: ProxyConfigZone, + request: RRSETRequest, + existing_rrsets: list[dict] = [], +) -> bool: + """Raises HTTPException if RRSET is not allowed. + + `existing_rrsets` should be passed for append_only zones so that REPLACE + changesets can be verified not to remove any currently live records. + """ if zone.read_only: logger.info("RRSET update not allowed with read only token") raise HTTPException(403, "RRSET update not allowed with read only token") @@ -219,5 +272,14 @@ def ensure_rrsets_request_allowed(zone: ProxyConfigZone, request: RRSETRequest) if not check_rrset_allowed(zone, rrset): logger.info(f"RRSET {rrset['name']} not allowed in zone {zone.name}") raise HTTPException(403, f"RRSET {rrset['name']} not allowed") + if zone.append_only and rrset.get("changetype") == "REPLACE": + if not check_append_only_records_intact(existing_rrsets, rrset): + logger.info( + f"RRSET {rrset['name']} append_only violation in zone {zone.name}" + ) + raise HTTPException( + 403, + f"RRSET {rrset['name']} append_only violation: existing records would be removed", + ) logger.info(f"RRSET {rrset['name']} allowed") return True diff --git a/powerdns_api_proxy/models.py b/powerdns_api_proxy/models.py index fdb697b..e084552 100644 --- a/powerdns_api_proxy/models.py +++ b/powerdns_api_proxy/models.py @@ -28,6 +28,11 @@ class ProxyConfigZone(BaseModel): `all_records` will be set to `True` if no `records` are defined. `cryptokeys` enables management of DNSSEC. `read_only` controls write permissions for this specific zone. + `allowed_record_types` restricts writes to the given DNS record types (e.g. ["TXT"]). + An empty list means all record types are allowed. + `append_only` blocks DELETE changesets and ensures REPLACE changesets never + remove existing records — the incoming records must be a superset of the + records currently in PowerDNS. """ name: str @@ -41,6 +46,8 @@ class ProxyConfigZone(BaseModel): all_records: bool = False read_only: bool = False cryptokeys: bool = False + allowed_record_types: list[str] = [] + append_only: bool = False def __init__(self, **data): super().__init__(**data) diff --git a/powerdns_api_proxy/proxy.py b/powerdns_api_proxy/proxy.py index 7566bb8..05bbb96 100644 --- a/powerdns_api_proxy/proxy.py +++ b/powerdns_api_proxy/proxy.py @@ -3,7 +3,7 @@ from http import HTTPStatus from typing import Literal -from fastapi import APIRouter, Depends, FastAPI, Header, Request, Response +from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Request, Response from fastapi.responses import HTMLResponse, JSONResponse from prometheus_fastapi_instrumentator import Instrumentator, metrics from starlette.exceptions import HTTPException as StarletteHTTPException @@ -361,10 +361,24 @@ async def update_zone_rrset( logger.info(f"Zone {zone_id} not allowed for environment {environment.name}") raise ZoneNotAllowedException() zone = environment.get_zone_if_allowed(zone_id) - ensure_rrsets_request_allowed(zone, await request.json()) + payload = await request.json() + + existing_rrsets = [] + if zone.append_only: + zone_resp = await pdns.get(f"/api/v1/servers/{server_id}/zones/{zone_id}") + zone_pdns_response = await handle_pdns_response(zone_resp) + zone_pdns_response.raise_for_error() + existing_rrsets = ( + zone_pdns_response.data.get("rrsets", []) + if isinstance(zone_pdns_response.data, dict) + else [] + ) + + ensure_rrsets_request_allowed(zone, payload, existing_rrsets) + resp = await pdns.patch( f"/api/v1/servers/{server_id}/zones/{zone_id}", - payload=await request.json(), + payload=payload, ) 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..0f139b3 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -6,6 +6,7 @@ from powerdns_api_proxy.config import ( check_acme_record_allowed, + check_append_only_records_intact, check_pdns_search_allowed, check_pdns_cryptokeys_allowed, check_pdns_tsigkeys_allowed, @@ -495,6 +496,65 @@ def test_rrset_request_not_allowed_false_zone(): assert err.value.detail == "RRSET example1.test-zone2.example.com. not allowed" +def test_ensure_rrsets_request_allowed_append_only_superset(): + """ensure_rrsets_request_allowed passes when existing records are all preserved.""" + zone = ProxyConfigZone(name="test-zone.example.com.", append_only=True) + existing_rrsets = [ + { + "name": "entry1.test-zone.example.com.", + "type": "TXT", + "records": [{"content": '"record-1"', "disabled": False}], + } + ] + request: RRSETRequest = { + "rrsets": [ + { + "name": "entry1.test-zone.example.com.", + "type": "TXT", + "changetype": "REPLACE", + "ttl": 300, + "records": [ + {"content": '"record-1"', "disabled": False}, + {"content": '"record-2"', "disabled": False}, + ], + "comments": [], + } + ] + } + assert ensure_rrsets_request_allowed(zone, request, existing_rrsets) + + +def test_ensure_rrsets_request_allowed_append_only_drops_record(): + """ensure_rrsets_request_allowed raises 403 when existing records would be removed.""" + zone = ProxyConfigZone(name="test-zone.example.com.", append_only=True) + existing_rrsets = [ + { + "name": "entry1.test-zone.example.com.", + "type": "TXT", + "records": [ + {"content": '"record-1"', "disabled": False}, + {"content": '"record-2"', "disabled": False}, + ], + } + ] + request: RRSETRequest = { + "rrsets": [ + { + "name": "entry1.test-zone.example.com.", + "type": "TXT", + "changetype": "REPLACE", + "ttl": 300, + "records": [{"content": '"record-2"', "disabled": False}], + "comments": [], + } + ] + } + with pytest.raises(HTTPException) as err: + ensure_rrsets_request_allowed(zone, request, existing_rrsets) + assert err.value.status_code == 403 + assert "append_only violation" in err.value.detail + + def test_check_acme_record_allowed_all_records(): zone = ProxyConfigZone(name="test-zone.example.com", all_records=True) rrset = RRSET( @@ -681,3 +741,288 @@ 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 + + +def test_check_rrset_allowed_record_type_allowed(): + """A matching record type is allowed when allowed_record_types is set.""" + zone = ProxyConfigZone(name="test-zone.example.com.", allowed_record_types=["TXT"]) + rrset: RRSET = { + "name": "entry1.test-zone.example.com.", + "type": "TXT", + "changetype": "REPLACE", + "ttl": 3600, + "records": [], + "comments": [], + } + assert check_rrset_allowed(zone, rrset) + + +def test_check_rrset_not_allowed_record_type_mismatch(): + """A record type not in allowed_record_types is blocked.""" + zone = ProxyConfigZone(name="test-zone.example.com.", allowed_record_types=["TXT"]) + rrset: RRSET = { + "name": "entry1.test-zone.example.com.", + "type": "A", + "changetype": "REPLACE", + "ttl": 3600, + "records": [], + "comments": [], + } + assert not check_rrset_allowed(zone, rrset) + + +def test_check_rrset_allowed_multiple_record_types(): + """All types in allowed_record_types are permitted; types outside are blocked.""" + zone = ProxyConfigZone( + name="test-zone.example.com.", allowed_record_types=["TXT", "AAAA"] + ) + for rtype in ["TXT", "AAAA"]: + rrset: RRSET = { + "name": "entry1.test-zone.example.com.", + "type": rtype, + "changetype": "REPLACE", + "ttl": 3600, + "records": [], + "comments": [], + } + assert check_rrset_allowed(zone, rrset) + + rrset_a: RRSET = { + "name": "entry1.test-zone.example.com.", + "type": "A", + "changetype": "REPLACE", + "ttl": 3600, + "records": [], + "comments": [], + } + assert not check_rrset_allowed(zone, rrset_a) + + +def test_check_rrset_allowed_no_type_restriction(): + """All record types are allowed when allowed_record_types is not set.""" + zone = ProxyConfigZone(name="test-zone.example.com.") + for rtype in ["A", "AAAA", "TXT", "MX", "CNAME"]: + rrset: RRSET = { + "name": "entry1.test-zone.example.com.", + "type": rtype, + "changetype": "REPLACE", + "ttl": 3600, + "records": [], + "comments": [], + } + assert check_rrset_allowed(zone, rrset) + + +def test_check_rrset_append_only_allows_replace(): + """REPLACE changesets are allowed when append_only is set.""" + zone = ProxyConfigZone(name="test-zone.example.com.", append_only=True) + rrset: RRSET = { + "name": "entry1.test-zone.example.com.", + "type": "TXT", + "changetype": "REPLACE", + "ttl": 3600, + "records": [], + "comments": [], + } + assert check_rrset_allowed(zone, rrset) + + +def test_check_rrset_append_only_blocks_delete(): + """DELETE changesets are blocked when append_only is set.""" + zone = ProxyConfigZone(name="test-zone.example.com.", append_only=True) + rrset: RRSET = { + "name": "entry1.test-zone.example.com.", + "type": "TXT", + "changetype": "DELETE", + "ttl": 3600, + "records": [], + "comments": [], + } + assert not check_rrset_allowed(zone, rrset) + + +def test_check_rrset_not_append_only_allows_delete(): + """DELETE changesets are allowed when append_only is not set.""" + zone = ProxyConfigZone(name="test-zone.example.com.") + rrset: RRSET = { + "name": "entry1.test-zone.example.com.", + "type": "TXT", + "changetype": "DELETE", + "ttl": 3600, + "records": [], + "comments": [], + } + assert check_rrset_allowed(zone, rrset) + + +def test_check_rrset_txt_append_only_allowed(): + """TXT REPLACE is allowed when both allowed_record_types=["TXT"] and append_only are set.""" + zone = ProxyConfigZone( + name="test-zone.example.com.", + allowed_record_types=["TXT"], + append_only=True, + ) + rrset: RRSET = { + "name": "entry1.test-zone.example.com.", + "type": "TXT", + "changetype": "REPLACE", + "ttl": 3600, + "records": [], + "comments": [], + } + assert check_rrset_allowed(zone, rrset) + + +def test_check_rrset_txt_append_only_blocks_delete(): + """TXT DELETE is blocked when both allowed_record_types=["TXT"] and append_only are set.""" + zone = ProxyConfigZone( + name="test-zone.example.com.", + allowed_record_types=["TXT"], + append_only=True, + ) + rrset: RRSET = { + "name": "entry1.test-zone.example.com.", + "type": "TXT", + "changetype": "DELETE", + "ttl": 3600, + "records": [], + "comments": [], + } + assert not check_rrset_allowed(zone, rrset) + + +def test_check_rrset_txt_append_only_blocks_wrong_type(): + """A non-TXT record type is blocked when allowed_record_types=["TXT"] and append_only are set.""" + zone = ProxyConfigZone( + name="test-zone.example.com.", + allowed_record_types=["TXT"], + append_only=True, + ) + rrset: RRSET = { + "name": "entry1.test-zone.example.com.", + "type": "A", + "changetype": "REPLACE", + "ttl": 3600, + "records": [], + "comments": [], + } + assert not check_rrset_allowed(zone, rrset) + + +def test_check_append_only_records_intact_no_existing_rrset(): + """No existing RRset for this name+type → always safe to proceed.""" + incoming: RRSET = { + "name": "demo.test-zone.example.com.", + "type": "TXT", + "changetype": "REPLACE", + "ttl": 300, + "records": [{"content": '"new-record"', "disabled": False}], + "comments": [], + } + assert check_append_only_records_intact([], incoming) + + +def test_check_append_only_records_intact_superset(): + """Incoming records include all existing ones plus a new one → allowed.""" + existing_rrsets = [ + { + "name": "demo.test-zone.example.com.", + "type": "TXT", + "records": [{"content": '"record-1"', "disabled": False}], + } + ] + incoming: RRSET = { + "name": "demo.test-zone.example.com.", + "type": "TXT", + "changetype": "REPLACE", + "ttl": 300, + "records": [ + {"content": '"record-1"', "disabled": False}, + {"content": '"record-2"', "disabled": False}, + ], + "comments": [], + } + assert check_append_only_records_intact(existing_rrsets, incoming) + + +def test_check_append_only_records_intact_exact_same(): + """Incoming records are identical to existing → allowed.""" + existing_rrsets = [ + { + "name": "demo.test-zone.example.com.", + "type": "TXT", + "records": [{"content": '"record-1"', "disabled": False}], + } + ] + incoming: RRSET = { + "name": "demo.test-zone.example.com.", + "type": "TXT", + "changetype": "REPLACE", + "ttl": 300, + "records": [{"content": '"record-1"', "disabled": False}], + "comments": [], + } + assert check_append_only_records_intact(existing_rrsets, incoming) + + +def test_check_append_only_records_intact_drops_record(): + """Incoming records are missing an existing record → blocked.""" + existing_rrsets = [ + { + "name": "demo.test-zone.example.com.", + "type": "TXT", + "records": [ + {"content": '"record-1"', "disabled": False}, + {"content": '"record-2"', "disabled": False}, + ], + } + ] + incoming: RRSET = { + "name": "demo.test-zone.example.com.", + "type": "TXT", + "changetype": "REPLACE", + "ttl": 300, + "records": [{"content": '"record-2"', "disabled": False}], + "comments": [], + } + assert not check_append_only_records_intact(existing_rrsets, incoming) + + +def test_check_append_only_records_intact_different_type(): + """Existing A record is unaffected by a TXT REPLACE → safe.""" + existing_rrsets = [ + { + "name": "demo.test-zone.example.com.", + "type": "A", + "records": [{"content": "1.2.3.4", "disabled": False}], + } + ] + incoming: RRSET = { + "name": "demo.test-zone.example.com.", + "type": "TXT", + "changetype": "REPLACE", + "ttl": 300, + "records": [{"content": '"some-txt"', "disabled": False}], + "comments": [], + } + assert check_append_only_records_intact(existing_rrsets, incoming) + + +def test_check_append_only_records_intact_different_name(): + """Existing RRset has a different name → not matched, safe.""" + existing_rrsets = [ + { + "name": "other.test-zone.example.com.", + "type": "TXT", + "records": [{"content": '"record-1"', "disabled": False}], + } + ] + incoming: RRSET = { + "name": "demo.test-zone.example.com.", + "type": "TXT", + "changetype": "REPLACE", + "ttl": 300, + "records": [], + "comments": [], + } + assert check_append_only_records_intact(existing_rrsets, incoming)