---
title: 'PowerDNS-Admin and the Record Nobody Can Edit'
url: 'https://kaleb.au/blog/powerdns-admin-and-the-record-nobody-can-edit'
markdown: 'https://kaleb.au/blog/powerdns-admin-and-the-record-nobody-can-edit.md'
date: '2026-09-28'
description: 'PowerDNS-Admin is a good piece of work. It does forward and reverse zone management, user management with role-based access, SAML, LDAP and OAuth, two-factor auth, an API, and full IDN support, and it does all of it without ever asking you what you actually intend to use it for. I run hosted DNS on…'
---

# [PowerDNS-Admin and the Record Nobody Can Edit](https://kaleb.au/blog/powerdns-admin-and-the-record-nobody-can-edit)

   28 Sep 2026  

PowerDNS-Admin is a good piece of work. It does forward and reverse zone management, user management with role-based access, SAML, LDAP and OAuth, two-factor auth, an API, and full IDN support, and it does all of it without ever asking you what you actually intend to use it for.

I run hosted DNS on top of it. That means the difference between what it ships and what I need is four commits, all mine, and the one that matters is about a DNS record nobody is allowed to edit.

Repository: [git.punksky.com/punksky/dns/power-dns-admin](https://git.punksky.com/punksky/dns/power-dns-admin)

## Where the fork starts

The fork is PowerDNS-Admin v0.4.2. Upstream's last commit on this branch is [`6b73b8f`](https://git.punksky.com/punksky/dns/power-dns-admin/log/?h=master), "Update login.html", December 2024. Everything before that is Tyler Todd, Matt Scott and the rest of the upstream contributors, which is most of the log by a wide margin.

On top of that:

| Date | Commit | Files | Change |
|---|---|---|---|
| 2026-02-21 | `4ab9546` work on name space logic | 16 | −536 / +188 |
| 2026-02-21 | `0f3d244` update build script | 1 | −31 / +13 |
| 2026-09-28 | `492ba93` introduce system-managed record locks with admin control | 12 | −13 / +512 |
| 2026-09-28 | `8b352c0` migrate Dockerfile to alpine, streamline dependencies and update build workflow | 2 | −45 / +33 |

Two pieces of real work, nineteen months apart, and two commits of housekeeping. That's the whole fork.

One detail worth noting about the September commit, because it's the kind of change that's invisible in a stat block: it also added `.lower()` to the nameserver string. Zone names are stored and compared case-insensitively, so a nameserver that came back with mixed case when the zone itself isn't is a mismatch waiting to happen. `Mcghie` became `McGhie` in the same commit, which is what prompted it.

It's worth being honest about where upstream has gone since. This fork is still on v0.4.2 from January 2024, and upstream is working towards v0.5.0 with branches like `1601_refactor_domain_routes` and `1536-application-settings-editor-overhaul` in flight. Rebasing onto that is a real piece of work and not a weekend, because my changes touch `Record.add()`, which is the single most-called function in the codebase.

## Namespaces, because one nameserver identity is a shared liability

The first of my commits adds a file that is, on its face, a list of Australian towns.

```python
NS_SUBDOMAIN_OPTIONS = [
    "Cooma", "Canberra", "Sydney", "Yass", "Caragabal", "Nimmitabel",
    "Gympie", "Cooroy", "Adelaide", "Melbourne", "Tathra", "Zetland",
    # ... and eventually "torvalds", "mx", "Punk"
]
```

Thirty-nine of them, and then some jokes. A new zone gets one of those subdomains drawn at random, matted against all four nameserver hosts:

```python
def get_random_nameservers():
    hosts = random.sample(NS_HOST_OPTIONS, min(4, len(NS_HOST_OPTIONS)))
    return ["{}.{}".format(random.choice(NS_SUBDOMAIN_OPTIONS), host).lower() for host in hosts]
```

The reason is narrow and unglamorous. If every customer zone delegates to the same four nameserver hostnames, then every zone looks like the same infrastructure to anything scanning for it, and one block or one takedown hits all of them at once. Spreading the delegation across a pool of hostnames means losing one of them degrades a quarter of the zones rather than all of them.

The same commit makes apex NS records read-only, so a customer can't accidentally strip the delegation that keeps their zone resolvable:

```python
domain_apex = domain_name.rstrip('.')
for rrset in new_rrsets['rrsets'] + del_rrsets['rrsets']:
    if (rrset.get('type') == 'NS' and
            rrset.get('name', '').rstrip('.') == domain_apex):
        return {
            'status': 'error',
            'msg': 'NS records at the zone apex are read-only.'
        }
```

## The record nobody can edit

Here's the actual problem. In a hosted DNS product, the customer has full control of their zone. They can delete any record in it. That's the product.

Except for some records. There are records that must exist and must point where they point, because something I operate depends on them and the customer has no way to know that, because from inside the panel their zone looks entirely theirs.

So I added a lock.

```python
class RecordLock(db.Model):
    __tablename__ = 'record_lock'

    domain_id = db.Column(db.Integer, db.ForeignKey('domain.id'),
                          nullable=False, index=True)
    name = db.Column(db.String(255), nullable=False)
    type = db.Column(db.String(10), nullable=False)
    managed_by = db.Column(db.String(128), nullable=False, default='system')
```

Five columns, a foreign key to the zone, and a unique constraint on `(domain_id, name, type)`. There's a matching Alembic migration, `40b0889b6f8b`, which is the boring part that makes this survivable.

The key is `(zone, name, type)`, normalised by stripping trailing dots on both sides so `example.com.` and `example.com` are the same record. Getting that wrong would produce a lock that appears to work and silently doesn't, which is the worst possible failure mode for a feature whose entire purpose is to fail loudly.

## Four enforcement points, not one

The part I was deliberate about is *where* the check happens. PowerDNS-Admin has more than one path to mutate a record, and a lock that only guards one of them is a lock that gets bypassed in a week.

It's enforced in `Record.add()`, which handles the bulk diff from the web UI and the API:

```python
# This is an absolute lock: no caller (including operators) may
# mutate a locked record through the regular apply path. Only the
# dedicated admin endpoint can change the lock itself.
for rrset in new_rrsets['rrsets'] + del_rrsets['rrsets']:
    if _is_system_managed(domain_name, rrset.get('name', ''), rrset.get('type', '')):
        return {
            'status': 'error',
            'msg': 'Record {0} ({1}) is system-managed and read-only.'.format(...)
        }
```

It's enforced again in `Record.delete()` and again in `Record.update_single()`. Those last two are the ones people forget, because they're the single-record paths used by the auto-PTR logic and by DynDNS. DynDNS clients update their own record without any UI involvement, which makes it exactly the mechanism you'd expect to be used to walk around a lock.

And there's a fourth, at the HTTP edge, as a decorator on the zone-forwarding route:

```python
@api_bp.route('/servers/<server_id>/zones/<zone_id>/<path:subpath>',
              methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])
@apikey_auth
@apikey_can_access_domain
@protected_from_system_records
@csrf.exempt
def api_zone_subpath_forward(server_id, zone_id, subpath):
```

`protected_from_system_records` inspects the `rrsets` array in a PATCH body and rejects any `REPLACE` or `DELETE` touching a locked key, with a `RecordIsSystemManaged` exception that renders as a `403`.

Four layers, because I don't know which path a caller will use and I would rather pay for the redundancy than find out.

## Absolute, and reversible

Two properties of the lock that took some thought.

The first is that it's absolute. An operator, an administrator, an API key with the widest possible scope all rejected. The reasoning is that a lock you can turn off with the right permission isn't protecting anything from anyone who matters. The only thing that can change a lock is the dedicated endpoint:

```text
GET    /admin/domain/<zone>/system-record     list locks
POST   /admin/domain/<zone>/system-record     create a system-managed record and lock it
DELETE /admin/domain/<zone>/system-record     remove the lock
```

The second is that removing a lock does *not* delete the record. `DELETE` on that endpoint removes the `record_lock` row and leaves the rrset sitting in PowerDNS, now user-editable again. Create and remove are not symmetric, deliberately: an operator needs to be able to hand a record back, and "hand it back" shouldn't mean "destroy it on the way out".

Creating one writes the rrset through the normal `Record.add()` path first, and only then inserts the lock. If the lock insert fails, it returns a `500` that says so plainly:

```text
Record was written to PowerDNS but the lock could not be created.
Re-run to make it read-only.
```

A partially-applied system record is visible, and the error tells you what state you're in rather than pretending it didn't happen. Both create and delete write a `History` entry authored by `System`, so the audit trail attributes these changes to something other than whichever human happened to be logged in.

## The padlock is decoration

There are two UI changes and neither of them is the security boundary.

`RecordEntry` now takes `is_system_managed` and ANDs it into both permission flags:

```python
self._is_allowed_edit = is_allowed_edit and not is_system_managed
self._is_allowed_delete = (is_allowed_edit
                           and self.type != 'SOA'
                           and not is_system_managed)
```

And the template renders a padlock badge, plus client-side guards on the edit and delete click handlers so a locked record doesn't open an editable row that the server will then refuse.

All three of those are for the user's benefit, not the system's. If someone disables JavaScript or calls the API directly, the badge does nothing the refusal has to come from `Record.add()` and friends, which is why those are where the real check lives. It's a nice property of the design that the UI can't accidentally become the only enforcement.

## Housekeeping

The February commit also deletes a lot: seven GitHub workflows (MegaLinter, CodeQL, stale, lock, dependabot), the PR and issue templates, the funding config, all 536 lines of it, replaced by 188 lines that add a `lib/nameservers.py`, an apex-NS guard, and a hand-written `.gitlab-ci.yml`.

The CI file is the part I'd defend. It's lint, test and build, MegaLinter gated to merge requests rather than to `master`, and SAST via bandit and safety:

```yaml
stages:
  - lint
  - test
  - build
```

Then in September the test image moves from `debian:bullseye-slim` and its eighteen `apt-get install` lines to `alpine:3.17` and an `ARG BUILD_DEPENDENCIES` list, which is a better shape and about forty fewer lines:

```dockerfile
# py3-pip should not belong to BUILD_DEPENDENCIES. Otherwise, when we remove
# them with "apk del", the python requests module will be removed as well.
```

That comment is the entire reason to not consolidate those two lines. It documents a trap that cost somebody an afternoon, and it's worth more than the line it sits on.

## What running someone else's admin panel costs

The thing worth taking from this isn't the lock. It's that the moment PowerDNS-Admin became a product surface, I inherited every security bug in it, and the mitigation is writing guards inside its decorator chain and hoping the maintainers don't move them.

That's the actual bargain of running other people's software as a business. You get the features for free and you get the risk for free, and the only lever you have is how carefully you put your own code underneath theirs. Four commits is a good outcome. It's also four commits of *my* code that now has to keep working against every upstream release I choose not to take.

Every record a customer can't break is one that works. Which is a low bar for a DNS panel, and still the bar that matters when it's somebody else's reputation resolution.

---

## Navigation

- Parent: [Blog](https://kaleb.au/blog.md)
- Previous: [The Guest Never Gets a Port](https://kaleb.au/blog/the-guest-never-gets-a-port.md)
- Next: [The High Court Is Deciding Whether Australia Needs a Power It Already Rejected at a Referendum](https://kaleb.au/blog/white-australia-and-the-1951-question.md)
