PlatformEngr® Backstage plugins course

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 platform may 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.

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.ts
yarn 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

  1. 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
  2. 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
  3. 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
  1. The backend checks them and the frontend asks about them, so both import them One definition, used on both sides.
  2. Only if the backend does not check: the button is a hint, the backend decides Only the backend check protects anything.
  3. 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.

Report a problem with this module