Skip to content

Commit

Permalink
Merge pull request #248 from ESLFACEITGROUP/opa-backend-processor
Browse files Browse the repository at this point in the history
Feature: OPA Entity Checker Catalog Processor Module - validate all entities always
  • Loading branch information
Parsifal-M authored Dec 3, 2024
2 parents 94b42eb + 59c1830 commit b19810a
Show file tree
Hide file tree
Showing 24 changed files with 745 additions and 99 deletions.
6 changes: 6 additions & 0 deletions .changeset/swift-vans-perform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@parsifal-m/plugin-opa-backend': minor
'@parsifal-m/backstage-plugin-opa-entity-checker-processor': patch
---

Add a new plugin that implements a catalog entity processor to validate entities during ingestion. The opa-backend was refactored to exposed the entity Checker Api as a service that can be used by other backend plugins.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,15 @@ This repository contains a collection of plugins for [Backstage](https://backsta

## Beta Plugins

### Authz

- [backstage-opa-authz-react](./plugins/opa-authz-react/README.md) - A frontend plugin that allows you to control the visibility of components based on the result of an OPA policy evaluation.
- [backstage-opa-authz](./packages/opa-authz/README.md) - A node library that includes an OPA client and middleware to evaluate policies, allowing you to control authorization in your backstage backend plugins using OPA.

### Entity Checker Processor

- [catalog-backend-module-opa-entity-checker-processor](./plugins/catalog-backend-module-opa-entity-checker-processor) - A Backstage catalog processor that validates entities at ingestion time using the `backstage-opa-backend` plugin and adds an annotation based on the OPA policy evaluation result which can be `error`, `warning` or `info`

## Policies

- [backstage-opa-policies-templates](https://github.com/Parsifal-M/backstage-opa-policies-templates?tab=readme-ov-file#hello) - A collection of policies that can be used with the plugins in this repository. (WIP)
Expand Down
25 changes: 25 additions & 0 deletions docs/opa-entity-checker/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,31 @@ see the details.

![Compact MetaData Card Violations Open](../assets/card-compact-opened.png)

## Entity Processor Validation - Validate all entities always

While the frontend validation allows for quick feedback, users will only see the feedback on visiting an entity. After implementing new rules, you might wonder how many entities are now returning validation error.

This is where catalog processors comes into play. When you discover or import entities into the Backstage catalog, you can run `processors` to (in this case) validate and update entities. The OPA Entity Checker Catalog Processor will validate your entities when they are ingested into the catalog and add an annotation based on the evaluation of the policy!

For instance, the final entity in Backstage might look like this:

```yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
namespace: default
annotations:
open-policy-agent/entity-checker-violations-status: error
```
As you can see, an annotation of `open-policy-agent/entity-checker-violations-status: error` has been added to the entity, this is because after the processor ran and checked the metadata against the OPA policy it found a violation which is considered an `error`

```http request
GET http://localhost:7007/api/catalog/entities/by-query?filter=metadata.annotations.open-policy-agent/entity-checker-violations-status=error
Content-Type: 'application/json'
Authorization: Bearer {{BACKSTAGE_TOKEN}}
```

## Join The Community

This project is a part of the broader Backstage and Open Policy Agent ecosystems. Explore more about these communities:
Expand Down
21 changes: 21 additions & 0 deletions docs/opa-entity-checker/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,27 @@ opaClient:
entrypoint: 'entity_checker/violation'
```

## Adding the Entity Processor

Run the following command to install the OPA Entity Checker Processor in your Backstage project.

```bash
yarn add --cwd packages/backend @parsifal-m/backstage-plugin-opa-entity-checker-processor
```

Then make the following changes to the `packages/backend/src/index.ts` file in your Backstage project.

```diff
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
backend.add(import('@parsifal-m/plugin-opa-backend'));
+ backend.add(import('@parsifal-m/backstage-plugin-opa-entity-checker-processor'));
```

## Recommendations

I recommend using [Regal: A linter and language server for Rego](https://github.com/StyraInc/regal) to help you write your policies. It provides syntax highlighting, linting, and type checking for Rego files.
2 changes: 2 additions & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@backstage/plugin-catalog-backend-module-github": "^0.7.5",
"@backstage/plugin-catalog-backend-module-github-org": "^0.3.2",
"@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "^0.2.1",
"@backstage/plugin-catalog-backend-module-unprocessed": "^0.5.1",
"@backstage/plugin-catalog-common": "^1.1.0",
"@backstage/plugin-permission-backend": "^0.5.50",
"@backstage/plugin-permission-common": "^0.8.1",
Expand All @@ -44,6 +45,7 @@
"@backstage/plugin-search-backend-node": "^1.3.3",
"@backstage/plugin-techdocs-backend": "^1.11.0",
"@internal/backstage-plugin-opa-demo-backend": "^0.1.0",
"@parsifal-m/backstage-plugin-opa-entity-checker-processor": "^0.1.0",
"@parsifal-m/plugin-opa-backend": "workspace:*",
"@parsifal-m/plugin-permission-backend-module-opa-wrapper": "workspace:*",
"app": "link:../app",
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ backend.add(import('@backstage/plugin-search-backend-module-catalog/alpha'));
backend.add(import('@backstage/plugin-search-backend-module-techdocs/alpha'));
backend.add(import('@backstage/plugin-techdocs-backend/alpha'));
backend.add(import('@internal/backstage-plugin-opa-demo-backend'));
backend.add(import('@backstage/plugin-catalog-backend-module-unprocessed'));
backend.add(
import('@parsifal-m/backstage-plugin-opa-entity-checker-processor'),
);
backend.start();
2 changes: 2 additions & 0 deletions plugins/backstage-opa-backend/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"name": "@parsifal-m/plugin-opa-backend",
"description": "The opa backend plugin responsible for interacting with the OPA server for validation and authorisation requests",
"version": "1.5.8",
"main": "src/index.ts",
"types": "src/index.ts",
Expand Down Expand Up @@ -39,6 +40,7 @@
"dependencies": {
"@backstage/backend-defaults": "^0.5.1",
"@backstage/backend-plugin-api": "^1.0.1",
"@backstage/catalog-model": "^1.7.0",
"@backstage/config": "^1.2.0",
"@backstage/errors": "^1.2.4",
"@backstage/integration": "^1.15.1",
Expand Down
144 changes: 144 additions & 0 deletions plugins/backstage-opa-backend/src/api/EntityCheckerApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import {
countResultByLevel,
determineOverallStatus,
EntityCheckerApiImpl,
OPAResult,
} from './EntityCheckerApi';
import { mockServices } from '@backstage/backend-test-utils';

describe('EntityCheckerApiImpl', () => {
it('should error if OPA package is not set', async () => {
const mockLogger = mockServices.logger.mock();
const config = mockServices.rootConfig({
data: {
opaClient: {
baseUrl: 'http://localhost:8181',
},
},
});

const error = () => {
/* eslint-disable no-new */
new EntityCheckerApiImpl({
logger: mockLogger,
opaBaseUrl: config.getOptionalString('opaClient.baseUrl'),
entityCheckerEntrypoint: config.getOptionalString(
'opaClient.policies.entityChecker.entrypoint',
),
});
};

expect(error).toThrow('OPA package not set or missing!');
});

it('should error url if OPA not set', async () => {
const mockLogger = mockServices.logger.mock();
const config = mockServices.rootConfig({
data: {
opaClient: {
policies: {
entityChecker: {
entrypoint: 'entityCheckerEntrypoint',
},
},
},
},
});

const error = () => {
/* eslint-disable no-new */
new EntityCheckerApiImpl({
logger: mockLogger,
opaBaseUrl: config.getOptionalString('opaClient.baseUrl'),
entityCheckerEntrypoint: config.getOptionalString(
'opaClient.policies.entityChecker.entrypoint',
),
});
};

expect(error).toThrow('OPA URL not set or missing!');
});
});

describe('countResultByLevel', () => {
it('should count the occurrences of each level', () => {
const results: OPAResult[] = [
{ level: 'error', message: 'Error message 1' },
{ level: 'warning', message: 'Warning message 1' },
{ level: 'error', message: 'Error message 2' },
{ level: 'info', message: 'Info message 1' },
{ level: 'warning', message: 'Warning message 2' },
{ level: 'error', message: 'Error message 3' },
];

const expectedCounts = new Map<string, number>([
['error', 3],
['warning', 2],
['info', 1],
]);

const actualCounts = countResultByLevel(results);

expect(actualCounts).toEqual(expectedCounts);
});

it('should return an empty map for an empty array', () => {
const results: OPAResult[] = [];
const expectedCounts = new Map<string, number>();
const actualCounts = countResultByLevel(results);
expect(actualCounts).toEqual(expectedCounts);
});
});

describe('determineOverallStatus', () => {
it('should return error if count of errors > 0', () => {
const levelCounts = new Map([
['error', 2],
['warning', 1],
['info', 3],
]);
const priorityOrder = ['error', 'warning', 'info'];
expect(determineOverallStatus(levelCounts, priorityOrder)).toBe('error');
});

it('should return "warning" when there are no errors', () => {
const levelCounts = new Map([
['warning', 1],
['info', 3],
]);
const priorityOrder = ['error', 'warning', 'info'];
expect(determineOverallStatus(levelCounts, priorityOrder)).toBe('warning');
});

it('should return "info" when there are no errors nor warnings', () => {
const levelCounts = new Map([['info', 3]]);
const priorityOrder = ['error', 'warning', 'info'];
expect(determineOverallStatus(levelCounts, priorityOrder)).toBe('info');
});

it('should return "info" when all counts are 0', () => {
const levelCounts = new Map([
['error', 0],
['warning', 0],
['info', 0],
]);
const priorityOrder = ['error', 'warning', 'info'];
expect(determineOverallStatus(levelCounts, priorityOrder)).toBe('pass');
});

it('should return "pass" when the map is empty', () => {
const levelCounts = new Map();
const priorityOrder = ['error', 'warning', 'info'];
expect(determineOverallStatus(levelCounts, priorityOrder)).toBe('pass');
});

it('should use the provided priority order, let swap things around', () => {
const levelCounts = new Map([
['error', 1],
['warning', 1],
['info', 1],
]);
const priorityOrder = ['warning', 'error', 'info']; // Different order
expect(determineOverallStatus(levelCounts, priorityOrder)).toBe('warning');
});
});
99 changes: 99 additions & 0 deletions plugins/backstage-opa-backend/src/api/EntityCheckerApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import fetch from 'node-fetch';
import { LoggerService } from '@backstage/backend-plugin-api';
import { Entity } from '@backstage/catalog-model';

export interface EntityCheckerApi {
checkEntity(options: checkEntityOptions): Promise<OpaEntityCheckResult>;
}

export type checkEntityOptions = {
entityMetadata: Entity;
};

export type EntityCheckerConfig = {
logger: LoggerService;
opaBaseUrl: string | undefined;
entityCheckerEntrypoint: string | undefined;
};

export interface OpaEntityCheckResult {
result?: OPAResult[];
}

export interface OPAResult {
id?: string;
check_title?: string;
level: 'error' | 'warning' | 'info';
message: string;
}

/**
* countResultByLevel is a utility function that can be used tha generate statics about policy results
* @param arr
*/
export function countResultByLevel(arr: OPAResult[]): Map<string, number> {
return arr.reduce((acc: Map<string, number>, val) => {
const count = acc.get(val.level) || 0;
acc.set(val.level, count + 1);
return acc;
}, new Map<string, number>());
}

/**
* determineOverallStatus is meant to be used in concision with countResultByLevel, the status is which ever >0 given a priority list
* @param levelCounts
* @param priorityOrder
*/
export function determineOverallStatus(
levelCounts: Map<string, number>,
priorityOrder: string[],
): string {
for (const level of priorityOrder) {
if (levelCounts.get(level) && levelCounts.get(level)! > 0) {
return level;
}
}
return 'pass'; // Default to 'pass'
}

export class EntityCheckerApiImpl implements EntityCheckerApi {
constructor(private readonly config: EntityCheckerConfig) {
const logger = this.config.logger;

if (!config.opaBaseUrl) {
logger.error('OPA URL not set or missing!');
throw new Error('OPA URL not set or missing!');
}

if (!config.entityCheckerEntrypoint) {
logger.error('OPA package not set or missing!');
throw new Error('OPA package not set or missing!');
}
}

async checkEntity(
options: checkEntityOptions,
): Promise<OpaEntityCheckResult> {
const logger = this.config.logger;
const entityMetadata = options.entityMetadata;

if (!entityMetadata) {
logger.error('Entity metadata is missing!');
throw new Error('Entity metadata is missing!');
}

const opaUrl = `${this.config.opaBaseUrl}/v1/data/${this.config.entityCheckerEntrypoint}`;
logger.debug(
`Sending entity metadata to OPA: ${JSON.stringify(entityMetadata)}`,
);
return await fetch(opaUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ input: entityMetadata }),
}).then(response => {
return response.json() as Promise<OpaEntityCheckResult>;
});
}
}
2 changes: 2 additions & 0 deletions plugins/backstage-opa-backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './service/router';
export * from './api/EntityCheckerApi';
export { entityCheckerServiceRef } from './plugin';
export { opaPlugin as default } from './plugin';
Loading

0 comments on commit b19810a

Please sign in to comment.