Dan Matthew

Program note · Writing · 6 Sep 2026

A permission a wildcard can never widen

Most permission bugs are not exotic. They are a wildcard grant that reached one step further than anyone meant it to. Building a CRM full of personal data, that was the failure I wanted to make impossible.

#nimbuscms #php #security #permissions

Most permission bugs are not exotic. They are a wildcard grant that reached one step further than anyone meant it to. When I built a CRM plugin for NimbusCMS, a plugin whose whole job is to hold people's names, emails, phone numbers, and notes, that was the exact failure I wanted to make impossible before I wrote a line of it.

In Nimbus, a plugin declares its own capability and access is granted one capability at a time. The CRM declares nimbuscms.crm, with read and write. The rule that matters is this: a content editor holding a broad content grant can never reach it.

The convenient trap

Plenty of systems grant content permissions with a wildcard. An editor role gets something like :read or :write across every content type, which is genuinely convenient. But a CRM is not content. If the CRM answered to a content wildcard, then every content editor would quietly hold the keys to the personal data, and nobody would have decided that on purpose. It would just be a side effect of a convenience.

So Nimbus makes management capabilities wildcard immune. The authorization rule, in full, is short:

  • An admin grant allows everything.
  • An exact resource:action grant allows that one thing.
  • For content, a wildcard *:action works, and writing a type implies reading it.
  • But a plugin's declared capability, and the core management resources like users, tokens, roles, and settings, are wildcard immune. Only an exact grant or admin reaches them.

A *:write content grant stops at the door of the CRM. The CRM's data is reachable by exactly two kinds of principal: someone granted nimbuscms.crm:read explicitly, or an admin. No accident widens it.

The second boundary, across plugins

The restaurant app I built on Nimbus links a reservation to a CRM guest. Here is the whole of what it stores about that guest: an id, a bare number. It never reads the CRM.

On the reservation screen a floor waiter sees the booking. The "guest in CRM" link points at the CRM's own capability gated page, which the waiter cannot open and the manager can. The PII gate is visible right there in the product: one screen, two roles, one can open the record and one cannot. And the restaurant plugin never had to hold or copy a single CRM field to make that true. It stores an id and a link, nothing else. The plugin that owns the people is the only plugin that can read the people.

The same gate for an agent

The CRM is operable by an agent over MCP, and the exact same rule applies to a token as to a person. A token scoped to content write cannot call a CRM tool. The tools are also non enumerating: asking for a contact that does not exist returns a clean not found rather than an oracle you can probe for which ids are real.

What it costs

This is a single operator model. A holder of nimbuscms.crm:read can read every contact, because in a one operator CRM that holder is the operator. There is no per record ownership check in version one, and I would add one the moment a real multi user need shows up, not a moment before.

Wildcard immunity is also a blunt instrument on purpose. You cannot hand someone "all the plugins" with one grant, you grant each one explicitly. For a capability that guards personal data, that friction is the feature.

The best part is what it removes. I never have to reason at request time about whether some broad grant leaks the CRM, because the type of the capability forbids it. The wildcard stops at the door.