PowerDNS-Admin and the Record Nobody Can Edit

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

Where the fork starts

The fork is PowerDNS-Admin v0.4.2. Upstream's last commit on this branch is 6b73b8f, "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.

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:

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:

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.

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:

# 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:

@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:

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:

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:

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:

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:

# 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.