Install
$ agentstack add skill-bendaamerahmed-backstage-idp-plugin-backstage-permissions ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
Backstage permissions
Add real authorization to a Backstage instance: permission definitions, a policy that decides, and backend enforcement points that obey. The UI never enforces anything.
Preconditions
- Repo uses the new backend system (
createBackend,backend.add()). Detect first;
the legacy backend wiring for permissions is different and unsupported on current lines.
- Frontend generation known (NFS:
createAppfrom@backstage/frontend-defaults,
createFrontendPlugin, blueprints, /alpha exports — vs legacy createPlugin, ``). It changes where UI checks are placed, not whether they matter.
backstage.jsonrelease line known; permission APIs moved (see step 5) and the
installed packages are the source of truth, not memory.
- You know which principal the endpoint serves: end users, service-to-service, or both.
- Assume
yarnfrom the repo root unless the repo says otherwise.
Procedure
- Survey what exists. Grep for
permission:inapp-config*.yaml,
@backstage/plugin-permission-backend and @backstage/plugin-permission-backend-module-allow-all-policy in packages/backend/src/index.ts, and existing PermissionPolicy implementations under packages/backend/src/extensions/. See backstage-repo-discovery.
- Enable the framework. Set
permission.enabled: trueinapp-config.yamland
register backend.add(import('@backstage/plugin-permission-backend')). With enabled: false the framework short-circuits to ALLOW and your policy is never called — every check you write is dead code until this flag is on.
- Define permissions in the plugin's
-commonpackage, never the backend package —
the frontend imports them too. Use createPermission from @backstage/plugin-permission-common: a unique dotted name (..), attributes: { action: 'create' | 'read' | 'update' | 'delete' }, and export a Permissions array so integrators can enumerate them.
- Choose basic vs resource. A basic permission asks "may this user do X at all"
(creation, where no resource exists yet). A resource permission adds resourceType and can be answered per-object. Resource type strings are global across the instance — namespace them.
- Register with the permissions registry. In the plugin's
register/init, take
coreServices.permissionsRegistry and call addPermissions([...]). For resource permissions call addResourceType({ resourceRef, permissions, rules, getResources }), where resourceRef comes from createPermissionResourceRef and getResources maps refs to objects (returning undefined for missing ones). This service replaced createPermissionIntegrationRouter; if the repo still uses that function, migrate it. Read the installed @backstage/backend-plugin-api types for the exact shapes before writing.
- Enforce in every backend route that mutates or exposes protected data.
Get credentials with httpAuth.credentials(req, { allow: ['user'] }), then permissions.authorize([{ permission, resourceRef }], { credentials }), and throw NotAllowedError from @backstage/errors on AuthorizeResult.DENY. One check per route, at the top, before any read or write.
- For lists and paginated reads, use
authorizeConditional. On
AuthorizeResult.CONDITIONAL, convert the returned conditions into your storage filter with createConditionTransformer(permissionsRegistry.getPermissionRuleset(resourceRef)) and push the filter into the query. Never fetch everything and filter in memory.
- Write the policy as a backend module. `createBackendModule({ pluginId: 'permission',
moduleId: 'permission-policy' }), depend on policyExtensionPoint from @backstage/plugin-permission-node/alpha, and call policy.setPolicy(new YourPolicy()) in init. Implement PermissionPolicy.handle(request, user): narrow with isPermission(request.permission, somePermission) for one permission, or isResourcePermission(request.permission, 'catalog-entity') to cover a whole family. Make the catch-all return` explicit and deliberate — that single line is your instance's default posture.
- Return conditional decisions for ownership. For the catalog, use
createCatalogConditionalDecision(request.permission, catalogConditions.isEntityOwner({ claims: user?.info.ownershipEntityRefs ?? [] })) — conditions from @backstage/plugin-catalog-backend/alpha, permissions from @backstage/plugin-catalog-common/alpha. Conditional decisions are only valid for resource permissions; returning one for a create-style permission is an error.
- Add custom rules only when no existing rule fits.
createPermissionRulefrom
@backstage/plugin-permission-node with name, description, resourceRef, a zod paramsSchema, apply (in-memory predicate) and toQuery (storage filter). apply and toQuery must express the same predicate or conditional reads and single-resource checks will disagree. Wrap with createConditionFactory for use in policies and register via permissionsRegistry.addPermissionRules in a backend module.
- Reflect, do not enforce, in the UI.
usePermission({ permission, resourceRef })
from @backstage/plugin-permission-react returns { loading, allowed }; use it to disable or hide controls. RequirePermission wraps a route element. On NFS, wrap inside the component supplied to the page extension rather than editing App.tsx routes. Omitting resourceRef for a resource permission yields allowed: false, and results are stale-while-revalidate — never branch on either for security.
- Remove the allow-all module (
@backstage/plugin-permission-backend-module-allow-all-policy)
from packages/backend/src/index.ts once a real policy is registered. Two policy providers is a startup failure, and leaving allow-all wins silently in some orders.
- Test the denied path first. Unit-test the policy by calling
handle()directly
with a constructed permission and user, asserting DENY and asserting the exact conditions object for conditional cases. Route-test with the backend test utils' permissions mock (mockServices.permissions.mock()) with authorize resolved to DENY and assert HTTP 403 — an allow-only test suite proves nothing.
Verification
yarn tscandyarn testclean. Run the plugin's backend tests specifically.yarn startand confirm the backend logs no "policy already set"/duplicate-module error.- Hit the protected route directly with a real user token (
curl -H "Authorization: Bearer "),
once as an allowed user (2xx) and once as a denied user (403 with a NotAllowedError body). Bypassing the UI is the only meaningful proof.
- For conditional reads, assert the filtered list differs between two users, and check
the generated SQL/query count to confirm filtering happened at the data source.
- Temporarily flip
permission.enabled: falseand confirm the denied call now succeeds —
that proves the check is wired to the framework, not to unrelated logic. Flip it back.
Failure modes
permission.enabledunset. Framework returns ALLOW for everything, policy never
runs, usePermission reports allowed. Nothing is broken and nothing is enforced.
- Allow-all policy module still registered. The most common "my policy is ignored".
- Enforcement only on the write path shown in the UI. Bulk endpoints, refresh
endpoints, and search/list routes on the same plugin are usually left open.
- Transitive group membership is not in the claims. Ownership refs resolve from
direct memberOf relations only; a user in team-a does not get org/engineering in ownershipEntityRefs, so parent-group-owned entities appear unowned to them. Fix in the sign-in resolver's ownership resolution, not in the policy — see backstage-auth.
- Entities with no owner. No
spec.ownermeans noownedByrelation, so
isEntityOwner matches nobody and the entity becomes uneditable by anyone. Always pair ownership conditions with an admin-group escape hatch.
- Multiple owners.
relations.ownedByis a list; rules that compare a single
spec.owner string, or that assume the first element, silently deny co-owners.
- Ownership annotations treated as proof.
spec.ownerand annotations come from
catalog-info.yaml in a source repo. Anyone who can merge to that repo can name themselves owner. Ownership is an authorization input only if you also control who can register locations and who can merge — otherwise it is a self-asserted claim. Discuss with backstage-catalog before basing destructive permissions on it.
- Resource permission authorized without
resourceRef. The condition has nothing
to evaluate against; the decision is meaningless even when it returns ALLOW.
apply/toQuerydrift in a custom rule. Single-item checks allow what list
filtering hides, or vice versa. Test both against the same fixture.
- Service-to-service traffic.
allow: ['user']rejects service principals with 401;
allow: ['user', 'service'] lets them through without a user policy decision. Decide per route which principals are acceptable and say so in the PR.
- Policy throws. An exception in
handle()fails the request, usually as a 500 on
every protected route at once. Guard lookups inside the policy.
Do not
- Do not treat
usePermission,RequirePermission, a hidden button, or a disabled
menu item as an access control. They are cosmetics over a public API.
- Do not define permissions in a backend or frontend package —
-commononly. - Do not return a conditional decision for a non-resource permission.
- Do not set
permission.enabled: falseto unblock a failing check. - Do not invent condition names, rule names, or import paths; read the installed
package's /alpha exports and types for the repo's release line.
- Do not ship a policy whose catch-all is ALLOW without stating that choice explicitly
in the change description.
- Do not merge, push, or deploy a policy change without stopping for explicit
authorization — a policy edit changes access for every user at once.
- Do not proceed if the correct default posture (allow-by-default vs deny-by-default)
is undecided; return a BLOCKED report naming the permissions in question.
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: bendaamerahmed
- Source: bendaamerahmed/backstage-idp-plugin
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.