Declaring Plugin Permissions
ASecurityInteractively authoring an access-manager permission-declaration manifest (permissions.yaml) for the access-manager "inversão de responsabilidade": drives a plugin team through discovering its real Authorize() surface, normalizing every action to the SEMANTIC standard (never HTTP verbs), declaring roles/group grants and the M2M contract, then emits and validates a manifest that matches the lib-auth/v3 auth/declaration schema. Use when a plugin must publish its own permissions at boot (WireFro...
- 215 stars
- 0 votes
- 0 copies
- 5 views
- Added September 6, 2026
Works with
Security analysis
100/100Pro scans all 2 files and shows the line behind each finding
npx -y skills add LerianStudio/ring --skill declaring-plugin-permissions --agent claude-codeAre you the author of Declaring Plugin Permissions?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/lerianstudio-declaring-plugin-permissions)---
name: ring:declaring-plugin-permissions
description: >-
Interactively authoring an access-manager permission-declaration manifest
(permissions.yaml) for the access-manager "inversão de responsabilidade": drives
a plugin team through discovering its real Authorize() surface, normalizing every
action to the SEMANTIC standard (never HTTP verbs), declaring roles/group grants
and the M2M contract, then emits and validates a manifest that matches the
lib-auth/v3 auth/declaration schema. Use when a plugin must publish its own
permissions at boot (WireFromEnv) instead of the access-manager seed owning them,
or when writing/fixing a permissions.yaml. It also bumps the repo's
github-actions-shared-workflows CI pin to the release carrying the
permission-manifest nudge. Skip when the plugin has no auth guards,
or you only need the wiring (see WireFromEnv) and the manifest already exists.
allowed-tools:
- AskUserQuestion
- Read
- Grep
- Glob
- Write
- Edit
- Bash
---
# Declaring Plugin Permissions
Drive a Lerian plugin team through authoring `permissions.yaml` — the client-side,
SEMANTIC declaration the plugin PUBLISHES at boot under the access-manager
inversion. This is an **interactive workflow**: follow the steps in order and use
`AskUserQuestion` to gather every choice. Do not free-hand a manifest.
## Overview
Schema authority: `lib-auth/v3 auth/declaration/manifest.go` (>= `v3.4.0-beta.1`),
mirrored server-side by `plugin-access-manager identity/pkg/model/declaration.go`.
The reconciler validates this exact shape at boot and refuses a bad manifest.
**THE CRITICAL INVARIANT:** every `(resource, action)` pair in the manifest MUST
exactly match a real `AuthClient.Authorize(service, resource, action)` guard in the
plugin (`lib-auth auth/middleware/middleware.go`). If they diverge, authz silently
breaks — the guard demands a permission the manifest never declared. Adopting the
semantic standard therefore means the **route guards AND the manifest move together**.
- CANONICAL model (semantic, complies): `br-sisbajud`
(`internal/auth/declaration/permissions.yaml`).
- ANTI-EXAMPLE (HTTP verbs, do NOT copy): `midaz-fees` — its guards pass
`Authorize("plugin-fees","estimates","post")`. Legacy. Never emit verb actions.
## When to use
- A plugin must publish its own permissions at boot (inversion) — authoring a new
`permissions.yaml`.
- Fixing or migrating a plugin whose guards/manifest use HTTP verbs to the semantic
standard.
## Skip when
- The plugin has no `Authorize(...)` guards / no RBAC surface.
- Only the wiring is needed and the manifest already exists — point to
`authdecl.WireFromEnv` and stop.
## The naming standard (non-negotiable)
The schema doc comment says verbatim: *"The action is SEMANTIC
(create/read/update/delete), never an HTTP verb."*
| HTTP verb (FORBIDDEN) | Semantic action (REQUIRED) |
|-----------------------|----------------------------|
| post | create |
| get | read |
| put / patch | update |
| delete (method) | delete / remove |
Domain verbs are first-class and encouraged where CRUD does not fit:
`rotate`, `trigger`, `justify`, `generate`, `reprocess`, `read_pii`,
`request_read`, `request_write`, `receive`. Keep them; do not force them into CRUD.
**This is REQUIRED — and nothing downstream will hold it for you.** The HTTP-verb
reject was removed from BOTH lib-auth and identity (lib-auth#145 /
plugin-access-manager#310), so a manifest with `action: get` validates, boots and
publishes without complaint — several in production do exactly that. What holds the
standard is review plus the `check-manifest-actions` Makefile guard you add in
Step 9. Only `delete` among HTTP methods is allowed — it is also a valid semantic
action. Never emit `post`/`get`/`put`/`patch` as an `action`.
## Manifest schema (author against THIS)
Top-level YAML: `service` (str, REQUIRED), `version` (int, REQUIRED), `permissions`
(list), `roles` (list), `m2m` (object). All bare-name rules below: **the server
composes the prefix — never pre-prefix.**
| Field | Rule |
|-------|------|
| `service` | REQUIRED, non-empty, no `.`/`..` segment. KEEP any `plugin-` prefix. MUST equal the M2M app slug AND DisplayName (BOLA, enforced at boot). Also the 1st arg of `Authorize`. |
| `version` | REQUIRED int >= 1. ADVISORY — excluded from content hash; bumping alone is a no-op publish. |
| `permissions[].resource` | REQUIRED, **BARE** (server composes `{service}/`). |
| `permissions[].action` | REQUIRED, **SEMANTIC** — never an HTTP verb. |
| `permissions[].effect` | `allow` ONLY. A `deny` is REJECTED — see below. |
| `permissions[].roles` | >= 1 BARE role name, each MUST be declared in `roles:`. |
| `roles[].name` | REQUIRED, BARE. `/` allowed as hierarchy separator (`fees/editor`). |
| `roles[].granted_to` | list of `{ group: <bare-name> }`. **GROUP-ONLY** — there is no `user` grantee. Server composes the `{owner}/` prefix. |
| `m2m.exposed` | bool — this plugin is callable as an M2M target. |
| `m2m.needs` | list of target service slugs this plugin CALLS via M2M (e.g. `midaz`). |
**Why `deny` is refused and not merely discouraged.** The manifest used to accept
it and the reconciler wrote it to Casdoor as a real permission, but no decision
point ever applied it: every evaluator reads an effect other than `allow` as "did
not match" and carries on, authorizing on the first `allow` that does match. There
is no deny-wins pass. So a `deny` was a refusal you could read in the manifest, in
review, and in the stored permission — while the runtime granted. Validation now
refuses it at boot (fail-closed, lib-auth#183) and the declaration PUT answers 422
(plugin-access-manager#448).
Only the `effect` field is constrained. `deny` is still a fine ACTION name:
`br-sfn/services/spb` declares `{ resource: str-emission-approvals, action: deny,
effect: allow }`, because approving or denying an STR emission is that domain's
verb.
Composed names the server builds: permission `{service}/{resource}:{action}`,
role `{service}/{name}`, group `{owner}/{group}`.
### House style: write `permissions:` in FLOW style
The schema is indifferent to YAML style, the org is not. Write each permission as
a single flow-mapping line and align the columns:
```yaml
permissions:
- { resource: account-types, action: read, effect: allow, roles: [editor, contributor, viewer] }
- { resource: account-types, action: create, effect: allow, roles: [editor, contributor] }
- { resource: account-types, action: delete, effect: allow, roles: [editor] }
```
not the five-line block form. This is what the existing manifests do — `midaz`,
`tracer`, `plugin-access-manager`, `streaming-hub`, `reporter`, `lender`,
`br-sisbajud`, `billing-worker` and every `br-sfn/services/*` — and a real
manifest is 20-100 pairs, where flow gives one readable line and one clean diff
hunk per permission instead of five. Keep `roles:` and `m2m:` in block style, as
those same manifests do.
See `template.permissions.yaml` in this folder for a compact, valid, commented example.
---
## Interactive workflow — follow in order
### Step 1 — Determine `service`
Grep the plugin for its product/slug constant before asking:
```bash
grep -rn -iE "ProductName|ApplicationName|ModuleName|feesApplicationName|Slug\s*=" \
--include="*.go" <plugin-root> | head
```
Propose the found constant as the default. Confirm with `AskUserQuestion`, and
**warn**: it MUST equal the M2M app DisplayName and be the first arg of every
`Authorize(...)` call (BOLA). Keep any `plugin-` prefix.
### Step 2 — Discover the REAL authorization surface
Enumerate what the code actually enforces today:
```bash
grep -rn "\.Authorize(" --include="*.go" <plugin-root>
```
Extract each `(service, resource, action)` triple from the guard chains (and route
tables). Present the full list. **This list is ground truth** — the manifest must
cover exactly these pairs (Step 8 re-checks).
### Step 3 — Normalize actions to the SEMANTIC standard
For EACH discovered action:
- If it is an HTTP verb (post/get/put/patch/delete-method) → propose the semantic
equivalent from the table AND **flag that the route guard must change too**
(guard + manifest move together — this is a code change, not just YAML).
- If it is already semantic → keep it.
- If CRUD does not fit → offer the relevant domain verb.
Use `AskUserQuestion` per action/resource to let the engineer confirm or rename,
offering sensible options (e.g. for `delete`: `delete` vs `remove`). Record the
final semantic `(resource, action)` set. If any guard currently passes a verb, list
those guards explicitly as **follow-up code edits the team owns** — do not silently
emit verb actions to make the mismatch "go away".
### Step 4 — Roles and group grants
Ask (via `AskUserQuestion`):
- Which roles exist? Default `viewer` + `editor`; offer domain roles
(operator/investigator/compliance-officer/…) as in `br-sisbajud`.
- Which BARE group(s) grant each role? (Groups are group-only, bare.)
- Grant mapping — offer this DEFAULT, let them override:
**read/query actions → viewer + editor; mutating actions → editor-only.**
Every permission MUST list >= 1 role, and every listed role MUST be declared.
### Step 5 — M2M contract
Ask:
- `exposed`: is this plugin an M2M target (callable by other plugins)?
- `needs`: which services does it CALL via M2M (e.g. `midaz`)? Omit the block if
neither applies.
### Step 6 — Emit `permissions.yaml`
`Write` the manifest to the plugin's declaration dir (mirror `br-sisbajud`:
`internal/auth/declaration/permissions.yaml`). Bare resources/groups/roles,
semantic actions, valid effects, >= 1 role per permission, `permissions:` entries
in FLOW style (see "House style" above). Then show the wiring the
plugin adds (manifest authoring is the focus; this is the glue):
```go
//go:embed permissions.yaml
var Manifest []byte
// ...at startup (authdecl = github.com/LerianStudio/lib-auth/v3/auth/declaration, >= v3.4.0-beta.1):
stop, err := authdecl.WireFromEnv(ctx, authdecl.WireInput{
Slug: <service>, // MUST equal manifest.service (BOLA)
Manifest: Manifest,
Logger: logger,
})
```
Deployment sets the FIXED env contract (default OFF, fail-open). A NEW adopter
creates these vars with the canonical `IDP_` names from the start. The four
RI/D7-declaration vars carry the product-wide `IDP_` prefix (identity provider,
lib-auth #4232 — shared across every plugin, NOT a per-plugin prefix):
`IDP_DECLARATION_ENABLED`, `IDP_HOST`, `IDP_M2M_CLIENT_ID`, `IDP_M2M_CLIENT_SECRET`,
plus the token-minter vars `PLUGIN_AUTH_ENABLED`, `PLUGIN_AUTH_HOST` (out of scope
for #4232, unchanged).
The `IDP_` names require **lib-auth ≥ `v3.4.0-beta.6`** (the release that carries #4232).
This is a LATER threshold than the `>= v3.4.0-beta.1` manifest-schema pin above. For ONE
release after #4232 the old names (`DECLARATION_ENABLED`, `PLUGIN_IDENTITY_HOST`,
`M2M_CLIENT_ID`, `M2M_CLIENT_SECRET`) still work as deprecated aliases (canonical
`IDP_` wins; `WireFromEnv` WARNs when only the alias is set), so a plugin pinned to
an older lib-auth keeps booting — migrate to the `IDP_` names before the following
release drops the aliases.
### Step 7 — Validate
Run structural checks against every rule below, and if a Go toolchain + lib-auth
(>= v3.4.0-beta.1) are available, verify against the REAL validator (parse+Validate,
zero network) with a tiny throwaway program:
```go
// authdecl "github.com/LerianStudio/lib-auth/v3/auth/declaration"
// _, err := authdecl.New(authdecl.Config{Slug: "<service>", Manifest: raw, /* IdentityAddr, ClientID/Secret dummy */})
// New parses + Validate()s the manifest eagerly and enforces slug==service (BOLA); a
// non-manifest error means the manifest itself is structurally valid.
```
Or a YAML lint + this checklist. **Validation rules (all aggregated at boot):**
- `service` non-empty and not `.`/`..`.
- `version` >= 1.
- each `action` is non-empty. The SEMANTIC standard above is a CONVENTION, not
a boot check: the HTTP-verb reject was removed from both lib-auth and identity
(lib-auth#145 / plugin-access-manager#310), so a manifest with `action: get`
publishes without complaint. Hold the standard in review and with the Makefile
guard below — nothing downstream will hold it for you.
- each permission: non-empty `resource` and `action`; `effect` is `allow`;
>= 1 role; every role reference is a DECLARED role.
- no duplicate composed permission `{service}/{resource}:{action}`.
- no duplicate composed role `{service}/{name}`.
- no Casdoor-safe-name collision: chars `/ ? : # & % = + ;` and whitespace collapse
to `-` (lossy), so two different names can collide — and a name that derives to
empty is rejected.
### Step 8 — Alignment gate (BLOCKING)
Re-confirm every declared `(resource, action)` maps to a real `Authorize(...)` call
(Step 2 list) and vice-versa. Enumerate ANY mismatch as blocking:
- guard exists, manifest missing → add the permission.
- manifest declares a pair no guard uses → remove it or add the guard.
- guard still passes an HTTP verb → the team MUST update the guard to the semantic
action so both sides use it (do not "fix" it by declaring the verb).
Do not consider the manifest done while any mismatch remains.
### Step 9 — Scaffold the durable CI guard (Makefile)
The alignment gate above is a one-time check. Lock the semantic standard in so a
future edit that reintroduces an HTTP verb FAILS the build. For the MANIFEST's
action names there is exactly ONE automated layer, and it is the one you add
here. Guard alignment is NOT automated at all: the target below reads
`$(MANIFEST)` and nothing else, so a guard still passing `get` against a manifest
declaring `read` is caught by Steps 3 and 8 and by review — nowhere else.
- **Boot-time (lib-auth): none.** The validator used to reject
`post`/`get`/`put`/`patch`, and that reject was removed from lib-auth and
identity (lib-auth#145 / plugin-access-manager#310). A manifest with a verb
action starts and publishes fine — nothing catches the regression at runtime.
- **CI (Makefile):** the `check-manifest-actions` guard below. Cheap, lives in the
plugin's own repo, and is the only thing that fails a build on a verb
reintroduced in the manifest.
Check the plugin's Makefile for the existing `check-*` convention (most Lerian
plugins wire `check-tests`, `check-migrations`, … into a `ci:`/`check` aggregate —
grep `^check-` and `^ci:`). Add a `check-manifest-actions` target matching that
idiom and wire it into the aggregate:
```makefile
MANIFEST ?= internal/auth/declaration/permissions.yaml
.PHONY: check-manifest-actions
# Fail if the manifest is missing/unreadable, or if it uses HTTP-verb actions.
# 'delete' is allowed (also a valid semantic action). This is the ONLY automated
# check for the semantic standard — lib-auth no longer rejects verbs at boot. It
# reads the manifest only: a mismatched Authorize() guard is Step 8's job.
# The pattern matches BOTH styles: flow `- { ..., action: post, ... }` (the house
# style) and block `action: post` on its own line, with or without quotes and with
# or without a trailing `# comment`. It deliberately does NOT match a `#` comment
# line that merely mentions an action, and does not fire on a resource or a longer
# action that starts with a verb (`repost`, `getaway`, `read_pii`).
check-manifest-actions:
@test -r "$(MANIFEST)" || { echo "ERROR: manifest not found or unreadable: $(MANIFEST)"; exit 1; }
@echo "Checking manifest actions are semantic (not HTTP verbs)..."
@if grep -inE '^([[:space:]]*-[[:space:]]*\{(.*[{,])?)?[[:space:]]*action:[[:space:]]*["'\'']?(post|get|put|patch)["'\'']?[[:space:]]*([,}#]|$$)' "$(MANIFEST)"; then \
echo "ERROR: HTTP-verb action in $(MANIFEST) — use a SEMANTIC action (create/read/update/delete or a domain verb). 'delete' is allowed."; \
exit 1; \
fi
@echo "OK: manifest actions are semantic."
```
Add `check-manifest-actions` to the `ci:`/`check:` prerequisite list (and `.PHONY`).
If the plugin has no `check-*`/`ci` idiom, still add the target and call it where
tests run. Confirm it FAILS on a seeded `action: post` — seeded in the SAME style
the manifest uses — and PASSES on the real manifest before finishing.
### Step 10 — Bump the shared-workflows CI pin
The org shared CI (`LerianStudio/github-actions-shared-workflows`, reusable
`go-pr-validation.yml`) now carries a NON-BLOCKING `permission-manifest-nudge` that
reminds any lib-auth repo still missing a `permissions.yaml`. You are already
touching this repo — bump its pin so the pipeline is current.
- Find the consumer pins: `grep -rn 'LerianStudio/github-actions-shared-workflows' .github/workflows`.
Expect exact-tag `uses: …@vX.Y.Z` on `go-pr-validation.yml` / `go-release.yml` /
`routine.yml`. Leave any `…@v1` major-float pins as-is.
- Resolve the latest release tag:
`gh release view --repo LerianStudio/github-actions-shared-workflows --json tagName -q .tagName`
(or `gh api repos/LerianStudio/github-actions-shared-workflows/releases/latest -q .tag_name`).
It must be `>=` the release that introduced `permission-manifest-nudge`.
- Bump EVERY exact-tag shared-workflows pin in `.github/workflows/*.yml` to that tag,
keeping all of them on the SAME version. Do not touch unrelated `uses:` lines.
This is hygiene, not a gate: for THIS repo — which now declares a manifest — the
nudge reports "compliant" and posts nothing. The bump only keeps the shared pipeline
current. Confirm the target tag exists before writing, and preserve the pin format.
---
## Red Flags — STOP
- An `action` is `post`/`get`/`put`/`patch`/`delete`-the-method → it is an HTTP verb.
- A `resource`, `role`, or `group` carries a `{service}/` or `{owner}/` prefix → it
will double-prefix; write it BARE.
- A `granted_to` entry has a `user:` key → there is no user grantee; groups only.
- A permission lists zero roles, or a role not in `roles:` → validation fails.
- You are emitting a verb action to sidestep a guard mismatch → fix the guard instead.
- `service` differs from the M2M app DisplayName / the `Authorize` 1st arg → BOLA break.
All of these mean: **stop and correct before writing/finishing the manifest.**
## Anti-Rationalization
| Rationalization | Why it's WRONG | Required action |
|-----------------|----------------|-----------------|
| "The guard passes `post`, so I'll declare `post` to match." | Freezes the legacy anti-pattern; the standard is semantic on BOTH sides. | Rename guard AND manifest to the semantic action together. |
| "I'll pre-prefix the resource with the service to be safe." | Server composes the prefix; you get `{service}/{service}/…`. | Write resources/roles/groups BARE. |
| "A user grantee would be convenient here." | The schema has no user grantee. | Use a group; grant the group to the role. |
| "Version bump publishes the new content." | Version is excluded from the content hash — bump alone is a no-op. | Change the actual permissions/roles content. |
| "The manifest is valid, so we're done." | Structural validity ≠ alignment with real guards. | Pass Step 8; every pair must map to an `Authorize` call. |
Files in this skill
- SKILL.md
- template.permissions.yaml
Attribution
Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.