TT Lab
Get started
Learn Learning paths Courses

CBA — Backstage Associate

The New Backend Plugin Returns Nothing but 401

Continue in TT Lab

Goal

You bring up a real Backstage backend process inside a VM, and build and attach a backend plugin yourself. You check, through HTTP responses and logs, why a new path returns 401, how to open a public path, and how plugin code uses configuration, other plugins, and extension points.

Why it matters

Most of the time, changing Backstage means adding a plugin or widening an existing one. If every request is 401 after you attach a new backend plugin, it is easy to spend time hunting for a code bug. In reality, the default auth policy is blocking before the router. For the same reason, a plugin needs a token when it calls the catalog, and when you want to change catalog behavior you add a module instead of editing that package.

This lab covers only the backend. Frontend plugins (React and Material UI) require building the app bundle, and this VM does not do that build, so you do not check them directly. You summarize the difference between the two in the report.

The first VM start takes about 4 minutes, and the installation in step 1 takes about 30 seconds from the internet. You write the code not in TypeScript but as a single CommonJS JavaScript file (index.js).

Steps

  1. Install six backend packages at exact versions in /usr/local/cba-plugin.
  2. Bring up a backend that has only the catalog plugin on port 7007.
  3. Attach the oncall plugin and record the result of calling it without authentication (401).
  4. Open only the single path /ping with addAuthPolicy.
  5. Read a configuration value on every request with rootConfig, and record how the response changes when you change the configuration without a restart.
  6. Call the catalog API service-to-service with the auth and discovery services.
  7. Add an entity provider with a catalog module (createBackendModule).
  8. Report which layer the 401 and 404 come from, and the differences between backend and frontend plugins.

Notes

Install the backend ingredients for loading plugins at exact versions

In /usr/local/cba-plugin, create an npm project and install the six packages below at exact versions with no range symbols. The version must also appear in the package.json dependencies without ^. The root disk is small, so keep the npm cache on the scratch disk with npm_config_cache=/usr/local/cba-plugin/.npmcache. @backstage/backend-defaults@0.17.8, @backstage/backend-plugin-api@1.10.0, @backstage/plugin-catalog-backend@3.9.1, @backstage/plugin-catalog-node@2.2.4, better-sqlite3@12.4.1, express@4.22.3.

npm install --save-exact 패키지@버전 ... writes the version into package.json without a range (the placeholders are the package name and the version). better-sqlite3 is a native module, and the latest 13.x has no prebuilt binary for this Node 20, so it tries to compile and fails — that is why you pick a pinned version from the 12.x that backend-defaults requires. When the installation finishes, check the versions with npm ls --depth=0.

Bring up a backend that has only the catalog

Create /usr/local/cba-plugin/app-config.yaml, /usr/local/cba-plugin/catalog/org.yaml, and /usr/local/cba-plugin/index.js, and bring up a backend that has only the catalog plugin on port 7007. Configuration: backend.baseUrl: http://localhost:7007, backend.listen.port: 7007, backend.database as client: better-sqlite3 and connection: ':memory:', catalog.rules as [{allow: [Component, Group, Location]}], and in catalog.locations type: file and target: ./catalog/org.yaml. Put in org.yaml the Group team-payments and the Components payments-api and refund-worker that team owns. index.js adds @backstage/plugin-catalog-backend to createBackend() and starts it. Start the process from /usr/local/cba-plugin with node index.js and send its output to /usr/local/cba-plugin/backend.log. If /.backstage/health/v1/readiness is 200, it is ready.

It must stay alive even if you close the shell, so detach all standard input and output, as in setsid nohup node index.js > backend.log 2>&1 < /dev/null & (if you do not, the command does not finish and hangs). Find the Plugin initialization complete line in the startup log, and also see what comes back when you call /api/catalog/entities without authentication.

Every path of the new plugin is 401

In index.js, create a plugin with pluginId oncall using createBackendPlugin and add it. Register an express router with coreServices.httpRouter and provide two paths: GET /ping (→ {"ok":true}) and GET /roster (the on-call roster JSON). Do not add an auth policy yet. After restarting, save the result of calling http://127.0.0.1:7007/api/oncall/ping without authentication, as the raw curl -s -i output, to /root/cba-plugin/before-policy.txt.

A plugin router is attached under /api/<pluginId>. Look in the documentation's default auth policy for why you get 401 even when the code has no bug. For comparison, also call /api/oncall/없는경로 and /api/없는플러그인/x (the placeholders are a path that does not exist and a plugin that does not exist), and you will see which layer the 401 comes from.

Open just one health-check path without authentication

In the oncall plugin's init, use http.addAuthPolicy to open only the /ping path with allow: 'unauthenticated'. After restarting, without authentication /api/oncall/ping must be 200 {"ok":true} and /api/oncall/roster must still be 401.

The policy's path has the same form as the path you wrote in the router (without the plugin prefix). The setting that opens an entire plugin (backend.auth.dangerouslyDisableDefaultAuthPolicy) makes all plugins unauthenticated, so do not use it.

Read the on-call channel from configuration, not from code

Put oncall.channel: '#payments-oncall' in app-config.yaml, inject coreServices.rootConfig into the oncall plugin, add GET /channel (→ {"channel":"..."}, allowed without authentication), which returns config.getString('oncall.channel') every time a request is received, and then restart. After checking the response, change the value in the configuration file to '#platform-oncall' without restarting, and confirm that the response changes. In /root/cba-plugin/channel.txt, leave two lines, before=<처음 응답의 channel> and after=<바뀐 응답의 channel> (the placeholders are the channel in the first response and the channel in the changed response).

The backend watches the configuration file and re-reads it when it changes (Found 0 new secrets in config is printed in the log again). If the value still does not change, check whether you read it once in init and kept it in a variable. Poll for a few seconds until the response changes.

A plugin needs a token to call the catalog too

Add GET /services (allowed without authentication) to the oncall plugin. This handler gets a token with getPluginRequestToken of coreServices.auth (onBehalfOf is auth.getOwnServiceCredentials() and targetPluginId is catalog), gets the address with getBaseUrl('catalog') of coreServices.discovery, calls /entities?filter=kind=component with an Authorization: Bearer header, and then returns {"catalogStatus": <카탈로그 응답 코드>, "names": [Component 이름 정렬]} (the placeholders are the catalog response code and the sorted Component names). After restarting, names in the response must contain payments-api and refund-worker. Leave the channel configuration you changed in step 5 as it is.

Plugins cannot call each other through code; they communicate only over HTTP. So even inside the same process, you need a token that passes the catalog's default auth policy. Right after startup the catalog may not have processed the file yet and the list may be empty, so call again after a few seconds. Also look at what the User-Agent of the /api/catalog/entities request is logged as in the backend log.

Push entities in with a module without editing the catalog

Create and add a module with pluginId catalog and moduleId pager-provider using createBackendModule. Inject catalogProcessingExtensionPoint from @backstage/plugin-catalog-node, register a provider named pager-provider with addEntityProvider, and in connect put in, with applyMutation({type: 'full', ...}), the Component pager-bridge (owner team-payments, including the backstage.io/managed-by-location and backstage.io/managed-by-origin-location annotations). Do not put it in org.yaml. After restarting, pager-bridge must appear in names of /api/oncall/services together with payments-api and refund-worker.

A module widens a plugin only through the extension points that the target plugin exposes. Check in the module documentation why you import the extension point from the -node library package and not from the catalog package itself. Entities a provider puts in are also filtered out at the processing stage if they lack the location annotations.

Report which layer the 401 and 404 come from

In the first five lines of /root/cba-plugin/report.md, write values you checked directly from the backend that is up now, as 키=값 (key=value) — mount_path= (the path where the oncall plugin router is attached), protected_route_status= (/roster without authentication), unknown_route_status= (a path that does not exist under the oncall plugin, without authentication), unknown_plugin_status= (a path under a pluginId that is not registered), and catalog_direct_status= (/api/catalog/entities without authentication). Below that, write an explanation of where the backend plugin and the frontend plugin each run and how they are connected.

Copy the numbers not from memory but from values you measure now with curl. The reason a nonexistent path is not 404 is that the auth check comes before the router. In the explanation, include the point that a frontend plugin runs in the browser and calls /api/<pluginId> of backend.baseUrl.