diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 9c37b87..dae9dda 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -56,7 +56,6 @@ import { import { TechDocsAddons } from '@backstage/plugin-techdocs-react'; import { ReportIssue } from '@backstage/plugin-techdocs-module-addons-contrib'; import { OpaMetadataAnalysisCard } from '@parsifal-m/plugin-opa-entity-checker'; -import { DevQuote } from '@parsifal-m/plugin-dev-quotes-homepage'; const techdocsContent = ( @@ -122,9 +121,6 @@ const overviewContent = ( - - - diff --git a/plugins/opa-backend/package.json b/plugins/opa-backend/package.json index e2ed3ab..e5ff4a9 100644 --- a/plugins/opa-backend/package.json +++ b/plugins/opa-backend/package.json @@ -40,7 +40,9 @@ "express": "^4.17.1", "express-promise-router": "^4.1.0", "winston": "^3.2.1", - "yn": "^4.0.0" + "yn": "^4.0.0", + "node-fetch": "^2.6.7", + "@backstage/errors": "^1.2.3" }, "devDependencies": { "@backstage/cli": "^0.22.13", diff --git a/plugins/opa-backend/src/service/router.test.ts b/plugins/opa-backend/src/service/router.test.ts index b35adb5..cf54da0 100644 --- a/plugins/opa-backend/src/service/router.test.ts +++ b/plugins/opa-backend/src/service/router.test.ts @@ -1,71 +1,359 @@ import { getVoidLogger } from '@backstage/backend-common'; import express from 'express'; import request from 'supertest'; -import axios from 'axios'; - import { createRouter } from './router'; +import { ConfigReader } from '@backstage/config'; +import fetch from 'node-fetch'; + +jest.mock('node-fetch'); -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; +const { Response: FetchResponse } = jest.requireActual('node-fetch'); describe('createRouter', () => { let app: express.Express; + const config = new ConfigReader({ + opaClient: { + baseUrl: 'http://localhost', + policies: { + entityChecker: { + package: 'entitymeta_policy', + }, + rbac: { + package: 'rbac_policy', + }, + }, + }, + }); + beforeAll(async () => { const router = await createRouter({ logger: getVoidLogger(), - config: { - getString: jest.fn().mockImplementation((key: string) => { - if (key === 'opaClient.policies.entityChecker.package') { - return 'entityChecker.package'; - } - throw new Error(`Unmocked config key "${key}"`); - }), - getOptionalString: jest.fn().mockImplementation((key: string) => { - if (key === 'opaClient.baseUrl') { - return 'http://dummy-opa-base-url.com'; - } - return null; // Return null for non-existing optional keys - }), - ...(jest.fn() as any), - }, + config: config, }); + app = express().use(router); + jest.resetAllMocks(); }); - beforeEach(() => { - jest.resetAllMocks(); + describe('GET /health', () => { + it('returns ok', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ status: 'ok' }); + }); }); - describe('POST /entity-checker', () => { - it('returns data from OPA', async () => { - const mockResponse = { data: { result: 'mockResult' } }; - mockedAxios.post.mockResolvedValue(mockResponse); + describe('Entity Checker Route', () => { + const mockedPayload = { + input: { + metadata: { + namespace: 'default', + annotations: { + 'backstage.io/managed-by-location': + 'file:/brewed-backstage/examples/entities.yaml', + 'backstage.io/managed-by-origin-location': + 'file:/brewed-backstage/examples/entities.yaml', + }, + name: 'example-website', + uid: '762d5d68-7418-4b65-baa4-43d5e6cd591d', + etag: '46e9e22027eb7c502df70e8c34a0285123bc8e01', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + spec: { + type: 'website', + lifecycle: 'experimental', + owner: 'guests', + system: 'examples', + providesApis: ['example-grpc-api'], + }, + relations: [ + { + type: 'ownedBy', + targetRef: 'group:default/guests', + target: { + kind: 'group', + namespace: 'default', + name: 'guests', + }, + }, + { + type: 'partOf', + targetRef: 'system:default/examples', + target: { + kind: 'system', + namespace: 'default', + name: 'examples', + }, + }, + { + type: 'providesApi', + targetRef: 'api:default/example-grpc-api', + target: { + kind: 'api', + namespace: 'default', + name: 'example-grpc-api', + }, + }, + ], + }, + }; + + const mockedEntityResponse = { + allow: true, + is_system_present: true, + violation: [ + { + level: 'warning', + message: 'You do not have any tags set!', + }, + ], + }; - const response = await request(app) + it('POSTS and returns a response from OPA as expected', async () => { + (fetch as jest.MockedFunction).mockImplementation(() => { + return Promise.resolve( + new FetchResponse(JSON.stringify(mockedEntityResponse), { + headers: { 'Content-Type': 'application/json' }, + }), + ); + }); + + const res = await request(app) .post('/entity-checker') - .send({ input: 'entityMetadata' }); + .send(mockedPayload) + .expect('Content-Type', /json/); - expect(response.status).toEqual(200); - expect(response.body).toEqual(mockResponse.data.result); + expect(res.status).toEqual(200); + expect(res.body).toEqual(mockedEntityResponse); + }); + + it('will complain if the OPA url is missing', async () => { + const noBaseUrlConfig = new ConfigReader({ + opaClient: { + baseUrl: undefined, + policies: { + entityChecker: { + package: 'entitymeta_policy', + }, + rbac: { + package: 'rbac_policy', + }, + }, + }, + }); + + const router = await createRouter({ + logger: getVoidLogger(), + config: noBaseUrlConfig, + }); + + const localApp = express().use(router); + + const response = await request(localApp) + .post(`/entity-checker`) + .send({ input: {} }); // send an empty input + + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('OPA URL not set or missing!'); }); - it('handles error from OPA', async () => { - const mockErrorResponse = { + it('will complain if no entity checker package is set', async () => { + const noEntityCheckerPackageConfig = new ConfigReader({ + opaClient: { + baseUrl: 'http://localhost', + policies: { + entityChecker: { + package: undefined, + }, + rbac: { + package: 'rbac_policy', + }, + }, + }, + }); + + const router = await createRouter({ + logger: getVoidLogger(), + config: noEntityCheckerPackageConfig, + }); + + const localApp = express().use(router); + + const response = await request(localApp) + .post(`/entity-checker`) + .send({ input: {} }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe( + 'OPA entity checker package not set or missing!', + ); + }); + + it('will return a 500 if OPA there is an issue sending the request to OPA', async () => { + (fetch as jest.MockedFunction).mockImplementation(() => { + return Promise.reject(new Error('OPA is not available')); + }); + + const res = await request(app) + .post('/entity-checker') + .send(mockedPayload) + .expect('Content-Type', /json/); + + expect(res.status).toEqual(500); + expect(res.body).toEqual({ + message: 'An error occurred trying to send entity metadata to OPA', + }); + }); + + it('returns a 400 if the input is missing', async () => { + const res = await request(app) + .post('/entity-checker') + .send() + .expect('Content-Type', /json/); + + expect(res.status).toEqual(400); + expect(res.body).toEqual({ error: { - name: 'Error', - message: 'OPA error message', + message: 'Entity metadata is missing!', + name: 'InputError', + }, + request: { + method: 'POST', + url: '/entity-checker', + }, + response: { + statusCode: 400, + }, + }); + }); + }); + + describe('Permissions Route', () => { + const mockedInput = { + input: { + permission: { + name: 'catalog.entity.read', + }, + identity: { + user: 'user:default/parsifal-m', + claims: ['user:default/parsifal-m', 'group:default/maintainers'], }, - }; + }, + }; - mockedAxios.post.mockRejectedValue(mockErrorResponse); + const mockedResponse = { + result: { + allow: true, + }, + }; - const response = await request(app) + it('POSTS and returns a response from OPA as expected', async () => { + (fetch as jest.MockedFunction).mockImplementation(() => { + return Promise.resolve( + new FetchResponse(JSON.stringify(mockedResponse), { + headers: { 'Content-Type': 'application/json' }, + }), + ); + }); + + const res = await request(app) .post('/entity-checker') - .send({ input: 'entityMetadata' }); + .send(mockedInput) + .expect('Content-Type', /json/); + + expect(res.status).toEqual(200); + expect(res.body).toEqual(mockedResponse); + }); + + it('will complain if the OPA url is missing', async () => { + const noBaseUrlConfig = new ConfigReader({ + opaClient: { + baseUrl: undefined, + policies: { + entityChecker: { + package: 'entitymeta_policy', + }, + rbac: { + package: 'rbac_policy', + }, + }, + }, + }); + + const router = await createRouter({ + logger: getVoidLogger(), + config: noBaseUrlConfig, + }); + + const localApp = express().use(router); + + const response = await request(localApp) + .post(`/opa-permissions`) + .send({ input: {} }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('OPA URL not set or missing!'); + }); + + it('will complain if no rbac package is set', async () => { + const noRbacPackageConfig = new ConfigReader({ + opaClient: { + baseUrl: 'http://localhost', + policies: { + entityChecker: { + package: 'entitymeta_policy', + }, + rbac: { + package: undefined, + }, + }, + }, + }); + + const router = await createRouter({ + logger: getVoidLogger(), + config: noRbacPackageConfig, + }); + + const localApp = express().use(router); + + const response = await request(localApp) + .post(`/opa-permissions`) + .send({ input: {} }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe( + 'OPA RBAC package not set or missing!', + ); + }); + + it('will return 400 if the policy input is missing', async () => { + const res = await request(app) + .post('/opa-permissions') + .send() + .expect('Content-Type', /json/); + + expect(res.status).toEqual(400); + expect(res.body.message).toEqual('The policy input is missing!'); + }); + + it('will return a 500 if OPA there is an issue sending the request to OPA', async () => { + (fetch as jest.MockedFunction).mockImplementation(() => { + return Promise.reject(new Error('OPA is not available')); + }); + + const res = await request(app) + .post('/opa-permissions') + .send({ policyInput: {} }) + .expect('Content-Type', /json/); - expect(response.status).toEqual(500); // Check the HTTP status code - expect(response.text).toContain('OPA error message'); // Check the actual error message received + expect(res.status).toEqual(500); + expect(res.body.message).toEqual( + 'An error occurred trying to send policy input to OPA', + ); }); }); }); diff --git a/plugins/opa-backend/src/service/router.ts b/plugins/opa-backend/src/service/router.ts index b016253..298a517 100644 --- a/plugins/opa-backend/src/service/router.ts +++ b/plugins/opa-backend/src/service/router.ts @@ -1,9 +1,10 @@ import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; -import axios from 'axios'; +import fetch from 'node-fetch'; import { errorHandler } from '@backstage/backend-common'; import { Config } from '@backstage/config'; +import { InputError } from '@backstage/errors'; export type RouterOptions = { logger: Logger; @@ -39,62 +40,95 @@ export async function createRouter( router.post('/entity-checker', async (req, res, next) => { const entityMetadata = req.body.input; + + if (!opaBaseUrl) { + logger.error('OPA URL not set or missing!'); + throw new InputError('OPA URL not set or missing!'); + } + const opaUrl = `${opaBaseUrl}/v1/data/${entityCheckerPackage}`; - if (!opaUrl) { - return next(new Error('OPA URL not set or missing!')); + if (!entityCheckerPackage) { + res + .status(400) + .json({ message: 'OPA entity checker package not set or missing!' }); + logger.error('OPA package not set or missing!'); + throw new InputError('OPA package not set or missing!'); } if (!entityMetadata) { - return next(new Error('Entity metadata is missing!')); + logger.error('Entity metadata is missing!'); + throw new InputError('Entity metadata is missing!'); } try { - const opaResponse = await axios.post(opaUrl, { - input: entityMetadata, + const opaResponse = await fetch(opaUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ input: entityMetadata }), }); - return res.json(opaResponse.data.result); + const opaEntityCheckerResponse = await opaResponse.json(); + return res.json(opaEntityCheckerResponse); } catch (error) { - res.status(500); + logger.error( + 'An error occurred trying to send entity metadata to OPA:', + error, + ); + res.status(500).json({ + message: `An error occurred trying to send entity metadata to OPA`, + }); return next(error); } }); router.post('/opa-permissions', async (req, res, next) => { const policyInput = req.body.policyInput; - const opaUrl = `${opaBaseUrl}/v1/data/${opaRbacPackage}`; - if (!opaUrl) { - res.status(400); + if (!opaBaseUrl) { + res.status(400).json({ message: 'OPA URL not set or missing!' }); logger.error('OPA URL not set or missing!'); - return next(new Error('OPA URL not set or missing!')); + throw new InputError('OPA URL not set or missing!'); } + const opaUrl = `${opaBaseUrl}/v1/data/${opaRbacPackage}`; + if (!opaRbacPackage) { - res.status(400); + res.status(400).json({ message: 'OPA RBAC package not set or missing!' }); logger.error('OPA package not set or missing!'); - return next(new Error('OPA package not set or missing!')); + throw new InputError('OPA package not set or missing!'); } if (!policyInput) { - res.status(400); + res.status(400).json({ message: 'The policy input is missing!' }); logger.error('Policy input is missing!'); - return next(new Error('Policy input is missing!')); + throw new InputError('Policy input is missing!'); } try { - const opaResponse = await axios.post(opaUrl, { - input: policyInput, + const opaResponse = await fetch(opaUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ input: policyInput }), }); logger.info( `Permission request sent to OPA with input: ${JSON.stringify( policyInput, )}`, ); - return res.json(opaResponse.data.result); + const opaPermissionsResponse = await opaResponse.json(); + return res.json(opaPermissionsResponse.result); } catch (error) { - res.status(500); - logger.error('Error during OPA policy evaluation:', error); + res.status(500).json({ + message: `An error occurred trying to send policy input to OPA`, + }); + logger.error( + 'An error occurred trying to send policy input to OPA:', + error, + ); return next(error); } }); diff --git a/plugins/opa-permissions-wrapper/src/opa-client/index.ts b/plugins/opa-permissions-wrapper/src/opa-client/index.ts new file mode 100644 index 0000000..1d24117 --- /dev/null +++ b/plugins/opa-permissions-wrapper/src/opa-client/index.ts @@ -0,0 +1 @@ +export * from './opaClient'; diff --git a/plugins/opa-permissions-wrapper/src/opa-client/opaClient.test.ts b/plugins/opa-permissions-wrapper/src/opa-client/opaClient.test.ts new file mode 100644 index 0000000..92b64d0 --- /dev/null +++ b/plugins/opa-permissions-wrapper/src/opa-client/opaClient.test.ts @@ -0,0 +1,133 @@ +import { OpaClient } from './opaClient'; +import fetch from 'node-fetch'; +import { ConfigReader } from '@backstage/config'; +import { Logger } from 'winston'; +import { PolicyEvaluationInput } from '../types'; + +jest.mock('node-fetch', () => jest.fn()); +jest.mock('@backstage/config', () => { + return { + ConfigReader: jest.fn().mockImplementation(() => { + return { + getString: jest.fn().mockReturnValue('http://localhost:7007'), + }; + }), + }; +}); +jest.mock('winston'); + +describe('OpaClient', () => { + let mockLogger: Logger; + let mockConfig: ConfigReader; + const mockFetch = fetch as jest.MockedFunction; + + beforeAll(() => { + mockLogger = { + info: jest.fn(), + error: jest.fn(), + } as unknown as Logger; + mockConfig = new ConfigReader({ + backend: { + baseUrl: 'http://localhost:7007', + }, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should evaluate policy correctly', async () => { + const mockInput: PolicyEvaluationInput = { + permission: { name: 'read' }, + identity: { user: 'testUser', claims: ['claim1', 'claim2'] }, + }; + const mockResponse = { result: 'DENY' }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResponse), + } as any); + + const client = new OpaClient(mockConfig, mockLogger); + const result = await client.evaluatePolicy(mockInput); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/opa/opa-permissions', + expect.anything(), + ); + expect(mockFetch.mock.calls[0][1]?.body).toContain( + JSON.stringify({ policyInput: mockInput }), + ); + expect(result).toEqual(mockResponse); + }); + + it('should handle ALLOW result', async () => { + const mockInput: PolicyEvaluationInput = { + permission: { name: 'write' }, + identity: { user: 'testUser', claims: ['claim1', 'claim2'] }, + }; + const mockResponse = { result: 'ALLOW' }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResponse), + } as any); + + const client = new OpaClient(mockConfig, mockLogger); + const result = await client.evaluatePolicy(mockInput); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/opa/opa-permissions', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ policyInput: mockInput }), + }, + ); + expect(result).toEqual(mockResponse); + }); + + it('should throw error when response is not ok', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + json: jest.fn().mockResolvedValueOnce({}), + } as any); + + const client = new OpaClient(mockConfig, mockLogger); + const mockInput: PolicyEvaluationInput = { + permission: { name: 'read' }, + identity: { user: 'testUser', claims: ['claim1', 'claim2'] }, + }; + await expect(client.evaluatePolicy(mockInput)).rejects.toThrow(); + }); + + it('should throw error when response is not okk', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + json: jest.fn().mockResolvedValueOnce({}), + } as any); + + const client = new OpaClient(mockConfig, mockLogger); + const mockInput: PolicyEvaluationInput = { + permission: { name: 'read' }, + identity: { user: 'testUser', claims: ['claim1', 'claim2'] }, + }; + await expect(client.evaluatePolicy(mockInput)).rejects.toThrow(); + }); + + it('should throw error when fetch throws an error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const client = new OpaClient(mockConfig, mockLogger); + const mockInput: PolicyEvaluationInput = { + permission: { name: 'read' }, + identity: { user: 'testUser', claims: ['claim1', 'claim2'] }, + }; + await expect(client.evaluatePolicy(mockInput)).rejects.toThrow( + 'Network error', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Error during OPA policy evaluation:', + expect.objectContaining({ message: 'Network error' }), + ); + }); +}); diff --git a/plugins/opa-permissions-wrapper/src/opa-client/opaClient.ts b/plugins/opa-permissions-wrapper/src/opa-client/opaClient.ts index 010fb99..21ba97e 100644 --- a/plugins/opa-permissions-wrapper/src/opa-client/opaClient.ts +++ b/plugins/opa-permissions-wrapper/src/opa-client/opaClient.ts @@ -1,7 +1,11 @@ -import axios from 'axios'; +import fetch from 'node-fetch'; import { Config } from '@backstage/config'; import { Logger } from 'winston'; -import { PolicyEvaluationInput } from '../types'; +import { PolicyEvaluationInput, PolicyEvaluationResult } from '../types'; +import { ResponseError } from '@backstage/errors'; + +// NOTE: Something to think about here, we could directly make an API call to OPA on line 28 +// instead of routing through the backend plugin. Something we need to think about. export class OpaClient { private readonly baseUrl: string; @@ -12,26 +16,37 @@ export class OpaClient { this.logger = logger; } - async evaluatePolicy(input: PolicyEvaluationInput): Promise { + async evaluatePolicy( + input: PolicyEvaluationInput, + ): Promise { this.logger.info( `Sending request to OPA: ${this.baseUrl}/api/opa/opa-permissions`, ); - this.logger.info(`Sending request to OPA: ${JSON.stringify(input)}`); + this.logger.info(`Sending input to OPA: ${JSON.stringify(input)}`); try { - const response = await axios.post( - `${this.baseUrl}/api/opa/opa-permissions`, - { - policyInput: input, + const response = await fetch(`${this.baseUrl}/api/opa/opa-permissions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', }, - ); + body: JSON.stringify({ + policyInput: input, + }), + }); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + const data = await response.json(); this.logger.info( - `Received response from OPA server: ${JSON.stringify(response.data)}`, + `Received response from OPA server: ${JSON.stringify(data)}`, ); - return response.data; + return data; } catch (error: unknown) { this.logger.error('Error during OPA policy evaluation:', error); throw new Error(`Failed to evaluate policy: ${error}`); diff --git a/plugins/opa-permissions-wrapper/src/permission-evaluator/index.ts b/plugins/opa-permissions-wrapper/src/permission-evaluator/index.ts index d9006db..ae71fce 100644 --- a/plugins/opa-permissions-wrapper/src/permission-evaluator/index.ts +++ b/plugins/opa-permissions-wrapper/src/permission-evaluator/index.ts @@ -1 +1 @@ -export { policyEvaluator } from './opaEvaluator'; +export * from './opaEvaluator'; diff --git a/plugins/opa-permissions-wrapper/src/permission-evaluator/opaEvaluator.ts b/plugins/opa-permissions-wrapper/src/permission-evaluator/opaEvaluator.ts index 69f5bbe..4b66960 100644 --- a/plugins/opa-permissions-wrapper/src/permission-evaluator/opaEvaluator.ts +++ b/plugins/opa-permissions-wrapper/src/permission-evaluator/opaEvaluator.ts @@ -2,12 +2,16 @@ import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; import { PolicyDecision, AuthorizeResult, + PermissionCondition, + PermissionCriteria, + PermissionRuleParams, } from '@backstage/plugin-permission-common'; import { OpaClient } from '../opa-client/opaClient'; import { PolicyQuery } from '@backstage/plugin-permission-node'; import { PolicyEvaluationInput } from '../types'; +import { Logger } from 'winston'; -export const policyEvaluator = (opaClient: OpaClient) => { +export const policyEvaluator = (opaClient: OpaClient, logger: Logger) => { return async ( request: PolicyQuery, user?: BackstageIdentityResponse, @@ -25,11 +29,26 @@ export const policyEvaluator = (opaClient: OpaClient) => { const response = await opaClient.evaluatePolicy(input); if (response.decision.result === 'CONDITIONAL') { + if (!response.decision.conditions) { + logger.error('Conditions are missing for CONDITIONAL decision'); + throw new Error('Conditions are missing for CONDITIONAL decision'); + } + if (!response.decision.pluginId) { + logger.error('PluginId is missing for CONDITIONAL decision'); + throw new Error('PluginId is missing for CONDITIONAL decision'); + } + if (!response.decision.resourceType) { + logger.error('ResourceType is missing for CONDITIONAL decision'); + throw new Error('ResourceType is missing for CONDITIONAL decision'); + } + return { result: AuthorizeResult.CONDITIONAL, pluginId: response.decision.pluginId, resourceType: response.decision.resourceType, - conditions: response.decision.conditions, + conditions: response.decision.conditions as PermissionCriteria< + PermissionCondition + >, }; } diff --git a/plugins/opa-permissions-wrapper/src/types.ts b/plugins/opa-permissions-wrapper/src/types.ts index c47ad7a..cc8d559 100644 --- a/plugins/opa-permissions-wrapper/src/types.ts +++ b/plugins/opa-permissions-wrapper/src/types.ts @@ -8,10 +8,12 @@ export type PolicyEvaluationInput = { }; }; -export interface ConditionalDecision { - claims: string; +export type PolicyEvaluationResult = { decision: { - conditions: { + result: string; + pluginId?: string; + resourceType?: string; + conditions?: { anyOf?: { params: { [key: string]: any; @@ -34,9 +36,5 @@ export interface ConditionalDecision { rule: string; }[]; }; - pluginId: string; - resourceType: string; - result: string; }; - permission: string; -} +};