From 25b7ddd6644484ec6ed9ea17d99b59d00dce08c1 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 8 Mar 2026 18:08:09 -0400 Subject: [PATCH] feat: allow dynamically enabling and disabling extensions Signed-off-by: aramissennyeydd --- .../ff-perm-enabled-frontend-app-api.md | 7 + .../ff-perm-enabled-frontend-defaults.md | 5 + .../ff-perm-enabled-frontend-plugin-api.md | 7 + packages/app/src/examples/pagesPlugin.tsx | 208 +++++++++++++++++- packages/frontend-app-api/package.json | 2 + .../src/tree/instantiateAppNodeTree.test.ts | 92 ++++++++ .../src/tree/instantiateAppNodeTree.ts | 29 ++- .../src/tree/resolveAppNodeSpecs.test.ts | 26 +++ .../src/tree/resolveAppNodeSpecs.ts | 17 ++ packages/frontend-defaults/report.api.md | 1 + packages/frontend-defaults/src/createApp.tsx | 32 ++- packages/frontend-internal/package.json | 1 + .../src/wiring/InternalExtensionDefinition.ts | 2 + packages/frontend-plugin-api/package.json | 1 + packages/frontend-plugin-api/report.api.md | 8 + .../src/apis/definitions/AppTreeApi.ts | 2 + .../src/wiring/createExtension.ts | 5 + .../src/wiring/createExtensionBlueprint.ts | 6 + .../src/wiring/resolveExtensionDefinition.ts | 2 + .../src/apis/IdentityPermissionApi.ts | 15 +- .../src/apis/PermissionApi.ts | 3 + .../report.api.md | 4 +- yarn.lock | 4 + 23 files changed, 458 insertions(+), 21 deletions(-) create mode 100644 .changeset/ff-perm-enabled-frontend-app-api.md create mode 100644 .changeset/ff-perm-enabled-frontend-defaults.md create mode 100644 .changeset/ff-perm-enabled-frontend-plugin-api.md diff --git a/.changeset/ff-perm-enabled-frontend-app-api.md b/.changeset/ff-perm-enabled-frontend-app-api.md new file mode 100644 index 0000000000..dc3bba03ba --- /dev/null +++ b/.changeset/ff-perm-enabled-frontend-app-api.md @@ -0,0 +1,7 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Extensions with an `enabled` predicate are now evaluated before app tree instantiation, so pages that do not meet their conditions are excluded from the router tree. + +`prepareSpecializedApp().finalize()` is now async. Extensions whose `enabled` predicate references `permissions` are checked against the current user's allowed permissions (via a single batched call to `permissionApiRef`) before the app tree is instantiated. diff --git a/.changeset/ff-perm-enabled-frontend-defaults.md b/.changeset/ff-perm-enabled-frontend-defaults.md new file mode 100644 index 0000000000..c811cad8eb --- /dev/null +++ b/.changeset/ff-perm-enabled-frontend-defaults.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-defaults': patch +--- + +Updated `createApp` to await the now-async `finalize()` call from `prepareSpecializedApp`, enabling permission-gated extensions to be resolved before the app tree is rendered. diff --git a/.changeset/ff-perm-enabled-frontend-plugin-api.md b/.changeset/ff-perm-enabled-frontend-plugin-api.md new file mode 100644 index 0000000000..67fd93eb19 --- /dev/null +++ b/.changeset/ff-perm-enabled-frontend-plugin-api.md @@ -0,0 +1,7 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added `enabled` option to `createExtension`, `createExtensionBlueprint`, and `AppNodeSpec`, accepting a `FilterPredicate` from `@backstage/filter-predicates` to conditionally enable extensions based on feature flags or other runtime conditions. + +Added `permissions` option to `createFrontendPlugin` and `createFrontendModule`, allowing plugins and modules to declare which permissions they use. These permissions are checked at app startup so that extensions can conditionally enable themselves using a `permissions` predicate, e.g. `enabled: { permissions: { $contains: 'catalog.entity.create' } }`. diff --git a/packages/app/src/examples/pagesPlugin.tsx b/packages/app/src/examples/pagesPlugin.tsx index 35426b589b..374935e843 100644 --- a/packages/app/src/examples/pagesPlugin.tsx +++ b/packages/app/src/examples/pagesPlugin.tsx @@ -65,7 +65,8 @@ const IndexPage = PageBlueprint.make({ const page1Link = useRouteRef(page1RouteRef); return (
- op +

Example Pages Plugin

+

Navigation

{page1Link && (
Page 1 @@ -83,6 +84,47 @@ const IndexPage = PageBlueprint.make({
Settings
+ +

Permission Enablement Examples

+

+ The following pages demonstrate conditional extension enablement + via the enabled predicate using permissions. They + will only appear when the user has the required permissions. +

+
    +
  • + + Permission Gated Example + {' '} + — requires catalog.entity.create +
  • +
+ +

Feature Flag Enablement Examples

+

+ The following pages demonstrate conditional extension enablement + via the enabled predicate. They will only appear in + the router tree when their conditions are satisfied. Toggle the + relevant feature flags in Settings, + then refresh the app to see the pages appear. +

+
    +
  • + Feature Flag Example — + requires the experimental-features flag +
  • +
  • + All Flags Example — + requires both experimental-features and{' '} + advanced-features ($all) +
  • +
  • + Any Flag Example — requires{' '} + either experimental-features or{' '} + beta-access ($any) +
  • +
+
); @@ -150,6 +192,155 @@ const ExternalPage = PageBlueprint.make({ }, }); +// Example: Page enabled only when a single feature flag is active. +// +// The `enabled` predicate is evaluated once at app startup (before the router +// tree is built), so this page simply won't exist in the app until the flag is +// toggled and the page is refreshed. +// +// To test: enable the 'experimental-features' flag in Settings, then refresh. +const FeatureFlagPage = PageBlueprint.make({ + name: 'featureFlagExample', + params: { + path: '/feature-flag-example', + loader: async () => { + const Component = () => { + const indexLink = useRouteRef(indexRouteRef); + return ( +
+

Feature Flag Enabled Page

+

+ This page is only present in the app when the{' '} + experimental-features feature flag is active. +

+

+ It uses a simple{' '} + + {'{ featureFlags: { $contains: "experimental-features" } }'} + {' '} + predicate. +

+ {indexLink && Go back} +
+ ); + }; + return ; + }, + }, + enabled: { featureFlags: { $contains: 'experimental-features' } }, +}); + +// Example: Page enabled only when ALL of several feature flags are active. +// +// The $all operator requires every nested predicate to be satisfied. This page +// won't appear unless both 'experimental-features' and 'advanced-features' are +// enabled at the same time. +// +// To test: enable BOTH flags in Settings, then refresh. +const AllFlagsPage = PageBlueprint.make({ + name: 'allFlagsExample', + params: { + path: '/all-flags-example', + loader: async () => { + const Component = () => { + const indexLink = useRouteRef(indexRouteRef); + return ( +
+

All Flags Required Page

+

+ This page requires both{' '} + experimental-features and{' '} + advanced-features to be active simultaneously. +

+

+ It uses a $all predicate to AND the two conditions + together. +

+ {indexLink && Go back} +
+ ); + }; + return ; + }, + }, + enabled: { + $all: [ + { featureFlags: { $contains: 'experimental-features' } }, + { featureFlags: { $contains: 'advanced-features' } }, + ], + }, +}); + +// Example: Page enabled when ANY one of several feature flags is active. +// +// The $any operator is satisfied as soon as at least one nested predicate +// matches. Enabling either 'experimental-features' or 'beta-access' will make +// this page appear. +// +// To test: enable at least one of the two flags in Settings, then refresh. +const AnyFlagPage = PageBlueprint.make({ + name: 'anyFlagExample', + params: { + path: '/any-flag-example', + loader: async () => { + const Component = () => { + const indexLink = useRouteRef(indexRouteRef); + return ( +
+

Any Flag Sufficient Page

+

+ This page appears when either{' '} + experimental-features or beta-access is + active. +

+

+ It uses a $any predicate to OR the two conditions + together. +

+ {indexLink && Go back} +
+ ); + }; + return ; + }, + }, + enabled: { + $any: [ + { featureFlags: { $contains: 'experimental-features' } }, + { featureFlags: { $contains: 'beta-access' } }, + ], + }, +}); + +// Example: Page enabled only when the user is allowed to create catalog entities. +// +// The `enabled` predicate is evaluated once at app startup (after sign-in), +// so this page simply won't exist in the router tree if the user lacks the +// required permission. +const PermissionGatedPage = PageBlueprint.make({ + name: 'permissionGatedExample', + params: { + path: '/permission-gated-example', + loader: async () => { + const Component = () => { + const indexLink = useRouteRef(indexRouteRef); + return ( +
+

Permission Gated Page

+

+ This page is only present when the user has the{' '} + catalog.entity.create permission. +

+ {indexLink && Go back} +
+ ); + }; + return ; + }, + }, + enabled: { permissions: { $contains: 'catalog.entity.create' } }, +}); + export const pagesPlugin = createFrontendPlugin({ pluginId: 'pages', // routes: { @@ -170,5 +361,18 @@ export const pagesPlugin = createFrontendPlugin({ externalRoutes: { pageX: externalPageXRouteRef, }, - extensions: [IndexPage, Page1, ExternalPage], + featureFlags: [ + { name: 'experimental-features' }, + { name: 'advanced-features' }, + { name: 'beta-access' }, + ], + extensions: [ + IndexPage, + Page1, + ExternalPage, + FeatureFlagPage, + AllFlagsPage, + AnyFlagPage, + PermissionGatedPage, + ], }); diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json index 1951c18828..b188f94348 100644 --- a/packages/frontend-app-api/package.json +++ b/packages/frontend-app-api/package.json @@ -36,8 +36,10 @@ "@backstage/core-app-api": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/filter-predicates": "workspace:^", "@backstage/frontend-defaults": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", "lodash": "^4.17.21", diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts index e52bbfa320..ac22c34696 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts @@ -1782,4 +1782,96 @@ describe('instantiateAppNodeTree', () => { }); }); }); + + describe('enabled predicate', () => { + function makeNodeWithEnabled( + enabled: AppNodeSpec['enabled'], + disabled = false, + ): AppNode { + const ext = resolveExtensionDefinition( + createExtension({ + attachTo: { id: 'ignored', input: 'ignored' }, + output: [testDataRef], + factory: () => [testDataRef('value')], + }), + { namespace: 'test-ext' }, + ); + return { + spec: { + id: ext.id, + attachTo: ext.attachTo, + disabled, + enabled, + extension: ext as Extension, + plugin: createFrontendPlugin({ pluginId: 'app' }), + }, + edges: { attachments: new Map() }, + }; + } + + it('should skip a node when the predicate is not satisfied', () => { + const node = makeNodeWithEnabled({ + featureFlags: { $contains: 'the-flag' }, + }); + const tree = resolveAppTree('test-ext', [node.spec], collector); + instantiateAppNodeTree(tree.root, testApis, collector, undefined, { + featureFlags: [], + }); + expect(tree.root.instance).toBeUndefined(); + }); + + it('should instantiate a node when the predicate is satisfied', () => { + const node = makeNodeWithEnabled({ + featureFlags: { $contains: 'the-flag' }, + }); + const tree = resolveAppTree('test-ext', [node.spec], collector); + instantiateAppNodeTree(tree.root, testApis, collector, undefined, { + featureFlags: ['the-flag'], + }); + expect(tree.root.instance).toBeDefined(); + expect(tree.root.instance?.getData(testDataRef)).toBe('value'); + }); + + it('should support $all operator across multiple flags', () => { + const node = makeNodeWithEnabled({ + $all: [ + { featureFlags: { $contains: 'flag-a' } }, + { featureFlags: { $contains: 'flag-b' } }, + ], + }); + const tree = resolveAppTree('test-ext', [node.spec], collector); + + // Only one flag active — should not instantiate + instantiateAppNodeTree(tree.root, testApis, collector, undefined, { + featureFlags: ['flag-a'], + }); + expect(tree.root.instance).toBeUndefined(); + + // Both flags active — should instantiate + const tree2 = resolveAppTree('test-ext', [node.spec], collector); + instantiateAppNodeTree(tree2.root, testApis, collector, undefined, { + featureFlags: ['flag-a', 'flag-b'], + }); + expect(tree2.root.instance).toBeDefined(); + }); + + it('should instantiate nodes without an enabled field regardless of predicateContext', () => { + const node = makeNodeWithEnabled(undefined); + const tree = resolveAppTree('test-ext', [node.spec], collector); + instantiateAppNodeTree(tree.root, testApis, collector, undefined, { + featureFlags: [], + }); + expect(tree.root.instance).toBeDefined(); + }); + + it('should instantiate nodes with enabled predicate when predicateContext is not provided', () => { + const node = makeNodeWithEnabled({ + featureFlags: { $contains: 'the-flag' }, + }); + const tree = resolveAppTree('test-ext', [node.spec], collector); + // No predicateContext passed — predicate evaluation is skipped + instantiateAppNodeTree(tree.root, testApis, collector); + expect(tree.root.instance).toBeDefined(); + }); + }); }); diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts index 85a7baa11c..d60a910487 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts @@ -29,6 +29,7 @@ import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api'; import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; import { createExtensionDataContainer } from '@internal/frontend'; import { ErrorCollector } from '../wiring/createErrorCollector'; +import { evaluateFilterPredicate } from '@backstage/filter-predicates'; const INSTANTIATION_FAILED = new Error('Instantiation failed'); @@ -509,10 +510,25 @@ export function instantiateAppNodeTree( apis: ApiHolder, collector: ErrorCollector, extensionFactoryMiddleware?: ExtensionFactoryMiddleware, - options?: { - stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean; - }, + optionsOrPredicateContext?: + | { + stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean; + predicateContext?: Record; + } + | Record, ): boolean { + const options: { + stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean; + predicateContext?: Record; + } = + optionsOrPredicateContext && + ('stopAtAttachment' in optionsOrPredicateContext || + 'predicateContext' in optionsOrPredicateContext) + ? optionsOrPredicateContext + : { + predicateContext: optionsOrPredicateContext, + }; + function createInstance(node: AppNode): AppNodeInstance | undefined { if (node.instance) { return node.instance; @@ -520,6 +536,13 @@ export function instantiateAppNodeTree( if (node.spec.disabled) { return undefined; } + if ( + options?.predicateContext !== undefined && + node.spec.enabled !== undefined && + !evaluateFilterPredicate(node.spec.enabled, options.predicateContext) + ) { + return undefined; + } const instantiatedAttachments = new Map(); diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts index 3ccdf5258f..279ae13af1 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts @@ -15,6 +15,8 @@ */ import { + createExtension, + createExtensionDataRef, createFrontendModule, createFrontendPlugin, Extension, @@ -506,4 +508,28 @@ describe('resolveAppNodeSpecs', () => { }, ]); }); + + it('should carry enabled predicate through to AppNodeSpec', () => { + const dataRef = createExtensionDataRef().with({ id: 'test.data' }); + const enabledPredicate = { featureFlags: { $contains: 'my-flag' } }; + const plugin = createFrontendPlugin({ + pluginId: 'test-plugin', + extensions: [ + createExtension({ + attachTo: { id: 'app', input: 'root' }, + enabled: enabledPredicate, + output: [dataRef], + factory: () => [dataRef('value')], + }), + ], + }); + const specs = resolveAppNodeSpecs({ + features: [plugin], + builtinExtensions: [], + parameters: [], + collector, + }); + expect(specs).toHaveLength(1); + expect(specs[0].enabled).toEqual(enabledPredicate); + }); }); diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts index a095acee0e..005e6a05b9 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts @@ -116,6 +116,10 @@ export function resolveAppNodeSpecs(options: { source: plugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, + enabled: + internalExtension.version === 'v2' + ? internalExtension.enabled + : undefined, config: undefined as unknown, }, }; @@ -129,6 +133,10 @@ export function resolveAppNodeSpecs(options: { plugin: appPlugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, + enabled: + internalExtension.version === 'v2' + ? internalExtension.enabled + : undefined, config: undefined as unknown, }, }; @@ -148,6 +156,10 @@ export function resolveAppNodeSpecs(options: { configuredExtensions[index].extension = internalExtension; configuredExtensions[index].params.attachTo = internalExtension.attachTo; configuredExtensions[index].params.disabled = internalExtension.disabled; + configuredExtensions[index].params.enabled = + internalExtension.version === 'v2' + ? internalExtension.enabled + : undefined; } else { // Add the extension as a new one when not overriding an existing one configuredExtensions.push({ @@ -157,6 +169,10 @@ export function resolveAppNodeSpecs(options: { source: extension.plugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, + enabled: + internalExtension.version === 'v2' + ? internalExtension.enabled + : undefined, config: undefined, }, }); @@ -235,6 +251,7 @@ export function resolveAppNodeSpecs(options: { attachTo: param.params.attachTo, extension: param.extension, disabled: param.params.disabled, + enabled: param.params.enabled, plugin: param.params.plugin, source: param.params.source, config: param.params.config, diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index cfe4f55b2d..bc620c95a3 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -23,6 +23,7 @@ export function createApp(options?: CreateAppOptions): { // @public export interface CreateAppOptions { advanced?: { + allowUnknownExtensionConfig?: boolean; configLoader?: () => Promise<{ config: ConfigApi; }>; diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index c4cf43fb2e..2c1aa0519c 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -156,22 +156,33 @@ function PreparedAppRoot(props: { }): JSX.Element { const signIn = props.preparedApp.getSignIn(); const [finalizeError, setFinalizeError] = useState(); - const [finalizedApp, setFinalizedApp] = useState(() => { - if (!signIn) { - return props.preparedApp.finalize(); - } - return undefined; - }); + const [finalizedApp, setFinalizedApp] = useState< + ReturnType | undefined + >(undefined); useEffect(() => { let cancelled = false; + const runFinalize = async () => { + try { + const predicateContext = await props.preparedApp.buildPredicateContext(); + if (cancelled) { + return; + } + setFinalizedApp(props.preparedApp.finalize(predicateContext)); + } catch (error) { + if (cancelled) { + return; + } + setFinalizeError(error as Error); + } + }; if (signIn) { void signIn.complete - .then(async () => { + .then(() => { if (cancelled) { return; } - setFinalizedApp(props.preparedApp.finalize()); + void runFinalize(); }) .catch(error => { if (cancelled) { @@ -179,8 +190,9 @@ function PreparedAppRoot(props: { } setFinalizeError(error); }); + } else { + void runFinalize(); } - return () => { cancelled = true; }; @@ -191,7 +203,7 @@ function PreparedAppRoot(props: { } if (!finalizedApp) { - return signIn!.element; + return signIn?.element ?? <>; } return renderFinalizedApp(finalizedApp); diff --git a/packages/frontend-internal/package.json b/packages/frontend-internal/package.json index 52cdb33641..abfac8a51a 100644 --- a/packages/frontend-internal/package.json +++ b/packages/frontend-internal/package.json @@ -23,6 +23,7 @@ "test": "backstage-cli package test" }, "dependencies": { + "@backstage/filter-predicates": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^" diff --git a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts index d6a4eb2eab..a3f1ae8ff3 100644 --- a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts +++ b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts @@ -28,6 +28,7 @@ import { // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { ResolvedExtensionInputs } from '../../../frontend-plugin-api/src/wiring/createExtension'; import { OpaqueType } from '@internal/opaque'; +import { FilterPredicate } from '@backstage/filter-predicates'; export const OpaqueExtensionDefinition = OpaqueType.create<{ public: OverridableExtensionDefinition; @@ -70,6 +71,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{ readonly name?: string; readonly attachTo: ExtensionDefinitionAttachTo; readonly disabled: boolean; + readonly enabled?: FilterPredicate; readonly configSchema?: PortableSchema; readonly inputs: { [inputName in string]: ExtensionInput }; readonly output: Array; diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json index 568d542a38..66973d4406 100644 --- a/packages/frontend-plugin-api/package.json +++ b/packages/frontend-plugin-api/package.json @@ -45,6 +45,7 @@ }, "dependencies": { "@backstage/errors": "workspace:^", + "@backstage/filter-predicates": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", "zod": "^3.25.76", diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 74823c0639..0af67779e7 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -14,6 +14,7 @@ import { ExtensionBlueprint as ExtensionBlueprint_2 } from '@backstage/frontend- import { ExtensionBlueprintParams as ExtensionBlueprintParams_2 } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef as ExtensionDataRef_2 } from '@backstage/frontend-plugin-api'; import { ExtensionInput as ExtensionInput_2 } from '@backstage/frontend-plugin-api'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { JSX as JSX_2 } from 'react'; @@ -254,6 +255,8 @@ export interface AppNodeSpec { // (undocumented) readonly disabled: boolean; // (undocumented) + readonly enabled?: FilterPredicate; + // (undocumented) readonly extension: Extension; // (undocumented) readonly id: string; @@ -577,6 +580,7 @@ export type CreateExtensionBlueprintOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; + enabled?: FilterPredicate; inputs?: TInputs; output: Array; config?: { @@ -663,6 +667,7 @@ export type CreateExtensionOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; + enabled?: FilterPredicate; inputs?: TInputs; output: Array; config?: { @@ -1026,6 +1031,7 @@ export interface ExtensionBlueprint< attachTo?: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo, UParentInputs>; disabled?: boolean; + enabled?: FilterPredicate; params: TParamsInput extends ExtensionBlueprintDefineParams ? TParamsInput : T['params'] extends ExtensionBlueprintDefineParams @@ -1061,6 +1067,7 @@ export interface ExtensionBlueprint< UParentInputs >; disabled?: boolean; + enabled?: FilterPredicate; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & string}' is already defined in parent definition`; @@ -1702,6 +1709,7 @@ export interface OverridableExtensionDefinition< UParentInputs >; disabled?: boolean; + enabled?: FilterPredicate; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & string}' is already defined in parent definition`; diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts index 16369680f2..c6c367e0b5 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts @@ -17,6 +17,7 @@ import { createApiRef } from '../system'; import { FrontendPlugin, Extension, ExtensionDataRef } from '../../wiring'; import { ExtensionAttachTo } from '../../wiring/resolveExtensionDefinition'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** * The specification for this {@link AppNode} in the {@link AppTree}. @@ -32,6 +33,7 @@ export interface AppNodeSpec { readonly attachTo: ExtensionAttachTo; readonly extension: Extension; readonly disabled: boolean; + readonly enabled?: FilterPredicate; readonly config?: unknown; readonly plugin: FrontendPlugin; } diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 0a1e9c91f8..10ca58d0da 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -36,6 +36,7 @@ import { } from './createExtensionBlueprint'; import { FrontendPlugin } from './createFrontendPlugin'; import { FrontendModule } from './createFrontendModule'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** * This symbol is used to pass parameter overrides from the extension override to the blueprint factory @@ -174,6 +175,7 @@ export type CreateExtensionOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; + enabled?: FilterPredicate; inputs?: TInputs; output: Array; config?: { @@ -255,6 +257,7 @@ export interface OverridableExtensionDefinition< UParentInputs >; disabled?: boolean; + enabled?: FilterPredicate; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & string}' is already defined in parent definition`; @@ -474,6 +477,7 @@ export function createExtension< name: options.name, attachTo: options.attachTo, disabled: options.disabled ?? false, + enabled: options.enabled, inputs: bindInputs(options.inputs, options.kind, options.name), output: options.output, configSchema, @@ -551,6 +555,7 @@ export function createExtension< attachTo: (overrideOptions.attachTo ?? options.attachTo) as ExtensionDefinitionAttachTo, disabled: overrideOptions.disabled ?? options.disabled, + enabled: overrideOptions.enabled ?? options.enabled, inputs: bindInputs( { ...(options.inputs ?? {}), diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index a7537d27e9..538a47003b 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -36,6 +36,7 @@ import { } from './resolveInputOverrides'; import { ExtensionDataContainer } from './types'; import { PageBlueprint } from '../blueprints/PageBlueprint'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** * A function used to define a parameter mapping function in order to facilitate @@ -114,6 +115,7 @@ export type CreateExtensionBlueprintOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; + enabled?: FilterPredicate; inputs?: TInputs; output: Array; config?: { @@ -221,6 +223,7 @@ export interface ExtensionBlueprint< attachTo?: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo, UParentInputs>; disabled?: boolean; + enabled?: FilterPredicate; params: TParamsInput extends ExtensionBlueprintDefineParams ? TParamsInput : T['params'] extends ExtensionBlueprintDefineParams @@ -261,6 +264,7 @@ export interface ExtensionBlueprint< UParentInputs >; disabled?: boolean; + enabled?: FilterPredicate; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & string}' is already defined in parent definition`; @@ -510,6 +514,7 @@ export function createExtensionBlueprint< attachTo: (args.attachTo ?? options.attachTo) as ExtensionDefinitionAttachTo, disabled: args.disabled ?? options.disabled, + enabled: args.enabled ?? options.enabled, inputs: options.inputs, output: options.output as ExtensionDataRef[], config: options.config, @@ -527,6 +532,7 @@ export function createExtensionBlueprint< attachTo: (args.attachTo ?? options.attachTo) as ExtensionDefinitionAttachTo, disabled: args.disabled ?? options.disabled, + enabled: args.enabled ?? options.enabled, inputs: { ...args.inputs, ...options.inputs }, output: (args.output ?? options.output) as ExtensionDataRef[], config: diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts index 70a0bd7837..9476c48256 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts @@ -28,6 +28,7 @@ import { OpaqueExtensionDefinition, OpaqueExtensionInput, } from '@internal/frontend'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** @public */ export type ExtensionAttachTo = { id: string; input: string }; @@ -74,6 +75,7 @@ export type InternalExtension = Extension< } | { readonly version: 'v2'; + readonly enabled?: FilterPredicate; readonly inputs: { [inputName in string]: ExtensionInput }; readonly output: Array; factory(options: { diff --git a/plugins/permission-react/src/apis/IdentityPermissionApi.ts b/plugins/permission-react/src/apis/IdentityPermissionApi.ts index dc1fc3b5a3..7a3ac6482e 100644 --- a/plugins/permission-react/src/apis/IdentityPermissionApi.ts +++ b/plugins/permission-react/src/apis/IdentityPermissionApi.ts @@ -52,11 +52,18 @@ export class IdentityPermissionApi implements PermissionApi { async authorize( request: AuthorizePermissionRequest, - ): Promise { - const response = await this.permissionClient.authorize( - [request], + ): Promise; + async authorize( + requests: AuthorizePermissionRequest[], + ): Promise; + async authorize( + request: AuthorizePermissionRequest | AuthorizePermissionRequest[], + ): Promise { + const requests = Array.isArray(request) ? request : [request]; + const responses = await this.permissionClient.authorize( + requests, await this.identityApi.getCredentials(), ); - return response[0]; + return Array.isArray(request) ? responses : responses[0]; } } diff --git a/plugins/permission-react/src/apis/PermissionApi.ts b/plugins/permission-react/src/apis/PermissionApi.ts index d3a37421ef..5f538a068d 100644 --- a/plugins/permission-react/src/apis/PermissionApi.ts +++ b/plugins/permission-react/src/apis/PermissionApi.ts @@ -30,6 +30,9 @@ export type PermissionApi = { authorize( request: EvaluatePermissionRequest, ): Promise; + authorize( + requests: EvaluatePermissionRequest[], + ): Promise; }; /** diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index cbe31b02f3..aec6cb61b5 100644 --- a/plugins/scaffolder-backend-module-gitlab/report.api.md +++ b/plugins/scaffolder-backend-module-gitlab/report.api.md @@ -154,7 +154,7 @@ export const createGitlabRepoPushAction: (options: { sourcePath?: string | undefined; targetPath?: string | undefined; token?: string | undefined; - commitAction?: 'auto' | 'update' | 'delete' | 'create' | undefined; + commitAction?: 'auto' | 'update' | 'create' | 'delete' | undefined; }, { projectid: string; @@ -271,7 +271,7 @@ export const createPublishGitlabMergeRequestAction: (options: { sourcePath?: string | undefined; targetPath?: string | undefined; token?: string | undefined; - commitAction?: 'auto' | 'update' | 'delete' | 'create' | 'skip' | undefined; + commitAction?: 'auto' | 'update' | 'create' | 'delete' | 'skip' | undefined; projectid?: string | undefined; removeSourceBranch?: boolean | undefined; assignee?: string | undefined; diff --git a/yarn.lock b/yarn.lock index 63aff90ba1..80b34e1d42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3628,10 +3628,12 @@ __metadata: "@backstage/core-app-api": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/frontend-defaults": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^" "@backstage/plugin-app": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@backstage/version-bridge": "workspace:^" @@ -3752,6 +3754,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^" "@backstage/test-utils": "workspace:^" @@ -9941,6 +9944,7 @@ __metadata: resolution: "@internal/frontend@workspace:packages/frontend-internal" dependencies: "@backstage/cli": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^"