feat: allow dynamically enabling and disabling extensions

Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com>
This commit is contained in:
aramissennyeydd
2026-03-08 18:08:09 -04:00
committed by Patrik Oldsberg
parent 00381fa7b2
commit 25b7ddd664
23 changed files with 458 additions and 21 deletions
@@ -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.
@@ -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.
@@ -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' } }`.
+206 -2
View File
@@ -65,7 +65,8 @@ const IndexPage = PageBlueprint.make({
const page1Link = useRouteRef(page1RouteRef);
return (
<div>
op
<h1>Example Pages Plugin</h1>
<h2>Navigation</h2>
{page1Link && (
<div>
<Link to={page1Link()}>Page 1</Link>
@@ -83,6 +84,47 @@ const IndexPage = PageBlueprint.make({
<div>
<Link to="/settings">Settings</Link>
</div>
<h2>Permission Enablement Examples</h2>
<p>
The following pages demonstrate conditional extension enablement
via the <code>enabled</code> predicate using permissions. They
will only appear when the user has the required permissions.
</p>
<ul>
<li>
<Link to="/permission-gated-example">
Permission Gated Example
</Link>{' '}
requires <code>catalog.entity.create</code>
</li>
</ul>
<h2>Feature Flag Enablement Examples</h2>
<p>
The following pages demonstrate conditional extension enablement
via the <code>enabled</code> predicate. They will only appear in
the router tree when their conditions are satisfied. Toggle the
relevant feature flags in <Link to="/settings">Settings</Link>,
then refresh the app to see the pages appear.
</p>
<ul>
<li>
<Link to="/feature-flag-example">Feature Flag Example</Link>
requires the <code>experimental-features</code> flag
</li>
<li>
<Link to="/all-flags-example">All Flags Example</Link>
requires <em>both</em> <code>experimental-features</code> and{' '}
<code>advanced-features</code> (<code>$all</code>)
</li>
<li>
<Link to="/any-flag-example">Any Flag Example</Link> requires{' '}
<em>either</em> <code>experimental-features</code> or{' '}
<code>beta-access</code> (<code>$any</code>)
</li>
</ul>
<PluginInfo />
</div>
);
@@ -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 (
<div>
<h1>Feature Flag Enabled Page</h1>
<p>
This page is only present in the app when the{' '}
<code>experimental-features</code> feature flag is active.
</p>
<p>
It uses a simple{' '}
<code>
{'{ featureFlags: { $contains: "experimental-features" } }'}
</code>{' '}
predicate.
</p>
{indexLink && <Link to={indexLink()}>Go back</Link>}
</div>
);
};
return <Component />;
},
},
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 (
<div>
<h1>All Flags Required Page</h1>
<p>
This page requires <em>both</em>{' '}
<code>experimental-features</code> and{' '}
<code>advanced-features</code> to be active simultaneously.
</p>
<p>
It uses a <code>$all</code> predicate to AND the two conditions
together.
</p>
{indexLink && <Link to={indexLink()}>Go back</Link>}
</div>
);
};
return <Component />;
},
},
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 (
<div>
<h1>Any Flag Sufficient Page</h1>
<p>
This page appears when <em>either</em>{' '}
<code>experimental-features</code> or <code>beta-access</code> is
active.
</p>
<p>
It uses a <code>$any</code> predicate to OR the two conditions
together.
</p>
{indexLink && <Link to={indexLink()}>Go back</Link>}
</div>
);
};
return <Component />;
},
},
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 (
<div>
<h1>Permission Gated Page</h1>
<p>
This page is only present when the user has the{' '}
<code>catalog.entity.create</code> permission.
</p>
{indexLink && <Link to={indexLink()}>Go back</Link>}
</div>
);
};
return <Component />;
},
},
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,
],
});
+2
View File
@@ -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",
@@ -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<unknown, unknown>,
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();
});
});
});
@@ -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<string, unknown>;
}
| Record<string, unknown>,
): boolean {
const options: {
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
predicateContext?: Record<string, unknown>;
} =
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<string, AppNode[]>();
@@ -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<string>().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);
});
});
@@ -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,
+1
View File
@@ -23,6 +23,7 @@ export function createApp(options?: CreateAppOptions): {
// @public
export interface CreateAppOptions {
advanced?: {
allowUnknownExtensionConfig?: boolean;
configLoader?: () => Promise<{
config: ConfigApi;
}>;
+22 -10
View File
@@ -156,22 +156,33 @@ function PreparedAppRoot(props: {
}): JSX.Element {
const signIn = props.preparedApp.getSignIn();
const [finalizeError, setFinalizeError] = useState<Error>();
const [finalizedApp, setFinalizedApp] = useState(() => {
if (!signIn) {
return props.preparedApp.finalize();
}
return undefined;
});
const [finalizedApp, setFinalizedApp] = useState<
ReturnType<PreparedSpecializedApp['finalize']> | 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);
+1
View File
@@ -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:^"
@@ -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<ExtensionDefinitionParameters>;
@@ -70,6 +71,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{
readonly name?: string;
readonly attachTo: ExtensionDefinitionAttachTo;
readonly disabled: boolean;
readonly enabled?: FilterPredicate;
readonly configSchema?: PortableSchema<any, any>;
readonly inputs: { [inputName in string]: ExtensionInput };
readonly output: Array<ExtensionDataRef>;
@@ -45,6 +45,7 @@
},
"dependencies": {
"@backstage/errors": "workspace:^",
"@backstage/filter-predicates": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"zod": "^3.25.76",
@@ -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<unknown, unknown>;
// (undocumented)
readonly id: string;
@@ -577,6 +580,7 @@ export type CreateExtensionBlueprintOptions<
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
enabled?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
config?: {
@@ -663,6 +667,7 @@ export type CreateExtensionOptions<
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
enabled?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
config?: {
@@ -1026,6 +1031,7 @@ export interface ExtensionBlueprint<
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<NonNullable<T['output']>, 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`;
@@ -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<unknown, unknown>;
readonly disabled: boolean;
readonly enabled?: FilterPredicate;
readonly config?: unknown;
readonly plugin: FrontendPlugin;
}
@@ -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<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
enabled?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
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 ?? {}),
@@ -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<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
enabled?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
config?: {
@@ -221,6 +223,7 @@ export interface ExtensionBlueprint<
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<NonNullable<T['output']>, 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:
@@ -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<TConfig, TConfigInput> = Extension<
}
| {
readonly version: 'v2';
readonly enabled?: FilterPredicate;
readonly inputs: { [inputName in string]: ExtensionInput };
readonly output: Array<ExtensionDataRef>;
factory(options: {
@@ -52,11 +52,18 @@ export class IdentityPermissionApi implements PermissionApi {
async authorize(
request: AuthorizePermissionRequest,
): Promise<AuthorizePermissionResponse> {
const response = await this.permissionClient.authorize(
[request],
): Promise<AuthorizePermissionResponse>;
async authorize(
requests: AuthorizePermissionRequest[],
): Promise<AuthorizePermissionResponse[]>;
async authorize(
request: AuthorizePermissionRequest | AuthorizePermissionRequest[],
): Promise<AuthorizePermissionResponse | AuthorizePermissionResponse[]> {
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];
}
}
@@ -30,6 +30,9 @@ export type PermissionApi = {
authorize(
request: EvaluatePermissionRequest,
): Promise<EvaluatePermissionResponse>;
authorize(
requests: EvaluatePermissionRequest[],
): Promise<EvaluatePermissionResponse[]>;
};
/**
@@ -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;
+4
View File
@@ -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:^"