Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions config-example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ environments:
zones:
- name: zone1.example.com
- name: zone2.example.com
accounts:
- example-account
89 changes: 89 additions & 0 deletions powerdns_api_proxy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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


Expand All @@ -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}"
)
Comment on lines +179 to +217
raise ZoneNotAllowedException()


def check_pdns_zone_admin(environment: ProxyConfigEnvironment, zone: str) -> bool:
try:
env_zone = environment.get_zone_if_allowed(zone)
Expand Down
2 changes: 2 additions & 0 deletions powerdns_api_proxy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
52 changes: 39 additions & 13 deletions powerdns_api_proxy/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down Expand Up @@ -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(
Expand All @@ -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)


Expand Down Expand Up @@ -301,9 +320,11 @@ async def get_zone_metadata(
<https://doc.powerdns.com/authoritative/http-api/zone.html#get--servers-server_id-zones-zone_id>
"""
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),
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -404,9 +426,11 @@ async def zone_notification(server_id: str, zone_id: str, X_API_Key: str = Heade
<https://doc.powerdns.com/authoritative/http-api/zone.html#put--servers-server_id-zones-zone_id-notify>
"""
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()
Expand All @@ -428,9 +452,11 @@ async def zone_rectification(
<https://doc.powerdns.com/authoritative/http-api/zone.html#put--servers-server_id-zones-zone_id-rectify>
"""
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()
Expand Down
Loading