OPA Permissions Wrapper logo

Backstage OPA Permissions Wrapper Plugin

Created by Peter Macdonald

OPA Permissions Wrapper lets Backstage use Open Policy Agent for permission checks. You write policies in Rego outside your Backstage code. When a user performs an action, the wrapper sends the permission and identity to OPA. OPA evaluates your policy and returns a decision. The Backstage permission framework applies that result.

This decouples authorization from deployments. You update policies in OPA and see the effect right away. Teams can own their rules without touching TypeScript. You keep a single source of truth for RBAC across your portal. Decisions can allow or deny. They can be conditional with filters that map to rules in Backstage plugins.

Common uses include guarding catalog reads and writes. Protecting TechDocs updates. Locking down admin tasks in custom backend APIs. You can also call an evaluatePolicy helper inside your own plugins for richer inputs and checks. If OPA is unreachable, you can choose a safe fallback to allow or deny. The module works with the Backstage permission framework you already have. It focuses on authorization, not authentication.

If you run a self hosted Backstage and want flexible policy control, this plugin gives you a clean path to manage permissions with Rego.

Installation Instructions

These instructions apply to self-hosted Backstage only.

Install the backend module with the new backend system

Add the package

Copy
yarn --cwd packages/backend add @parsifal-m/plugin-permission-backend-module-opa-wrapper

Register the module

Edit packages/backend/src/index.ts

Copy
import { createBackend } from '@backstage/backend-defaults';

const backend = createBackend();

backend.add(import('@backstage/plugin-app-backend/alpha'));
backend.add(import('@backstage/plugin-auth-backend'));

// other plugins you already use

backend.add(import('@parsifal-m/plugin-permission-backend-module-opa-wrapper'));

backend.start();

This registers the OPA permission policy. All permission checks flow through OPA.

Configure OPA

Add this to app-config.yaml

Copy
permission:
  opa:
    baseUrl: 'http://localhost:8181'
    policy:
      policyEntryPoint: 'rbac_policy/decision'
      policyFallbackDecision: 'deny'

policyEntryPoint is the OPA rule path you want to evaluate
policyFallbackDecision can be allow or deny
If the OPA server is not reachable the fallback decision is used

Install with the old backend system

Status

This module targets the new backend system in Backstage
There are no supported steps for the old backend system in this plugin

No frontend changes

This plugin runs in the backend
It does not export React components
You do not need to change the app package

Your existing permission checks continue to work
They now resolve through OPA

Example OPA policy

You can name your decision rule as you see fit
The plugin expects a result object in the OPA response

Copy
package backstage_policy

import future.keywords.if

CONDITIONAL(plugin_id, resource_type, conditions) := conditional_decision if {
  conditional_decision := {
    "result": "CONDITIONAL",
    "pluginId": plugin_id,
    "resourceType": resource_type,
    "conditions": conditions,
  }
}

default decision := {"result": "DENY"}

permission := input.permission.name
claims := input.identity.claims

decision := {"result": "ALLOW"} if {
  permission == "catalog.entity.read"
}

decision := CONDITIONAL("catalog", "catalog-entity", {"anyOf": [{
  "resourceType": "catalog-entity",
  "rule": "IS_ENTITY_OWNER",
  "params": {"claims": claims},
}]}) if {
  permission == "catalog.entity.delete"
}

decision := CONDITIONAL("catalog", "catalog-entity", {"anyOf": [{
  "resourceType": "catalog-entity",
  "rule": "IS_ENTITY_KIND",
  "params": {"kinds": ["Component"]},
}]}) if {
  permission == "catalog.entity.update"
}

What the backend sends to OPA

The plugin sends input shaped like this

Copy
export type PermissionsFrameworkPolicyInput = {
  permission: {
    name: string;
  };
  identity?: {
    user: string | undefined;
    claims: string[];
  };
};

OPA should return an object with a result field
If the decision is conditional include conditions as needed

Changelog

This changelog is produced from commits made to the OPA Permissions Wrapper plugin since a year ago, and based on the code located here. It may not contain information about all commits. Releases and version bumps are intentionally omitted. This changelog is generated by AI.

Breaking changes

  • #263 Remove backend middleware. Use the wrapper plugin helper to protect routes. merged 7 months ago
  • #266 Deprecate the opa authz package. All functionality lives in the wrapper plugin. Switch imports to the wrapper plugin. merged 7 months ago

Features

  • #230 Enable OPA for permissions without the permissions framework. Control frontend rendering. Protect backend routes with OPA. merged 11 months ago
  • #278 Add evaluatePolicy overload. Allow custom response types from OPA. merged 4 months ago

Bug fixes

  • #280 Accept not in policy conditionals. none did not work with the permissions framework. merged 4 months ago

Documentation

  • #293 Fix OPA configuration entry point naming in docs. merged 1 month ago
  • #277 Fix README script command. merged 5 months ago
  • #276 Fix typos in docs. merged 6 months ago
  • #270 Minor doc fix. merged 7 months ago

Refactors

  • #289 Improve configuration handling. Add tests. merged 2 months ago
  • #250 Use PolicyQueryUser in place of BackstageIdentity. Fewer imports in apps. merged 10 months ago
  • #232 Move to the new URL Reader service. merged 11 months ago

Set up Backstage in minutes with Roadie