PermDock
CLI

openapi

Emit security and securitySchemes into an existing OpenAPI document (or as an Overlay) from the permission catalog, or import a document into a generated definition.

permdock openapi works in both directions between a permission catalog and an OpenAPI document. It targets OpenAPI 3.2 by default, can write 3.1 documents using the registered x-oai-* fallbacks plus x-permdock-oauth2MetadataUrl, and can write experimental 3.3 documents that carry the pinned OpenAPI 3.3 Security Profile draft. The runtime hooks that attach security while a framework generates its document live in permdock/openapi (OpenAPI adapter); the CLI is for documents that already exist as files.

Flags

FlagValuesDefaultApplies toPurpose
--doc <path or URL>file path, http(s) URLrequiredemit, importThe OpenAPI document to read
--out <path>file path--doc for emit; required for importemit, importWhere to write the result
--from <module>module pathpolicy from the configemitModule exporting the policy, instead of the one the config names
--target3.1, 3.2, 3.33.2emitOpenAPI version of the output. 3.1 writes the fallback extensions; 3.3 is experimental: it writes the 3.2 shape plus the pinned Security Profile draft (type: profile scheme, securityProfileRequirements) and records the pin in x-permdock-catalog.drafts (OpenAPI 3.3)
--formatdocument, overlaydocumentemitWrite the mutated document, or an Overlay that describes the changes without touching the source
--overlay1.1, 1.21.1emit (--format overlay only)Overlay specification version of the output. 1.1 writes Overlay 1.1.0; 1.2 is experimental: it writes the pinned Overlay 1.2 draft with one reusable action under components.actions per permission set and $ref references per operation, and records the pin in x-permdock-catalog.drafts.overlay (OpenAPI Overlay)
--checkflagoffemitDo not write; exit 1 when the output would differ from what is on disk (drift), including a drafts pin older than the one this CLI emits
--profilefapi2noneemitDeclare a security profile: writes x-permdock-securityProfile on every target, plus the native profile scheme and requirements on 3.3, and validates the scheme against the profile's resource-server rules (FAPI 2.0)
--profile-scheme <name>scheme namepermdock<Profile> (permdockFapi2)emit (--target 3.3 only)Name of the type: profile scheme under components.securitySchemes
--scheme <name>scheme namepermdockOAuthemitName of the oauth2 scheme under components.securitySchemes
--metadata-url <URL> or <name>=<URL> (repeatable)URLnoneemitValue for oauth2MetadataUrl (3.2, 3.3) or x-permdock-oauth2MetadataUrl (3.1); on 3.3 each entry also becomes a profileMetadata.servers item
--arityflagoffemit (--format document)Add x-permdock-arity to every covered operation: { kind: 'collection' }, or { kind: 'instance', parameter } with the path parameter that carries the resource id
--device-flowflagoffemitAdd a deviceAuthorization flow (3.2, 3.3) or x-oai-deviceAuthorization with x-oai-deviceAuthorizationUrl (3.1)
--authorization-url <URL>, --token-url <URL>URLthe document's own valuesemitauthorizationUrl and tokenUrl of the authorizationCode flow (and tokenUrl of the device flow) when the document does not already declare the scheme's flows
--device-authorization-url <URL>URLthe document's own valueemit (--device-flow)deviceAuthorizationUrl of the device flow
--schemazod, valibot, arktypenoneimportValidator used for generated resource schemas; without it resources are schema-less
--map <file>JSON filenoneimport{ "METHOD /path": "resource.action" }: names the permission for an operation, overriding scopes, x-permdock-permissions and the inferred name
--annotateflagoffimportWrite the inferred keys back into the local --doc as x-permdock-permissions on every operation; JSON is rewritten with two-space indent, YAML keeps its comments and layout

--cwd, --config and --json are shared by every command (CLI). Exit codes follow the shared contract: 0 clean, 1 drift or findings, 2 usage error (for example --target 3.0, or --profile fapi2 on a document whose scheme is not oauth2 or openIdConnect).

Emit

permdock openapi emit --doc openapi.json                                  # 3.2 document, in place
permdock openapi emit --doc openapi.yaml --target 3.1 --out openapi.3-1.yaml
permdock openapi emit --doc openapi.json --format overlay --out permdock.overlay.json
permdock openapi emit --doc openapi.json --profile fapi2 --metadata-url https://auth.example.com/.well-known/oauth-authorization-server
permdock openapi emit --doc openapi.json --target 3.3 --profile fapi2 --out openapi.3-3.json   # experimental: pinned 3.3 Security Profile draft
permdock openapi emit --doc openapi.json --check                          # exit 1 if the document would change

emit reads the catalog (or the definition module) and the document, then:

  1. Adds or updates components.securitySchemes.<scheme> as an oauth2 scheme whose scopes are every permission scope in the catalog, with meta.description as the scope description. On 3.2 and 3.3 it sets oauth2MetadataUrl and, when --device-flow is passed, a deviceAuthorization flow. On 3.1 the device flow is written as x-oai-deviceAuthorization with x-oai-deviceAuthorizationUrl, both registered in the OAI Extension Registry, and the metadata URL as x-permdock-oauth2MetadataUrl; no x-oai-* extension is registered for oauth2MetadataUrl, so PermDock uses its own namespace rather than inventing one (OpenAPI registries).
  2. For every operation that declares x-permdock-permissions (an array of permission keys, written by the runtime hooks or by hand), adds a security entry requiring the matching scopes and validates that each key exists in the catalog. Unknown keys are errors. Permissions with a portable condition also receive x-permdock-conditions; permissions whose grants carry approval receive x-permdock-approval. Per-step permissions for Arazzo workflows are checked by permdock arazzo check, not by permdock openapi.
  3. Keeps a deprecated (3.2, 3.3) or x-oai-deprecated (3.1) the scheme already carries. OpenAPI has no per-scope deprecation, so a permission's meta.deprecated does not change the document.
  4. With --profile fapi2, writes x-permdock-securityProfile: "fapi2" on the scheme and on every covered operation, and refuses (exit 2) a scheme that accepts tokens anywhere but the HTTP header, since FAPI 2.0 forbids query-parameter tokens for resource servers. On --target 3.3 it additionally writes a type: profile scheme (profileMetadata.name: fapi-20-security-profile, supportedParametersSchema, servers from the metadata URLs) and one components.securityProfileRequirements entry per distinct scope set, in the shape of the pinned draft, sets openapi: 3.3.0 and validates the result against the pinned patch of the 3.2 schema; the extension stays as the twin (OpenAPI 3.3).
  5. Keeps what the document already says about the scheme: flow URLs, flows PermDock does not write, description. PermDock owns the scopes.
  6. With --arity, writes x-permdock-arity on every covered operation: instance when any listed permission is an instance action of its resource, else collection; for instance, parameter names the last path template parameter (/posts/{id} gives id), omitted when the path has none.
  7. Writes x-permdock-catalog at document level with the catalog version and generator, so --check can detect drift. On --target 3.3 it adds drafts with the pinned v3.3-dev commit and Security Profile design revision, and on --overlay 1.2 the pinned v1.2-dev commit; --check fails on a document or Overlay whose pins differ from the installed CLI's.

When the source document validates against the official OpenAPI 3.1 or 3.2 JSON Schema, the output must too: emit exits 1 with the schema errors instead of writing a document it made invalid (the usual cause is an oauth2 scheme with no authorizationUrl or tokenUrl, which --authorization-url and --token-url fix). An Overlay 1.1 output is validated against the Overlay 1.1 schema the same way. --target 3.3 and --overlay 1.2 are drafts without a published schema and are not validated.

Everything not owned by PermDock is left untouched; the command is safe to run on documents produced by @hono/zod-openapi, hono-openapi, @orpc/openapi, trpc-to-openapi, @elysia/openapi, @fastify/swagger, @nestjs/swagger, next-openapi-gen or hand-written specs. With --format overlay the source is not modified at all. Operations whose source already carries security that PermDock would replace are reported as findings, so two tools writing security on one operation is caught in CI.

{
  "paths": {
    "/posts/{id}": {
      "delete": {
        "operationId": "deletePost",
        "x-permdock-permissions": ["post.delete"],
        "security": [{ "permdockOAuth": ["post:delete"] }]
      }
    }
  },
  "components": {
    "securitySchemes": {
      "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:delete": "Delete a post you own" }
          }
        }
      }
    }
  }
}

Overlay format

--format overlay writes an Overlay 1.1.0 document instead: one update action for components.securitySchemes, one per operation (targeted by operationId) adding security and the x-permdock-* fields, one for the root x-permdock-catalog and, on --target 3.3, one for components.securityProfileRequirements. --overlay 1.2 writes the same content in the pinned Overlay 1.2 draft shape: the per-operation bodies move into components.actions, one reusable action per distinct set of granted permissions, and each operation becomes a $ref reference with its own target; the pin goes into x-permdock-catalog.drafts.overlay. In either version the Overlay never contains a remove action on security, securitySchemes or securityProfileRequirements, inline or in a reusable action. Both examples and the reasons to prefer this form are on OpenAPI Overlay.

permdock openapi emit --doc openapi.json --format overlay --out permdock.overlay.json
# apply with any Overlay 1.x applier, then serve or publish the result
permdock openapi emit --doc openapi.json --format overlay --overlay 1.2 --out permdock.overlay.json
# experimental: Overlay 1.2 draft; use only with an applier that accepts components.actions (next-openapi-gen today)

Applying the Overlay

PermDock ships no applier; the project's existing tool does it. Two common pipelines:

// next-openapi-gen: openapi-gen.config.ts (Next.js, TanStack Start, React Router, SvelteKit, Nuxt, Astro, Hono, Express)
export default defineConfig({
  openapi: "3.2.0",
  overlay: { apply: ["./permdock.overlay.json"] }, // applied before the spec is written; Scalar and Arazzo see the result
});
# Redocly CLI: lint, bundle and generate-client pipelines
redocly lint permdock.overlay.json                                             # validates the Overlay itself
redocly join openapi.json --overlay permdock.overlay.json -o dist/openapi.json  # applies it
# Bump.sh: apply at deploy time (the GitHub Action takes the same file as its `overlay:` input)
bump overlay openapi.json permdock.overlay.json > dist/openapi.json
bump deploy dist/openapi.json --doc my-api

# Speakeasy: chain PermDock's Overlay with Speakeasy's own in one workflow
speakeasy overlay apply -s openapi.json -o permdock.overlay.json > dist/openapi.json

# Vendor-neutral: the reference JavaScript applier
npx openapi-overlays-js --openapi openapi.json --overlay permdock.overlay.json > dist/openapi.json

The next-openapi-gen option is documented in its Overlay guide; Redocly's join --overlay is marked experimental in its command reference. Bump.sh documents bump overlay and the Action input in its Overlays guide; Speakeasy's applier is part of its overlay workflow; Zuplo applies Overlays before importing a description as routes. Scalar's CLI lists Overlay support as roadmap, so a Scalar pipeline applies the Overlay with one of the above before scalar registry publish. Downstream, point @hey-api/openapi-ts, Orval, Kubb, Scalar, Mintlify, Fern or any docs host at dist/openapi.json; they read standard security and need no PermDock code (OpenAPI ecosystem).

Lint ruleset

The CLI ships a ruleset file for Spectral, Redocly and vacuum alongside the CLI. It encodes the document invariants --check enforces so teams that already lint descriptions see the same findings in their linter: an operation with x-permdock-permissions and no security, an x-oai-* name outside x-oai-deprecated, x-oai-deviceAuthorization and x-oai-deviceAuthorizationUrl, a vendor namespace PermDock does not own, and an Overlay remove targeting security. --check remains the authoritative gate; the ruleset is a convenience.

Import

permdock openapi import --doc https://api.example.com/openapi.json --out src/permissions.generated.ts
permdock openapi import --doc openapi.yaml --out src/permissions.generated.ts --schema zod
permdock openapi import --doc openapi.yaml --out src/permissions.generated.ts --map permdock.map.json --annotate

import needs no policy or config. It reads a JSON or YAML document from a file or an http(s) URL, validates it against the official OpenAPI 3.1 or 3.2 JSON Schema (anything else, including Swagger 2.0 and OpenAPI 3.0, exits 2), and writes a deterministic definePermissions() module with a // @generated by permdock openapi import header. Per operation, the permission key comes from the first of:

  1. The --map entry for METHOD /path.
  2. x-permdock-permissions on the operation, so a document written by emit or the runtime hooks round-trips.
  3. The scopes of its oauth2 or openIdConnect security requirements (the operation's, else the document's), with colons turned into dots (post:delete to post.delete).
  4. A name inferred from the operation: the resource is the first tag, else the first static path segment; the action is the operationId in camel case, else the method and arity (GET on an instance is read, on a collection list; POST on a collection create; PUT and PATCH update; DELETE delete).

Every key is checked: at least two dot-separated identifiers and never __proto__, constructor or prototype. An operation that yields no valid key is a finding (exit 1) naming the operation, fixed with a --map entry.

Arity is instance when the path has a parameter after the resource segment (/posts/{id}), collection otherwise; an action seen on both becomes instance. Each action's meta.inferredFrom records the operations it came from (GET /posts/{id}), so a reviewer can see and override the heuristic; summary, description and tags become meta.title, meta.description and meta.tags, and read-only methods set meta.readOnly. With --schema, the resource schema is the first $ref JSON body among the resource's 200 or 201 responses and request bodies, translated for the portable core (strings, numbers, integers, booleans, string enums, arrays, objects with required); anything else becomes the validator's unknown.

--annotate writes the keys back onto each operation as x-permdock-permissions and changes nothing else, so vendor extensions and comments survive and emit can attach security afterwards.

The generated file merges with hand-written definitions through mergePermissions() like any other feature file (Larger apps). Re-running import rewrites the file; hand edits belong in a sibling definition. permdock rls import writes its module through the same generator.

Why

  • No policy needed. Import is the first step of adopting PermDock on an existing API, when there is no definition to load yet.
  • Validation first. A document the official schema rejects produces keys from whatever happened to parse; failing early keeps the generated module honest. The schemas ship with the CLI, so validation needs no network.
  • --map over flags per operation. A committed mapping file is reviewable and survives regeneration; heuristics stay visible in meta.inferredFrom instead of being hidden in naming rules.

--check in CI

- run: pnpm exec permdock openapi emit --doc openapi.json --check
- run: pnpm exec permdock openapi emit --doc openapi.json --format overlay --out permdock.overlay.json --check

Fails when a permission was added, renamed or deprecated without regenerating the document or Overlay, when an operation references a permission that does not exist, when a committed Overlay differs from what the catalog would produce (including a remove on security that PermDock never emits), or when a --target 3.3 document or --overlay 1.2 Overlay carries a drafts pin the installed CLI does not emit. --check compares an Overlay against a regeneration in the same Overlay version and never writes.

A complete workflow adds two third-party checks behind it. Neither needs PermDock code and there is no first-party action: the two commands above are the whole integration.

name: openapi
on: [pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec permdock collect --check
      - run: pnpm exec permdock openapi emit --doc openapi.json --format overlay --out permdock.overlay.json --check
      - run: npx openapi-overlays-js --openapi openapi.json --overlay permdock.overlay.json > dist/openapi.json
      # Breaking-change diff: a removed or weakened security requirement fails the job
      - run: npx oasdiff breaking origin/main:dist/openapi.json dist/openapi.json --fail-on ERR
      # Runtime parity: every operation with `security` must reject an anonymous call
      - run: pipx run schemathesis run dist/openapi.json --url http://localhost:3000 --checks ignored_auth

oasdiff classifies security removals as breaking; Schemathesis ignored_auth sends each protected operation without credentials and fails on anything other than 401 or 403, which catches a route whose protect was removed after the description was published (OpenAPI ecosystem, testing and diff).

Why

  • --target 3.3 is never chosen from the source document. A 3.3.x source is a draft its author opted into; emitting draft structures because of a version string would turn a pinned, experimental output into a silent default, and a producer upgrading its own version field would change PermDock's output without a flag in the diff. The target is always the flag, and 3.2 stays the default.
  • Overlay 1.2 becomes the default only when it is final. Until then --overlay 1.2 is the experimental form with its pin in x-permdock-catalog.drafts.overlay, and 1.1 is what every applier accepts. The default changes in the release that follows the final 1.2 text, with a changeset.
  • x-permdock-arity is opt-in. Most consumers read security and x-permdock-permissions; arity and the id parameter matter to generated MCP servers and SDKs that need to know whether a call targets one row. Emitting it everywhere would add a field to every operation of every document for the few consumers that use it. The id parameter is inferred from the path rather than configured, because the catalog knows the resource's id field but not what a route calls it.

Last updated on

On this page