Module 08 · Secure and extend · 60 min · You are: Platform team
Permissions: who may evaluate
In this module
- You learn: How permissions are defined, registered and checked, how one policy decides for the whole portal, and why the frontend check is only for show.
- You do: Add two permissions, check them in the router, replace the allow-all policy with your own, test a denial, and grey out Evaluate now.
- You end with: Only members of
platformmay evaluate on demand; everyone may read scorecards. A test proves the backend says 403. Merged.
Evaluating every entity on demand costs catalog calls and can be abused. More importantly, every company asks "who may do this?" sooner or later. Backstage answers it in one place, the policy, if each plugin defines its permissions and asks before acting.
Open this module in the interactive course to track progress.
Concepts
- Permission
createPermission({ name, attributes })in-common, so the backend checks it and the frontend can ask about it. Plugin authors.- authorize
permissions.authorize([{ permission }], { credentials })returns ALLOW or DENY. The backend enforces it. Permissions service.- Policy
- One class with
handle(request, user)decides for every plugin. It is set by a module on the permission plugin. Writing a policy. - usePermission
- Lets the frontend hide or disable what the backend would refuse. It is a convenience, not a guard.
- Service principals
- Plugins and static tokens are not users. The policy is asked about users; service calls are allowed.
Files to know
Open them in your editor or on GitHub; the bullets say what to look at.
portal/plugins/readiness-common/src/permissions.ts(reference, your repo): The two permissions.- A name the policy matches on L10–13
portal/plugins/readiness-backend/src/router.ts(reference, your repo): Enforced on every route.portal/plugins/permission-backend-module-acme/src/AcmePolicy.ts(reference, your repo): The whole portal's policy.- Only platform may evaluate; everything else is allowed L19–27
Steps
1. Define the permissions
In -common, because both sides use them.
cd ~/work/portal && yarn --cwd plugins/readiness-common add @backstage/plugin-permission-common@^0.9.12
Create ~/work/portal/plugins/readiness-common/src/permissions.ts
import { createPermission } from '@backstage/plugin-permission-common';
/** Read scorecards. @public */
export const readinessScorecardReadPermission = createPermission({
name: 'readiness.scorecard.read',
attributes: { action: 'read' },
});
/** Evaluate an entity now, outside the schedule. @public */
export const readinessEvaluatePermission = createPermission({
name: 'readiness.evaluate',
attributes: { action: 'update' },
});
/** Every permission the readiness plugin checks. @public */
export const readinessPermissions = [
readinessScorecardReadPermission,
readinessEvaluatePermission,
];
Add to ~/work/portal/plugins/readiness-common/src/index.ts after export * from './types';
export * from './permissions';
2. Enforce them in the backend
The router asks before every read and every evaluation. Six places change, so replace the file and compare it with the old one.
cd ~/work/portal && yarn --cwd plugins/readiness-backend add @backstage/plugin-permission-common@^0.9.12
Replace the whole file ~/work/portal/plugins/readiness-backend/src/router.ts
import {
HttpAuthService,
PermissionsService,
} from '@backstage/backend-plugin-api';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors';
import {
AuthorizeResult,
BasicPermission,
} from '@backstage/plugin-permission-common';
import {
ReadinessCheck,
readinessEvaluatePermission,
readinessScorecardReadPermission,
} from '@internal/backstage-plugin-readiness-common';
import express from 'express';
import Router from 'express-promise-router';
import { z } from 'zod/v3';
import { ReadinessEvaluator } from './ReadinessEvaluator';
import { ReadinessStore } from './ReadinessStore';
export async function createRouter(options: {
httpAuth: HttpAuthService;
permissions: PermissionsService;
store: ReadinessStore;
evaluator: ReadinessEvaluator;
checks: ReadinessCheck[];
}): Promise<express.Router> {
const { httpAuth, permissions, store, evaluator, checks } = options;
const router = Router();
router.use(express.json());
// Who is calling, and may they do this? Throws 403 if not.
async function authorize(req: express.Request, permission: BasicPermission) {
const credentials = await httpAuth.credentials(req);
const [decision] = await permissions.authorize([{ permission }], {
credentials,
});
if (decision.result !== AuthorizeResult.ALLOW) {
throw new NotAllowedError(`Not allowed: ${permission.name}`);
}
return credentials;
}
router.get('/checks', async (_req, res) => {
res.json({ items: checks.map(({ id, title }) => ({ id, title })) });
});
router.get('/scorecards', async (req, res) => {
await authorize(req, readinessScorecardReadPermission);
res.json({ items: await store.list() });
});
router.get('/scorecards/:kind/:namespace/:name', async (req, res) => {
await authorize(req, readinessScorecardReadPermission);
const entityRef = stringifyEntityRef(req.params);
const card = await store.get(entityRef);
if (!card) {
throw new NotFoundError(`No scorecard for ${entityRef} yet`);
}
res.json(card);
});
const evaluateSchema = z.object({ entityRef: z.string() });
router.post('/evaluate', async (req, res) => {
const credentials = await authorize(req, readinessEvaluatePermission);
const parsed = evaluateSchema.safeParse(req.body);
if (!parsed.success) {
throw new InputError(parsed.error.toString());
}
res.json(
await evaluator.evaluateEntity(parsed.data.entityRef, credentials),
);
});
return router;
}
Change in ~/work/portal/plugins/readiness-backend/src/plugin.ts
- import { ReadinessCheck } from '@internal/backstage-plugin-readiness-common';
+ import {
+ ReadinessCheck,
+ readinessPermissions,
+ } from '@internal/backstage-plugin-readiness-common';
Change in ~/work/portal/plugins/readiness-backend/src/plugin.ts
- logger: coreServices.logger,
- scheduler
+ logger: coreServices.logger,
+ permissions: coreServices.permissions,
+ permissionsRegistry: coreServices.permissionsRegistry,
+ scheduler
Change in ~/work/portal/plugins/readiness-backend/src/plugin.ts
- const { scheduler, catalog } = deps;
+ const { permissions, permissionsRegistry, scheduler, catalog } = deps;
+
+ permissionsRegistry.addPermissions(readinessPermissions);
Change in ~/work/portal/plugins/readiness-backend/src/plugin.ts
- httpAuth,
- store,
+ httpAuth,
+ permissions,
+ store,
A test with a policy that always denies:
Change in ~/work/portal/plugins/readiness-backend/src/plugin.test.ts
- import { startTestBackend } from '@backstage/backend-test-utils';
- import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
- import request
+ import { mockServices, startTestBackend } from '@backstage/backend-test-utils';
+ import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
+ import { AuthorizeResult } from '@backstage/plugin-permission-common';
+ import request
Add to ~/work/portal/plugins/readiness-backend/src/plugin.test.ts before the last line, });
it('answers 403 when the policy denies', async () => {
const { server } = await startTestBackend({
features: [
readinessPlugin,
catalogServiceMock.factory({ entities: [checkout] }),
mockServices.permissions.factory({ result: AuthorizeResult.DENY }),
],
});
await request(server)
.post('/api/readiness/evaluate')
.send({ entityRef: 'component:default/checkout' })
.expect(403);
});
cd ~/work/portal && yarn tsc && yarn --cwd plugins/readiness-backend test --watchAll=false
Expect Tests: 3 passed.
3. Write the policy
The first course kept the allow-all policy. Replace it with yours.
cd ~/work/portal && yarn new --select permission-policy-module --option moduleId=acme
cd plugins/permission-backend-module-acme && rm -r src/policy
cd ~/work/portal && yarn --cwd plugins/permission-backend-module-acme add @internal/backstage-plugin-readiness-common@workspace:^
Create ~/work/portal/plugins/permission-backend-module-acme/src/AcmePolicy.ts
import {
AuthorizeResult,
isPermission,
PolicyDecision,
} from '@backstage/plugin-permission-common';
import {
PermissionPolicy,
PolicyQuery,
PolicyQueryUser,
} from '@backstage/plugin-permission-node';
import { readinessEvaluatePermission } from '@internal/backstage-plugin-readiness-common';
/** Owned by the platform team. One place that says who may do what. */
export class AcmePolicy implements PermissionPolicy {
async handle(
request: PolicyQuery,
user?: PolicyQueryUser,
): Promise<PolicyDecision> {
if (isPermission(request.permission, readinessEvaluatePermission)) {
const refs = user?.info.ownershipEntityRefs ?? [];
return {
result: refs.includes('group:default/platform')
? AuthorizeResult.ALLOW
: AuthorizeResult.DENY,
};
}
return { result: AuthorizeResult.ALLOW };
}
}
Replace the whole file ~/work/portal/plugins/permission-backend-module-acme/src/module.ts
import { createBackendModule } from '@backstage/backend-plugin-api';
import { policyExtensionPoint } from '@backstage/plugin-permission-node/alpha';
import { AcmePolicy } from './AcmePolicy';
export const permissionModuleAcme = createBackendModule({
pluginId: 'permission',
moduleId: 'acme',
register({ registerInit }) {
registerInit({
deps: { policy: policyExtensionPoint },
async init({ policy }) {
policy.setPolicy(new AcmePolicy());
},
});
},
});
cp ~/work/backstage-workshop/portal/plugins/permission-backend-module-acme/src/AcmePolicy.test.ts ~/work/portal/plugins/permission-backend-module-acme/src/AcmePolicy.test.ts
Delete from ~/work/portal/packages/backend/src/index.tsyarn new added it at the end; it belongs where allow-all was.
- backend.add(import('@internal/backstage-plugin-permission-backend-module-acme'));
Change in ~/work/portal/packages/backend/src/index.ts
- backend.add(
- import('@backstage/plugin-permission-backend-module-allow-all-policy'),
- );
+ // Our own policy (plugin course, module 08) replaces allow-all.
+ backend.add(
+ import('@internal/backstage-plugin-permission-backend-module-acme'),
+ );
cd ~/work/portal && yarn --cwd packages/backend remove @backstage/plugin-permission-backend-module-allow-all-policy
yarn --cwd plugins/permission-backend-module-acme test --watchAll=false
4. Hide the button
Disable Evaluate now for people the policy would refuse.
cd ~/work/portal && yarn --cwd plugins/readiness add @backstage/plugin-permission-react@^0.5.6
Change in ~/work/portal/plugins/readiness/src/components/ReadinessContent.tsx
- import { useEntity } from '@backstage/plugin-catalog-react';
- import { stringifyEntityRef }
+ import { useEntity } from '@backstage/plugin-catalog-react';
+ import { usePermission } from '@backstage/plugin-permission-react';
+ import { stringifyEntityRef }
Change in ~/work/portal/plugins/readiness/src/components/ReadinessContent.tsx
- import { Button, Container, Flex } from '@backstage/ui';
- import useAsyncFn
+ import { Button, Container, Flex } from '@backstage/ui';
+ import { readinessEvaluatePermission } from '@internal/backstage-plugin-readiness-common';
+ import useAsyncFn
Change in ~/work/portal/plugins/readiness/src/components/ReadinessContent.tsx
- const entityRef = stringifyEntityRef(entity);
-
- const [load
+ const entityRef = stringifyEntityRef(entity);
+ const { allowed } = usePermission({
+ permission: readinessEvaluatePermission,
+ });
+
+ const [load
Change in ~/work/portal/plugins/readiness/src/components/ReadinessContent.tsx
- <Button isDisabled={run.loading}
+ <Button isDisabled={!allowed || run.loading}
cd ~/work/portal && yarn tsc
5. Try it as someone else
Restart the portal. You are in platform, so Evaluate now works. Leave the team for a moment.
Change in ~/work/portal/catalog/org.yaml
Leave platform.
- memberOf: [platform, team-checkout
+ memberOf: [team-checkout
The catalog reads org.yaml again within about two minutes. Then sign out and in (Settings, top right): your ownership is read at sign-in. Evaluate now on checkout is greyed out. Scripts still work, because the static token is a service, not a user:
cd ~/work/portal && set -a && . ./.env && set +a
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" -X POST -H 'Content-Type: application/json' localhost:7007/api/readiness/evaluate -d '{"entityRef":"component:default/checkout"}' | jq .score
Change in ~/work/portal/catalog/org.yaml
Back in. Sign out and in again.
- memberOf: [team-checkout
+ memberOf: [platform, team-checkout
6. Merge it
cd ~/work/portal
Propose it as a pull request. Put it on its own branch and push it:
git switch -c readiness-permissions && git add -A && git commit -qm "Readiness: permissions and the acme policy"
git push -qu origin HEAD
Open the pull request. It prints a link:
gh pr create --fill
Open that link. Files changed shows exactly your diff.
Merge it on GitHub (Squash and merge, then Delete branch), or from the terminal:
gh pr merge --squash --delete-branch
Bring your copy up to date:
git switch -q main && git pull -q
Simplified for the course
The policy checks group membership with one
if. Policies for many plugins use conditional decisions and rules, for example "owners may evaluate their own components". See custom rules.
Break it on purpose: Trust the button
The button is disabled for non-members anyway, so a developer drops the permission check from POST /evaluate to "simplify". In the browser, nothing changes.
You practise: why the backend enforces and the frontend only hides.
1. Break it
Change in ~/work/portal/plugins/readiness-backend/src/router.ts
The simplification.
- const credentials = await authorize(req, readinessEvaluatePermission);
+ const credentials = await httpAuth.credentials(req);
cd ~/work/portal && yarn --cwd plugins/readiness-backend test --watchAll=false
2. What you see
Expected: three passing tests. You get: expected 403 "Forbidden", got 200 "OK" in answers 403 when the policy denies. The UI looks the same.
3. Fix it yourself
Explain who can now evaluate, and put the check back. You are done when the tests pass.
Hint 1
Anyone with a Backstage token can call the API without the UI: curl, a script, another plugin.
The fix
Change in ~/work/portal/plugins/readiness-backend/src/router.ts
- const credentials = await httpAuth.credentials(req);
+ const credentials = await authorize(req, readinessEvaluatePermission);
yarn --cwd plugins/readiness-backend test --watchAll=false
After you fixed it: the five questions
- What failed?
- The backend stopped asking the policy, so every signed-in user could evaluate through the API.
- How was it detected?
- The denial test. The UI hid the hole.
- Who responds?
- The platform team.
- How is it corrected?
- Authorize in the backend, always.
- How is it prevented?
- A test per permission with
mockServices.permissions.factory({ result: AuthorizeResult.DENY }).
Quiz
- Why do the permissions live in
readiness-common?- The backend checks them and the frontend asks about them, so both import them
- Because the permission framework only scans packages with the common role
- So the policy module can change them without a backend release
- The button is disabled for you. Can you still evaluate?
- No: a disabled button means the backend refuses too
- Only if the backend does not check: the button is a hint, the backend decides
- Yes, always, because the frontend check replaces the backend one
- How many permission policies does a backend have?
- One per plugin, each set by that plugin's own module
- One per team, chosen from the user's groups at sign-in
- One for the whole backend, set by a module on the permission plugin
Answers
- The backend checks them and the frontend asks about them, so both import them One definition, used on both sides.
- Only if the backend does not check: the button is a hint, the backend decides Only the backend check protects anything.
- One for the whole backend, set by a module on the permission plugin One policy decides for every plugin, so it is one place to review.
Field note
I ask every plugin author the same question in review: "what happens if someone calls this with curl?" If the answer involves the UI, the permission check is missing.