PermDock
Standards

OpenAPI Overlay

How permdock openapi --format overlay emits an Overlay 1.1.0 document (or, behind --overlay 1.2, the pinned Overlay 1.2 draft with reusable actions) that adds security, securitySchemes and x-permdock-* fields to an OpenAPI description without mutating it, how to apply and check it in CI, and why the Overlay never removes security.

Status: planned Phase: 2 Draft posture: build (Overlay 1.2 pinned to v1.2-dev at commit edd4adea, 8 August 2026, recorded in x-permdock-catalog.drafts.overlay; 1.1.0 stays the default output)

What it is

The Overlay Specification 1.1.0 (released 14 January 2026; 1.0.0 was 17 October 2024) describes a document that repeatably applies transformations to one or many OpenAPI descriptions: adding metadata, removing elements before sharing a description, updating descriptions. An Overlay document has:

FieldMeaning
overlayThe Overlay specification version, 1.1.0
infotitle and version of the Overlay itself
extendsURI of the OpenAPI description the Overlay applies to (optional; an applier may be given the target explicitly)
actionsOrdered list of actions. Each has a target JSONPath expression selecting nodes in the description, plus one of update (merge a value into every selected node), remove (delete every selected node) or, new in 1.1, copy (copy a value from another location)

An applier resolves each target against the description and applies the actions in order; the output is a new OpenAPI description. The source is never edited.

Overlay 1.2 (in development on the v1.2-dev branch, release date "TBD" in its revision history) adds one construct: reusable actions. A root components object holds components.actions, a map of Reusable Action Objects, each with a description and a fields object that is an Action Object without target. An entry in actions may then be a Reusable Action Reference Object instead of an Action Object: $ref (a same-document JSON Pointer restricted to #/components/actions/), a required target, and an optional description that overrides the one in fields. Nothing else on the object may be overridden; the update, remove and copy come from the referenced action. Earlier notes on this page and elsewhere described a targetFormat field for AsyncAPI targets; that idea is not in the v1.2-dev text at the pinned commit and PermDock does not emit it.

Why PermDock prefers an Overlay

permdock openapi emit can write the mutated document, and that is still the default (--format document) because it is the simplest thing to serve. The Overlay form is preferred whenever the OpenAPI description is owned by someone other than the team running PermDock, or whenever the security metadata should be reviewable on its own:

  • The source stays owned by the API team. A generator (@hono/zod-openapi, hono-openapi, @orpc/openapi, trpc-to-openapi, next-openapi-gen) or a hand-written file remains the single source of truth for paths, schemas and descriptions. PermDock adds authorization metadata beside it instead of rewriting it.
  • It is how PermDock reaches producers it has no hook in. Next.js route handlers, and every framework next-openapi-gen scans, have no in-process place to call describe(). The Overlay is the hand-off: the producer generates, PermDock overlays, the producer's applier merges (ADR 0023). For generated descriptions the Overlay is therefore the documented default even though --format document stays the CLI default.
  • PermDock's output is reviewable. The Overlay contains exactly what PermDock decided: which operations require which scopes, which keys they map to, which scheme they use. A reviewer reads a few dozen lines instead of diffing a whole regenerated description.
  • Repeatable and diffable in CI. The Overlay is a deterministic function of the catalog and the source's operationIds. permdock openapi emit --format overlay --check fails when the committed Overlay no longer matches, which catches a permission renamed without the document following, and a document edited to change what PermDock emitted.
  • One Overlay, many descriptions. A monorepo with several APIs that share one catalog can apply one securitySchemes Overlay to each, which answers the "shared scheme document" question on the adapter page without URI-referenced schemes (a 3.2-only feature).

The Overlay is the delivery mechanism; the semantics of every field it writes are on OpenAPI 3.2 and OpenAPI registries.

How PermDock uses it

Output

permdock openapi emit --doc openapi.json --format overlay --out permdock.overlay.json
{
  "overlay": "1.1.0",
  "info": { "title": "PermDock authorization metadata", "version": "2026-09-06T10:15:00Z" },
  "extends": "./openapi.json",
  "actions": [
    {
      "target": "$.components.securitySchemes",
      "description": "OAuth 2.0 scheme with every catalog scope",
      "update": {
        "permdockOAuth": {
          "type": "oauth2",
          "oauth2MetadataUrl": "https://auth.example.com/.well-known/oauth-authorization-server",
          "x-permdock-securityProfile": "fapi2",
          "flows": {
            "authorizationCode": {
              "authorizationUrl": "https://auth.example.com/authorize",
              "tokenUrl": "https://auth.example.com/token",
              "scopes": {
                "post:read": "Read a post",
                "post:update": "Edit a post you own",
                "post:delete": "Delete a post you own"
              }
            }
          }
        }
      }
    },
    {
      "target": "$.paths.*[?@.operationId == 'deletePost']",
      "description": "post.delete",
      "update": {
        "security": [{ "permdockOAuth": ["post:delete"] }],
        "x-permdock-permissions": ["post.delete"],
        "x-permdock-conditions": {
          "post.delete": { "op": "eq", "field": "authorId", "value": { "ref": "subject.id" } }
        },
        "x-permdock-approval": { "post.delete": { "reason": "human" } }
      }
    },
    {
      "target": "$.paths.*[?@.operationId == 'updatePost']",
      "description": "post.update",
      "update": {
        "security": [{ "permdockOAuth": ["post:update"] }],
        "x-permdock-permissions": ["post.update"]
      }
    },
    {
      "target": "$",
      "description": "Catalog fingerprint for permdock openapi --check",
      "update": { "x-permdock-catalog": { "v": 1, "generator": "@permdock/cli", "catalog": "sha256:..." } }
    }
  ]
}

Rules the emitter follows:

  • Operations are targeted by operationId, never by path and method, so the Overlay survives a route being moved. Operations without an operationId are reported as findings (exit 1) rather than targeted by position.
  • Which operations appear comes from the same source as the document form: x-permdock-permissions already present in the source (written by the framework hooks), or the route registry when the CLI runs inside the project.
  • Actions are sorted (securitySchemes, then operations by operationId, then root) and the info.version is derived from the catalog, so two runs over the same inputs produce byte-identical output.
  • The Overlay never contains a remove action on security or securitySchemes, and never an update that sets security to [] unless the permission is explicitly public in the catalog. Loosening a document's security is not something PermDock does on anyone's behalf.
  • --target 3.1 swaps the 3.2 fields for the registered fallbacks (x-oai-deviceAuthorization, x-oai-deviceAuthorizationUrl, x-oai-deprecated) and x-permdock-oauth2MetadataUrl, exactly as in document mode.

Overlay 1.2 output

--overlay 1.2 (adapter: overlay({ version: '1.2' })) emits the Overlay 1.2 draft at the pinned v1.2-dev commit instead of 1.1.0. It is the build posture of ADR 0025 applied to a document format rather than to a field: PermDock needs it in Phase 2 because the 1.1 form repeats the same update body once per operation, the draft's shape is small enough to write fixtures against, and the stable twin is the 1.1 form itself, one flag away and carrying exactly the same information. 1.1 stays the default until 1.2.0 is released and the appliers in the table below accept it.

The 1.2 form differs from the 1.1 form in exactly one way: every operation update body becomes a reusable action, keyed by the permission keys it grants, and the operation entries become references to it. Operations that grant the same permissions share one reusable action; the securitySchemes, securityProfileRequirements and root catalog actions are unchanged because each has one target.

{
  "overlay": "1.2.0",
  "info": { "title": "PermDock authorization metadata", "version": "2026-09-06T10:15:00Z" },
  "extends": "./openapi.json",
  "components": {
    "actions": {
      "post.delete": {
        "description": "post.delete",
        "fields": {
          "update": {
            "security": [{ "permdockOAuth": ["post:delete"] }],
            "x-permdock-permissions": ["post.delete"],
            "x-permdock-conditions": {
              "post.delete": { "op": "eq", "field": "authorId", "value": { "ref": "subject.id" } }
            },
            "x-permdock-approval": { "post.delete": { "reason": "human" } }
          }
        }
      },
      "post.update": {
        "description": "post.update",
        "fields": {
          "update": {
            "security": [{ "permdockOAuth": ["post:update"] }],
            "x-permdock-permissions": ["post.update"]
          }
        }
      }
    }
  },
  "actions": [
    {
      "target": "$.components.securitySchemes",
      "description": "OAuth 2.0 scheme with every catalog scope",
      "update": { "permdockOAuth": { "type": "oauth2", "flows": { "...": "as in the 1.1 example" } } }
    },
    { "$ref": "#/components/actions/post.delete", "target": "$.paths.*[?@.operationId == 'deletePost']", "description": "deletePost" },
    { "$ref": "#/components/actions/post.update", "target": "$.paths.*[?@.operationId == 'updatePost']", "description": "updatePost" },
    { "$ref": "#/components/actions/post.update", "target": "$.paths.*[?@.operationId == 'patchPost']", "description": "patchPost" },
    {
      "target": "$",
      "description": "Catalog fingerprint for permdock openapi --check",
      "update": {
        "x-permdock-catalog": {
          "v": 1,
          "generator": "@permdock/cli",
          "catalog": "sha256:...",
          "drafts": { "overlay": "1.2-dev@<commit>" }
        }
      }
    }
  ]
}

Rules specific to 1.2:

  • The reusable-action key is the sorted, comma-joined list of permission keys the operation grants (post.delete; post.read,post.update for an operation with two). Keys are JSON Pointer tokens, so ~ and / are escaped as ~0 and ~1 in the $ref, per RFC 6901; permission keys are resource and action identifiers joined by dots (naming), so in practice the key appears verbatim.
  • components.actions is sorted by key, references keep the same operationId order as 1.1 output, and each reference's description is the operationId so a reviewer can still read the list of covered operations without resolving the pointers. Output is byte-identical across runs, and --check compares a committed 1.2 Overlay against a regenerated 1.2 Overlay, never across versions.
  • Only target and description appear on a reference; update, remove and copy are never written on one, matching the draft's rule. A committed Overlay with a remove anywhere on security, securitySchemes or securityProfileRequirements, inline or inside components.actions, still fails --check.
  • The pin drafts.overlay names the v1.2-dev commit the shape was checked against, in the root x-permdock-catalog action. --check fails and permdock doctor (PD012) warns when a committed 1.2 Overlay carries a different pin; the fix is to regenerate. 1.1 output carries no drafts entry for the Overlay.
  • The 1.2 form validates against the schemas/v1.2-dev JSON Schema at the pinned commit; redocly lint and the shipped ruleset accept both overlay: 1.1.0 and overlay: 1.2.0.
  • Release switch. When 1.2.0 is published, --overlay 1.2 emits the released shape in the next PermDock minor, drafts.overlay disappears, and any difference between draft and release is absorbed by the emitter (the importer reads nothing back from an Overlay, so there is no dual-read window). The default moves from 1.1 to 1.2 no earlier than one further minor and only once next-openapi-gen, Redocly CLI, Bump.sh, Speakeasy and overlays-js all accept components.actions. If reusable actions are dropped before release, --overlay 1.2 emits the 1.1 shape with overlay: 1.2.0 and this page records that.

Applying

Any Overlay 1.x applier can consume the file: it takes the OpenAPI description (from extends or a command-line argument) and the Overlay and writes the combined description. Run it as a build step and serve or publish the result; the source description in the repository stays unchanged. PermDock does not ship its own applier in Phase 2; the document form covers projects that do not want one.

ApplierWhere it runsNotes
next-openapi-gen overlay.applyInside openapi-gen generate, for Next.js, TanStack Start, React Router, Remix, SvelteKit, Nuxt, Astro, Hono and Express projectsApplied before the spec is written, so the Scalar UI it scaffolds and the Arazzo files it compiles see the applied description. Accepts Overlay 1.0, 1.1 and 1.2. The Next.js recipe
Redocly CLI join --overlayLint, bundle and generate-client pipelinesExperimental flag; redocly lint permdock.overlay.json validates the Overlay itself
Scalar CLIDocs and registry pipelinesOverlay support is on Scalar's roadmap; apply with one of the above before scalar registry publish

Whichever applier runs, the producer must not also write security on the operations PermDock covers (next-openapi-gen @auth and authPresets, for example). One owner per field: PermDock owns security, the producer owns paths and schemas. --check reports operations whose source already carries security the Overlay would replace.

operationId is the join key. Producers generate it, the Overlay targets it, Arazzo steps reference it. A producer configured to omit operationIds cannot be overlaid; --check fails on the first operation without one rather than falling back to path-and-method targeting.

Which Overlay version to emit. An applier that does not know components.actions rejects a 1.2 Overlay at overlay: 1.2.0 or, worse, ignores the references. Keep the default 1.1 for Redocly CLI, Bump.sh, Speakeasy and overlays-js pipelines until their release notes name Overlay 1.2; next-openapi-gen documents 1.0 to 1.2 and is the first applier the 1.2 form is tested against. The version is a per-pipeline choice; a monorepo can commit both files from one catalog.

CI recipe

# .github/workflows/permissions.yml
- run: pnpm exec permdock collect --check
- run: pnpm exec permdock openapi emit --doc openapi.json --format overlay --out permdock.overlay.json --check
# apply permdock.overlay.json to openapi.json with your Overlay applier, then publish the result

--check regenerates the Overlay in memory and compares it byte-for-byte with the committed file. It fails on a renamed or removed permission, a new protected route with no entry, a changed operationId, and any hand edit to the committed Overlay, including one that adds a remove action.

Threat: an Overlay that removes security

An Overlay is code that rewrites an API description, and remove is a legitimate action in the specification. An attacker who can modify the Overlay in a pipeline, or substitute their own, can strip security from every operation in the published description:

{ "target": "$.paths.*.*.security", "remove": true }

The published description would then claim the API is public. Enforcement is unaffected (PermDock's protect does not read the document), but clients, gateways and agents that trust the description would attempt unauthenticated calls, and a gateway that derives its policy from the description would stop enforcing.

Mitigations:

  • PermDock's Overlay never contains remove on security or securitySchemes, so the presence of one is a signal, not a configuration choice.
  • permdock openapi emit --format overlay --check fails on any difference from the regenerated Overlay, including an added remove. Run it in the same job that applies the Overlay.
  • Treat the Overlay like any other build input: commit it, review it, and do not fetch it from a mutable URL at build time.
  • Keep enforcement independent of the document. PermDock's protect guards routes from the policy, not from the OpenAPI description, so a tampered description cannot open a route (threat model).

Mapping table

Overlay conceptPermDock concept
extends--doc
actions[].target on $.components.securitySchemessecuritySchemes() from the adapter, every catalog scope
actions[].target by operationIdOne protected route; describe(permission) output
update.securitysecurity(permission)
update.x-permdock-*Permission keys, portable conditions, approval metadata, security profile
removeNever emitted for security fields
copy (1.1)Not used
components.actions and $ref references (1.2 draft, --overlay 1.2)One reusable action per distinct set of granted permission keys; one reference per operation
Deterministic output--check in CI

Sources

Open questions

  • Whether to target the operation object (as above) or the security array itself, given Overlay's merge semantics for arrays; the operation object keeps one action per route but relies on the applier replacing security rather than appending to it.
  • Whether PermDock should ship a minimal applier in @permdock/cli (permdock openapi apply) so --check can also validate the applied result, not only the Overlay.
  • Signing the Overlay (a detached signature checked before applying) versus relying on repository review and --check.

On this page