` styled with `background: var(--bui-bg-inherit)` should always
+ * resolve to the same color as the surrounding bg provider â at every level
+ * of the neutral chain and inside intent surfaces.
+ */
+const Probe = ({ label }: { label: string }) => (
+
+ {label}
+
+);
+
export const Default = meta.story({
render: () => (
@@ -151,3 +169,37 @@ export const Default = meta.story({
),
});
+
+export const BgInherit = meta.story({
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+});
diff --git a/packages/ui/src/css/core.css b/packages/ui/src/css/core.css
index 1047215ee8..74f5960017 100644
--- a/packages/ui/src/css/core.css
+++ b/packages/ui/src/css/core.css
@@ -47,4 +47,38 @@
[data-theme-mode='light'] {
color-scheme: light;
}
+
+ :root {
+ --bui-bg-inherit: var(--bui-bg-app);
+ }
+
+ [data-bg='neutral-1'] {
+ background-color: var(--bui-bg-neutral-1);
+ --bui-bg-inherit: var(--bui-bg-neutral-1);
+ }
+
+ [data-bg='neutral-2'] {
+ background-color: var(--bui-bg-neutral-2);
+ --bui-bg-inherit: var(--bui-bg-neutral-2);
+ }
+
+ [data-bg='neutral-3'] {
+ background-color: var(--bui-bg-neutral-3);
+ --bui-bg-inherit: var(--bui-bg-neutral-3);
+ }
+
+ [data-bg='danger'] {
+ background-color: var(--bui-bg-danger);
+ --bui-bg-inherit: var(--bui-bg-danger);
+ }
+
+ [data-bg='warning'] {
+ background-color: var(--bui-bg-warning);
+ --bui-bg-inherit: var(--bui-bg-warning);
+ }
+
+ [data-bg='success'] {
+ background-color: var(--bui-bg-success);
+ --bui-bg-inherit: var(--bui-bg-success);
+ }
}
diff --git a/packages/ui/src/definitions.ts b/packages/ui/src/definitions.ts
index 85599a3b23..cb71f5062e 100644
--- a/packages/ui/src/definitions.ts
+++ b/packages/ui/src/definitions.ts
@@ -35,7 +35,13 @@ export { ButtonLinkDefinition } from './components/ButtonLink/definition';
export { CardDefinition } from './components/Card/definition';
export { CheckboxDefinition } from './components/Checkbox/definition';
export { CheckboxGroupDefinition } from './components/CheckboxGroup/definition';
+export { ComboboxDefinition } from './components/Combobox/definition';
export { ContainerDefinition } from './components/Container/definition';
+export {
+ DatePickerDefinition,
+ DatePickerGroupDefinition,
+ DatePickerCalendarDefinition,
+} from './components/DatePicker/definition';
export { DateRangePickerDefinition } from './components/DateRangePicker/definition';
export { DialogDefinition } from './components/Dialog/definition';
export { FieldErrorDefinition } from './components/FieldError/definition';
diff --git a/packages/ui/src/hooks/useDefinition/helpers.test.ts b/packages/ui/src/hooks/useDefinition/helpers.test.ts
new file mode 100644
index 0000000000..92735d83bc
--- /dev/null
+++ b/packages/ui/src/hooks/useDefinition/helpers.test.ts
@@ -0,0 +1,398 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+ resolveResponsiveValue,
+ resolveDefinitionProps,
+ processUtilityProps,
+} from './helpers';
+import type { ComponentConfig } from './types';
+
+describe('resolveResponsiveValue', () => {
+ it('returns a plain string unchanged', () => {
+ expect(resolveResponsiveValue('hello', 'md')).toBe('hello');
+ });
+
+ it('returns a plain number unchanged', () => {
+ expect(resolveResponsiveValue(42, 'md')).toBe(42);
+ });
+
+ it('returns undefined unchanged', () => {
+ expect(resolveResponsiveValue(undefined, 'md')).toBeUndefined();
+ });
+
+ it('returns null unchanged', () => {
+ expect(resolveResponsiveValue(null, 'md')).toBeNull();
+ });
+
+ it('returns a non-breakpoint object unchanged', () => {
+ const obj = { foo: 'bar' };
+ expect(resolveResponsiveValue(obj, 'md')).toBe(obj);
+ });
+
+ it('returns an object with only an initial key unchanged (not detected as responsive)', () => {
+ const obj = { initial: 'base' };
+ expect(resolveResponsiveValue(obj, 'md')).toBe(obj);
+ });
+
+ it('resolves exact breakpoint match', () => {
+ expect(resolveResponsiveValue({ xs: 'small', md: 'medium' }, 'md')).toBe(
+ 'medium',
+ );
+ });
+
+ it('falls back to the nearest smaller breakpoint', () => {
+ expect(resolveResponsiveValue({ xs: 'small', md: 'medium' }, 'sm')).toBe(
+ 'small',
+ );
+ });
+
+ it('falls back across multiple missing breakpoints', () => {
+ expect(resolveResponsiveValue({ xs: 'small', xl: 'xlarge' }, 'lg')).toBe(
+ 'small',
+ );
+ });
+
+ it('falls back to initial when no named breakpoint matches at or below current', () => {
+ expect(
+ resolveResponsiveValue({ initial: 'base', md: 'medium' }, 'sm'),
+ ).toBe('base');
+ });
+
+ it('falls forward to the smallest available breakpoint when nothing is at or below current', () => {
+ expect(
+ resolveResponsiveValue({ md: 'medium', xl: 'xlarge' }, 'initial'),
+ ).toBe('medium');
+ });
+
+ it('resolves initial breakpoint from a responsive object that includes initial', () => {
+ expect(
+ resolveResponsiveValue({ initial: 'base', xs: 'small' }, 'initial'),
+ ).toBe('base');
+ });
+
+ it('skips undefined values during fallback', () => {
+ expect(
+ resolveResponsiveValue(
+ { xs: undefined, sm: 'small', md: undefined },
+ 'md',
+ ),
+ ).toBe('small');
+ });
+});
+
+describe('resolveDefinitionProps', () => {
+ it('separates own props from rest props based on propDefs keys', () => {
+ const definition = {
+ propDefs: { variant: {}, size: {} },
+ utilityProps: [],
+ styles: {},
+ classNames: {},
+ } as ComponentConfig
;
+
+ const { ownPropsResolved, restProps } = resolveDefinitionProps(
+ definition,
+ { variant: 'primary', size: 'large', 'aria-label': 'test' },
+ 'initial',
+ );
+
+ expect(ownPropsResolved).toEqual({ variant: 'primary', size: 'large' });
+ expect(restProps).toEqual({ 'aria-label': 'test' });
+ });
+
+ it('excludes utility props from rest props', () => {
+ const definition = {
+ propDefs: { variant: {} },
+ utilityProps: ['m', 'p'] as const,
+ styles: {},
+ classNames: {},
+ } as ComponentConfig;
+
+ const { restProps } = resolveDefinitionProps(
+ definition,
+ { variant: 'primary', m: '2', p: '4', 'aria-label': 'test' },
+ 'initial',
+ );
+
+ expect(restProps).toEqual({ 'aria-label': 'test' });
+ });
+
+ it('does not exclude utility props that are also in propDefs', () => {
+ const definition = {
+ propDefs: { gap: {} },
+ utilityProps: ['gap'] as const,
+ styles: {},
+ classNames: {},
+ } as ComponentConfig;
+
+ const { ownPropsResolved } = resolveDefinitionProps(
+ definition,
+ { gap: '4' },
+ 'initial',
+ );
+
+ expect(ownPropsResolved).toEqual({ gap: '4' });
+ });
+
+ it('applies default values from propDefs when prop is not provided', () => {
+ const definition = {
+ propDefs: { size: { default: 'medium' } },
+ utilityProps: [],
+ styles: {},
+ classNames: {},
+ } as ComponentConfig;
+
+ const { ownPropsResolved } = resolveDefinitionProps(
+ definition,
+ {},
+ 'initial',
+ );
+
+ expect(ownPropsResolved).toEqual({ size: 'medium' });
+ });
+
+ it('does not apply default when prop is explicitly provided', () => {
+ const definition = {
+ propDefs: { size: { default: 'medium' } },
+ utilityProps: [],
+ styles: {},
+ classNames: {},
+ } as ComponentConfig;
+
+ const { ownPropsResolved } = resolveDefinitionProps(
+ definition,
+ { size: 'large' },
+ 'initial',
+ );
+
+ expect(ownPropsResolved).toEqual({ size: 'large' });
+ });
+
+ it('preserves falsy prop values (false, 0, empty string) over defaults', () => {
+ const definition = {
+ propDefs: {
+ disabled: { default: true },
+ count: { default: 10 },
+ label: { default: 'default' },
+ },
+ utilityProps: [],
+ styles: {},
+ classNames: {},
+ } as ComponentConfig;
+
+ const { ownPropsResolved } = resolveDefinitionProps(
+ definition,
+ { disabled: false, count: 0, label: '' },
+ 'initial',
+ );
+
+ expect(ownPropsResolved).toEqual({
+ disabled: false,
+ count: 0,
+ label: '',
+ });
+ });
+
+ it('resolves responsive own prop values at the given breakpoint', () => {
+ const definition = {
+ propDefs: { variant: {} },
+ utilityProps: [],
+ styles: {},
+ classNames: {},
+ } as ComponentConfig;
+
+ const { ownPropsResolved } = resolveDefinitionProps(
+ definition,
+ { variant: { xs: 'small', md: 'large' } },
+ 'md',
+ );
+
+ expect(ownPropsResolved).toEqual({ variant: 'large' });
+ });
+
+ it('omits own props that are undefined and have no default', () => {
+ const definition = {
+ propDefs: { variant: {}, size: {} },
+ utilityProps: [],
+ styles: {},
+ classNames: {},
+ } as ComponentConfig;
+
+ const { ownPropsResolved } = resolveDefinitionProps(
+ definition,
+ { variant: 'primary' },
+ 'initial',
+ );
+
+ expect(ownPropsResolved).toEqual({ variant: 'primary' });
+ expect('size' in ownPropsResolved).toBe(false);
+ });
+
+ it('passes through rest props without responsive resolution', () => {
+ const definition = {
+ propDefs: { variant: {} },
+ utilityProps: [],
+ styles: {},
+ classNames: {},
+ } as ComponentConfig;
+
+ const responsiveObj = { xs: 'small', md: 'large' };
+ const { restProps } = resolveDefinitionProps(
+ definition,
+ { variant: 'primary', 'data-value': responsiveObj },
+ 'md',
+ );
+
+ expect(restProps['data-value']).toBe(responsiveObj);
+ });
+
+ it('returns empty ownPropsResolved when no props match propDefs', () => {
+ const definition = {
+ propDefs: { variant: {} },
+ utilityProps: [],
+ styles: {},
+ classNames: {},
+ } as ComponentConfig;
+
+ const { ownPropsResolved } = resolveDefinitionProps(
+ definition,
+ { 'aria-label': 'test' },
+ 'initial',
+ );
+
+ expect(ownPropsResolved).toEqual({});
+ });
+
+ it('returns empty restProps when all props are own or utility props', () => {
+ const definition = {
+ propDefs: { variant: {} },
+ utilityProps: ['m'] as const,
+ styles: {},
+ classNames: {},
+ } as ComponentConfig;
+
+ const { restProps } = resolveDefinitionProps(
+ definition,
+ { variant: 'primary', m: '2' },
+ 'initial',
+ );
+
+ expect(restProps).toEqual({});
+ });
+});
+
+describe('processUtilityProps', () => {
+ it('returns empty classes and style when no utility props are provided', () => {
+ const { utilityClasses, utilityStyle } = processUtilityProps({}, [
+ 'm',
+ 'p',
+ ]);
+ expect(utilityClasses).toBe('');
+ expect(utilityStyle).toEqual({});
+ });
+
+ it('returns empty classes and style when utility values are undefined', () => {
+ const { utilityClasses, utilityStyle } = processUtilityProps(
+ { m: undefined },
+ ['m'],
+ );
+ expect(utilityClasses).toBe('');
+ expect(utilityStyle).toEqual({});
+ });
+
+ it('returns empty classes and style when utility values are null', () => {
+ const { utilityClasses, utilityStyle } = processUtilityProps({ m: null }, [
+ 'm',
+ ]);
+ expect(utilityClasses).toBe('');
+ expect(utilityStyle).toEqual({});
+ });
+
+ it('generates a utility class for a predefined spacing value', () => {
+ const { utilityClasses } = processUtilityProps({ m: '2' }, ['m']);
+ expect(utilityClasses).toBe('bui-m-2');
+ });
+
+ it('generates a CSS custom property for a custom value', () => {
+ const { utilityStyle } = processUtilityProps({ width: '100px' }, ['width']);
+ expect(utilityStyle).toEqual({ '--width': '100px' });
+ });
+
+ it('generates both the class name and CSS var for custom values', () => {
+ const { utilityClasses, utilityStyle } = processUtilityProps(
+ { width: '100px' },
+ ['width'],
+ );
+ expect(utilityClasses).toBe('bui-w');
+ expect(utilityStyle).toEqual({ '--width': '100px' });
+ });
+
+ it('handles responsive utility values with breakpoint prefixes', () => {
+ const { utilityClasses } = processUtilityProps(
+ { m: { initial: '2', md: '4' } },
+ ['m'],
+ );
+ expect(utilityClasses).toContain('bui-m-2');
+ expect(utilityClasses).toContain('md:bui-m-4');
+ });
+
+ it('uses no prefix for the initial breakpoint in responsive values', () => {
+ const { utilityClasses } = processUtilityProps({ m: { initial: '2' } }, [
+ 'm',
+ ]);
+ expect(utilityClasses).toBe('bui-m-2');
+ });
+
+ it('applies the transform function (grow: true becomes 1)', () => {
+ const { utilityStyle } = processUtilityProps({ grow: true }, ['grow']);
+ expect(utilityStyle).toEqual({ '--grow': 1 });
+ });
+
+ it('applies the transform function (basis: 42 becomes "42px")', () => {
+ const { utilityStyle } = processUtilityProps({ basis: 42 }, ['basis']);
+ expect(utilityStyle).toEqual({ '--basis': '42px' });
+ });
+
+ it('ignores values not in the valid list for fixed-value props', () => {
+ const { utilityClasses, utilityStyle } = processUtilityProps(
+ { position: 'invalid' },
+ ['position'],
+ );
+ expect(utilityClasses).toBe('');
+ expect(utilityStyle).toEqual({});
+ });
+
+ it('combines classes and styles from multiple utility props', () => {
+ const { utilityClasses, utilityStyle } = processUtilityProps(
+ { m: '2', p: '4', width: '100px' },
+ ['m', 'p', 'width'],
+ );
+ expect(utilityClasses).toContain('bui-m-2');
+ expect(utilityClasses).toContain('bui-p-4');
+ expect(utilityClasses).toContain('bui-w');
+ expect(utilityStyle).toEqual({ '--width': '100px' });
+ });
+
+ it('handles a mix of predefined and custom values across different props', () => {
+ const { utilityClasses, utilityStyle } = processUtilityProps(
+ { m: '2', width: '100px', position: 'relative' },
+ ['m', 'width', 'position'],
+ );
+ expect(utilityClasses).toContain('bui-m-2');
+ expect(utilityClasses).toContain('bui-w');
+ expect(utilityClasses).toContain('bui-position-relative');
+ expect(utilityStyle).toEqual({ '--width': '100px' });
+ });
+});
diff --git a/packages/ui/src/hooks/useDefinition/useDefinition.test.tsx b/packages/ui/src/hooks/useDefinition/useDefinition.test.tsx
new file mode 100644
index 0000000000..b599b496ed
--- /dev/null
+++ b/packages/ui/src/hooks/useDefinition/useDefinition.test.tsx
@@ -0,0 +1,753 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { type PropsWithChildren, type ReactNode } from 'react';
+import { renderHook, render } from '@testing-library/react';
+import { MemoryRouter, Routes, Route } from 'react-router-dom';
+import { useDefinition } from './useDefinition';
+import type { ComponentConfig } from './types';
+import { BgProvider, useBgConsumer } from '../useBg';
+import { noopTracker } from '../../analytics/useAnalytics';
+import { BUIProvider } from '../../provider';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function BgReader() {
+ const { bg } = useBgConsumer();
+ return {bg ?? 'none'};
+}
+
+function Wrapper({ children }: PropsWithChildren) {
+ return {children};
+}
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const basicDef = {
+ styles: { root: 'css-root' },
+ classNames: { root: 'root' },
+ propDefs: {
+ variant: { dataAttribute: true } as const,
+ size: { dataAttribute: true, default: 'medium' } as const,
+ className: {},
+ },
+} as const satisfies ComponentConfig;
+
+const multiSlotDef = {
+ styles: { root: 'css-root', content: 'css-content' },
+ classNames: { root: 'root', content: 'content' },
+ propDefs: {
+ variant: { dataAttribute: true } as const,
+ className: {},
+ },
+ utilityProps: ['m'] as const,
+} as const satisfies ComponentConfig;
+
+const utilityDef = {
+ styles: { root: 'css-root' },
+ classNames: { root: 'root' },
+ propDefs: {
+ variant: {},
+ className: {},
+ },
+ utilityProps: ['m', 'p', 'width'] as const,
+} as const satisfies ComponentConfig;
+
+const providerDef = {
+ styles: { root: 'css-root' },
+ classNames: { root: 'root' },
+ propDefs: {
+ bg: { dataAttribute: true } as const,
+ children: {},
+ className: {},
+ },
+ bg: 'provider' as const,
+} as const satisfies ComponentConfig;
+
+const consumerDef = {
+ styles: { root: 'css-root' },
+ classNames: { root: 'root' },
+ propDefs: {
+ variant: {},
+ className: {},
+ },
+ bg: 'consumer' as const,
+} as const satisfies ComponentConfig;
+
+const analyticsDef = {
+ styles: { root: 'css-root' },
+ classNames: { root: 'root' },
+ propDefs: {
+ noTrack: {},
+ className: {},
+ },
+ analytics: true,
+} as const satisfies ComponentConfig;
+
+const hrefDef = {
+ styles: { root: 'css-root' },
+ classNames: { root: 'root' },
+ propDefs: {
+ href: {},
+ className: {},
+ },
+} as const satisfies ComponentConfig;
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+function createRouterWrapper({
+ basename,
+ currentRoute,
+}: {
+ basename?: string;
+ currentRoute: string;
+}) {
+ return function RouterWrapper({ children }: PropsWithChildren) {
+ const entry = basename ? `${basename}${currentRoute}` : currentRoute;
+ // Build nested Routes one level per path segment. The leaf route uses a
+ // non-splat path so that `..` resolution works correctly.
+ const segments =
+ currentRoute === '/'
+ ? []
+ : currentRoute.replace(/^\//, '').split('/').filter(Boolean);
+
+ const buildRoutes = (segs: string[], el: ReactNode): ReactNode => {
+ if (segs.length === 0) return ;
+ const [head, ...tail] = segs;
+ if (tail.length === 0) {
+ return ;
+ }
+ return {buildRoutes(tail, el)};
+ };
+
+ return (
+
+
+
+ {segments.length === 0 ? (
+
+ ) : (
+ buildRoutes(segments, children)
+ )}
+
+
+
+ );
+ };
+}
+
+describe('useDefinition', () => {
+ describe('prop resolution', () => {
+ it('returns resolved own props from propDefs', () => {
+ const { result } = renderHook(
+ () => useDefinition(basicDef, { variant: 'primary' }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps.variant).toBe('primary');
+ });
+
+ it('applies default values for missing own props', () => {
+ const { result } = renderHook(
+ () => useDefinition(basicDef, { variant: 'primary' }),
+ { wrapper: Wrapper },
+ );
+
+ expect((result.current.ownProps as any).size).toBe('medium');
+ });
+
+ it('returns rest props for props not in propDefs or utilityProps', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(basicDef, {
+ variant: 'primary',
+ 'aria-label': 'test',
+ }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.restProps).toEqual({ 'aria-label': 'test' });
+ });
+
+ it('excludes utility props from both ownProps and restProps', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(utilityDef, {
+ variant: 'primary',
+ m: '2',
+ 'aria-label': 'test',
+ }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps).not.toHaveProperty('m');
+ expect(result.current.restProps).not.toHaveProperty('m');
+ expect(result.current.restProps).toEqual({ 'aria-label': 'test' });
+ });
+ });
+
+ describe('classes', () => {
+ it('builds a classes object with keys matching definition.classNames', () => {
+ const { result } = renderHook(
+ () => useDefinition(multiSlotDef, { variant: 'primary' }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps.classes).toHaveProperty('root');
+ expect(result.current.ownProps.classes).toHaveProperty('content');
+ });
+
+ it('includes the base CSS class from definition.styles', () => {
+ const { result } = renderHook(
+ () => useDefinition(basicDef, { variant: 'primary' }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps.classes.root).toContain('css-root');
+ });
+
+ it('appends user className to the root slot by default', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(basicDef, {
+ variant: 'primary',
+ className: 'custom',
+ }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps.classes.root).toContain('custom');
+ });
+
+ it('appends utility classes to the root slot by default', () => {
+ const { result } = renderHook(
+ () => useDefinition(utilityDef, { variant: 'primary', m: '2' }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps.classes.root).toContain('bui-m-2');
+ });
+
+ it('appends user className to a custom classNameTarget slot', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(
+ multiSlotDef,
+ { variant: 'primary', className: 'custom' },
+ {
+ classNameTarget: 'content',
+ },
+ ),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps.classes.content).toContain('custom');
+ expect(result.current.ownProps.classes.root).not.toContain('custom');
+ });
+
+ it('appends utility classes to a custom utilityTarget slot', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(
+ multiSlotDef,
+ { variant: 'primary', m: '2' },
+ {
+ utilityTarget: 'content',
+ },
+ ),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps.classes.content).toContain('bui-m-2');
+ expect(result.current.ownProps.classes.root).not.toContain('bui-m-2');
+ });
+
+ it('does not append user className when classNameTarget is null', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(
+ basicDef,
+ { variant: 'primary', className: 'custom' },
+ {
+ classNameTarget: null,
+ },
+ ),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps.classes.root).not.toContain('custom');
+ });
+
+ it('does not append utility classes when utilityTarget is null', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(
+ utilityDef,
+ { variant: 'primary', m: '2' },
+ {
+ utilityTarget: null,
+ },
+ ),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps.classes.root).not.toContain('bui-m-2');
+ });
+
+ it('keeps non-targeted slots free of utility classes and user className', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(multiSlotDef, {
+ variant: 'primary',
+ m: '2',
+ className: 'custom',
+ }),
+ { wrapper: Wrapper },
+ );
+
+ // Defaults target root â content should be clean
+ expect(result.current.ownProps.classes.content).not.toContain('bui-m-2');
+ expect(result.current.ownProps.classes.content).not.toContain('custom');
+ });
+ });
+
+ describe('data attributes', () => {
+ it('generates data-* attributes for props with dataAttribute: true', () => {
+ const { result } = renderHook(
+ () => useDefinition(basicDef, { variant: 'primary' }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.dataAttributes['data-variant']).toBe('primary');
+ });
+
+ it('does not generate data-* for props without dataAttribute', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(basicDef, {
+ variant: 'primary',
+ className: 'custom',
+ }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.dataAttributes).not.toHaveProperty(
+ 'data-classname',
+ );
+ });
+
+ it('omits data-* when the prop value is undefined', () => {
+ const { result } = renderHook(() => useDefinition(basicDef, {}), {
+ wrapper: Wrapper,
+ });
+
+ expect(result.current.dataAttributes).not.toHaveProperty('data-variant');
+ });
+
+ it('stringifies non-string prop values in data attributes', () => {
+ const numericDef = {
+ styles: { root: 'css-root' },
+ classNames: { root: 'root' },
+ propDefs: {
+ count: { dataAttribute: true } as const,
+ className: {},
+ },
+ } as const satisfies ComponentConfig;
+
+ const { result } = renderHook(
+ () => useDefinition(numericDef, { count: 42 }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.dataAttributes['data-count']).toBe('42');
+ });
+
+ it('does not generate data-bg from propDefs when bg=provider (uses provider path instead)', () => {
+ const localProviderDef = {
+ styles: { root: 'css-root' },
+ classNames: { root: 'root' },
+ propDefs: {
+ bg: { dataAttribute: true } as const,
+ children: {},
+ className: {},
+ },
+ bg: 'provider' as const,
+ } as const satisfies ComponentConfig;
+
+ const { result } = renderHook(
+ () =>
+ useDefinition(localProviderDef, {
+ bg: 'danger',
+ children: null,
+ }),
+ { wrapper: Wrapper },
+ );
+
+ // data-bg comes from the provider resolution path, not the propDef
+ expect(result.current.dataAttributes['data-bg']).toBe('danger');
+ });
+ });
+
+ describe('bg: provider', () => {
+ it('sets data-bg from the resolved provider bg value', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(providerDef, {
+ bg: 'danger',
+ children: null,
+ }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.dataAttributes['data-bg']).toBe('danger');
+ });
+
+ it('does not set data-bg when bg prop is undefined', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(providerDef, {
+ children: null,
+ }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.dataAttributes).not.toHaveProperty('data-bg');
+ });
+
+ it('adds childrenWithBgProvider to ownProps', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(providerDef, {
+ bg: 'neutral',
+ children: 'hello',
+ }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps).toHaveProperty('childrenWithBgProvider');
+ });
+
+ it('wraps children in BgProvider when bg resolves to a value', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(providerDef, {
+ bg: 'neutral',
+ children: ,
+ }),
+ { wrapper: Wrapper },
+ );
+
+ const { getByTestId } = render(
+ <>{result.current.ownProps.childrenWithBgProvider}>,
+ );
+
+ expect(getByTestId('bg')).toHaveTextContent('neutral-1');
+ });
+
+ it('returns raw children as childrenWithBgProvider when bg is undefined', () => {
+ const childContent = hello;
+ const { result } = renderHook(
+ () =>
+ useDefinition(providerDef, {
+ children: childContent,
+ }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps.childrenWithBgProvider).toBe(childContent);
+ });
+
+ it('increments neutral bg level from parent context', () => {
+ const wrapper = ({ children }: PropsWithChildren) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(
+ () =>
+ useDefinition(providerDef, {
+ bg: 'neutral',
+ children: ,
+ }),
+ { wrapper },
+ );
+
+ expect(result.current.dataAttributes['data-bg']).toBe('neutral-2');
+ });
+ });
+
+ describe('bg: consumer', () => {
+ it('sets data-on-bg from parent BgProvider context', () => {
+ const wrapper = ({ children }: PropsWithChildren) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(
+ () => useDefinition(consumerDef, { variant: 'primary' }),
+ { wrapper },
+ );
+
+ expect(result.current.dataAttributes['data-on-bg']).toBe('neutral-1');
+ });
+
+ it('does not set data-on-bg when no parent BgProvider exists', () => {
+ const { result } = renderHook(
+ () => useDefinition(consumerDef, { variant: 'primary' }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.dataAttributes).not.toHaveProperty('data-on-bg');
+ });
+
+ it('returns children (not childrenWithBgProvider) in ownProps', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(consumerDef, { variant: 'primary', children: 'hello' }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps).toHaveProperty('children', 'hello');
+ expect(result.current.ownProps).not.toHaveProperty(
+ 'childrenWithBgProvider',
+ );
+ });
+ });
+
+ describe('no bg config', () => {
+ it('does not set data-bg or data-on-bg', () => {
+ const { result } = renderHook(
+ () => useDefinition(basicDef, { variant: 'primary' }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.dataAttributes).not.toHaveProperty('data-bg');
+ expect(result.current.dataAttributes).not.toHaveProperty('data-on-bg');
+ });
+
+ it('returns children in ownProps', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(basicDef, { variant: 'primary', children: 'hello' }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps).toHaveProperty('children', 'hello');
+ });
+ });
+
+ describe('utility style', () => {
+ it('returns utilityStyle with CSS custom properties from utility props', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(utilityDef, {
+ variant: 'primary',
+ width: '100px',
+ }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.utilityStyle).toEqual({ '--width': '100px' });
+ });
+
+ it('returns empty utilityStyle when no utility props are provided', () => {
+ const { result } = renderHook(
+ () => useDefinition(utilityDef, { variant: 'primary' }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.utilityStyle).toEqual({});
+ });
+ });
+
+ describe('analytics', () => {
+ it('returns analytics tracker when definition.analytics is true', () => {
+ const { result } = renderHook(() => useDefinition(analyticsDef, {}), {
+ wrapper: Wrapper,
+ });
+
+ expect(result.current).toHaveProperty('analytics');
+ expect(result.current.analytics).toHaveProperty('captureEvent');
+ });
+
+ it('does not include analytics key when definition.analytics is not set', () => {
+ const { result } = renderHook(
+ () => useDefinition(basicDef, { variant: 'primary' }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current).not.toHaveProperty('analytics');
+ });
+
+ it('returns noopTracker when noTrack is true', () => {
+ const { result } = renderHook(
+ () => useDefinition(analyticsDef, { noTrack: true }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.analytics).toBe(noopTracker);
+ });
+ });
+
+ describe('href resolution', () => {
+ describe('inside router context', () => {
+ describe.each([
+ ['no basename', undefined],
+ ['with basename /app', '/app'],
+ ] as const)('%s', (_label, basename) => {
+ it.each`
+ description | href | currentRoute | expected
+ ${'absolute path'} | ${'/foo'} | ${'/catalog'} | ${'/foo'}
+ ${'root /'} | ${'/'} | ${'/catalog'} | ${'/'}
+ ${'relative path "foo"'} | ${'foo'} | ${'/catalog'} | ${'/catalog/foo'}
+ ${'relative path "./foo"'} | ${'./foo'} | ${'/catalog'} | ${'/catalog/foo'}
+ ${'relative path "../foo"'} | ${'../foo'} | ${'/catalog/items'} | ${'/catalog/foo'}
+ ${'empty string'} | ${''} | ${'/catalog'} | ${'/catalog'}
+ ${'absolute with query params'} | ${'/foo?q=1'} | ${'/catalog'} | ${'/foo?q=1'}
+ ${'absolute with hash'} | ${'/foo#section'} | ${'/catalog'} | ${'/foo#section'}
+ ${'absolute with query and hash'} | ${'/foo?q=1#section'} | ${'/catalog'} | ${'/foo?q=1#section'}
+ ${'relative with query params'} | ${'foo?q=1'} | ${'/catalog'} | ${'/catalog/foo?q=1'}
+ `(
+ 'resolves $description â returns $expected',
+ ({
+ href,
+ currentRoute,
+ expected,
+ }: {
+ href: string;
+ currentRoute: string;
+ expected: string;
+ }) => {
+ const { result } = renderHook(
+ () => useDefinition(hrefDef, { href }),
+ {
+ wrapper: createRouterWrapper({ basename, currentRoute }),
+ },
+ );
+
+ expect(result.current.ownProps.href).toBe(expected);
+ },
+ );
+
+ it.each`
+ description | href
+ ${'https:// URL'} | ${'https://example.com'}
+ ${'http:// URL'} | ${'http://example.com'}
+ ${'mailto: link'} | ${'mailto:a@b.com'}
+ ${'tel: link'} | ${'tel:123'}
+ ${'protocol-relative URL'} | ${'//example.com'}
+ `('leaves $description unchanged', ({ href }: { href: string }) => {
+ const { result } = renderHook(
+ () => useDefinition(hrefDef, { href }),
+ {
+ wrapper: createRouterWrapper({
+ basename,
+ currentRoute: '/catalog',
+ }),
+ },
+ );
+
+ expect(result.current.ownProps.href).toBe(href);
+ });
+
+ it('does not modify props when href is undefined', () => {
+ const { result } = renderHook(() => useDefinition(hrefDef, {}), {
+ wrapper: createRouterWrapper({
+ basename,
+ currentRoute: '/catalog',
+ }),
+ });
+
+ expect(result.current.ownProps).not.toHaveProperty('href');
+ });
+ });
+ });
+
+ describe('outside router context', () => {
+ it('passes all href values through unchanged', () => {
+ const { result } = renderHook(
+ () => useDefinition(hrefDef, { href: '/foo' }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps.href).toBe('/foo');
+ });
+ });
+ });
+
+ describe('options', () => {
+ it('utilityTarget defaults to root', () => {
+ const { result } = renderHook(
+ () => useDefinition(multiSlotDef, { variant: 'primary', m: '2' }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps.classes.root).toContain('bui-m-2');
+ expect(result.current.ownProps.classes.content).not.toContain('bui-m-2');
+ });
+
+ it('classNameTarget defaults to root', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(multiSlotDef, {
+ variant: 'primary',
+ className: 'custom',
+ }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps.classes.root).toContain('custom');
+ expect(result.current.ownProps.classes.content).not.toContain('custom');
+ });
+
+ it('custom utilityTarget applies utility classes to that slot', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(
+ multiSlotDef,
+ { variant: 'primary', m: '2' },
+ { utilityTarget: 'content' },
+ ),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps.classes.content).toContain('bui-m-2');
+ expect(result.current.ownProps.classes.root).not.toContain('bui-m-2');
+ });
+
+ it('custom classNameTarget applies className to that slot', () => {
+ const { result } = renderHook(
+ () =>
+ useDefinition(
+ multiSlotDef,
+ { variant: 'primary', className: 'custom' },
+ { classNameTarget: 'content' },
+ ),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.ownProps.classes.content).toContain('custom');
+ expect(result.current.ownProps.classes.root).not.toContain('custom');
+ });
+ });
+});
diff --git a/packages/ui/src/hooks/useDefinition/useDefinition.tsx b/packages/ui/src/hooks/useDefinition/useDefinition.tsx
index a791343c15..1382b352f7 100644
--- a/packages/ui/src/hooks/useDefinition/useDefinition.tsx
+++ b/packages/ui/src/hooks/useDefinition/useDefinition.tsx
@@ -21,7 +21,12 @@ import { useBgProvider, useBgConsumer, BgProvider } from '../useBg';
import { resolveDefinitionProps, processUtilityProps } from './helpers';
import { useAnalytics } from '../../analytics/useAnalytics';
import { noopTracker } from '../../analytics/useAnalytics';
-import { useInRouterContext, useHref } from 'react-router-dom';
+import {
+ useResolvedPath,
+ useInRouterContext,
+ createPath,
+} from 'react-router-dom';
+import { isExternalLink } from '../../utils/linkUtils';
import type {
ComponentConfig,
UseDefinitionOptions,
@@ -39,17 +44,13 @@ export function useDefinition<
): UseDefinitionResult {
const { breakpoint } = useBreakpoint();
- // Turn relative href into an absolute path using the current route
- // context, so that client-side navigation works correctly.
let hrefResolvedProps = props;
const hasRouter = useInRouterContext();
- // useHref throws outside a Router, so we guard with useInRouterContext.
- // The guard is safe because a component's router context does not
- // change during its lifetime, keeping the hook call count stable.
if (hasRouter) {
- const absoluteHref = useHref((props as any).href ?? '');
- if ((props as any).href !== undefined) {
- hrefResolvedProps = { ...props, href: absoluteHref } as P;
+ const rawHref = (props as any).href;
+ const resolved = useResolvedPath(rawHref ?? '');
+ if (rawHref !== undefined && !isExternalLink(rawHref)) {
+ hrefResolvedProps = { ...props, href: createPath(resolved) } as P;
}
}
@@ -106,8 +107,10 @@ export function useDefinition<
analytics = ownPropsResolved.noTrack ? noopTracker : tracker;
}
- const utilityTarget = options?.utilityTarget ?? 'root';
- const classNameTarget = options?.classNameTarget ?? 'root';
+ const utilityTarget =
+ options?.utilityTarget !== undefined ? options.utilityTarget : 'root';
+ const classNameTarget =
+ options?.classNameTarget !== undefined ? options.classNameTarget : 'root';
const classes: Record = {};
diff --git a/packages/ui/src/hooks/useResolvedHref.ts b/packages/ui/src/hooks/useResolvedHref.ts
new file mode 100644
index 0000000000..3e5075ed1d
--- /dev/null
+++ b/packages/ui/src/hooks/useResolvedHref.ts
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useHref, useInRouterContext } from 'react-router-dom';
+import { isExternalLink } from '../utils/linkUtils';
+
+/**
+ * Resolves an href for rendering. External URLs are returned unchanged;
+ * internal paths are resolved through react-router's useHref so they
+ * respect the current basename and route context.
+ *
+ * @internal
+ */
+export function useResolvedHref(href: string): string;
+export function useResolvedHref(href: string | undefined): string | undefined;
+export function useResolvedHref(href: string | undefined): string | undefined {
+ const hasRouter = useInRouterContext();
+ // useHref throws outside a Router, so we guard with useInRouterContext.
+ // The guard is safe because a component's router context does not
+ // change during its lifetime, keeping the hook call count stable.
+ if (!hasRouter) {
+ return href;
+ }
+ const resolved = useHref(href ?? '');
+ if (!href || isExternalLink(href)) {
+ return href;
+ }
+ return resolved;
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index a1584c1718..bc29073767 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -34,6 +34,7 @@ export * from './components/Avatar';
export * from './components/Badge';
export * from './components/Button';
export * from './components/Card';
+export * from './components/DatePicker';
export * from './components/DateRangePicker';
export * from './components/Dialog';
export * from './components/FieldLabel';
@@ -43,6 +44,7 @@ export * from './components/ButtonIcon';
export * from './components/ButtonLink';
export * from './components/Checkbox';
export * from './components/CheckboxGroup';
+export * from './components/Combobox';
export * from './components/RadioGroup';
export * from './components/Slider';
export * from './components/Table';
diff --git a/packages/ui/src/provider/BUIProvider.tsx b/packages/ui/src/provider/BUIProvider.tsx
index 6576a9eabb..cc1d79774d 100644
--- a/packages/ui/src/provider/BUIProvider.tsx
+++ b/packages/ui/src/provider/BUIProvider.tsx
@@ -16,9 +16,10 @@
import { useMemo, type ReactNode } from 'react';
import { RouterProvider } from 'react-aria-components';
-import { useInRouterContext, useNavigate, useHref } from 'react-router-dom';
+import { useInRouterContext, useNavigate } from 'react-router-dom';
import { createVersionedValueMap } from '@backstage/version-bridge';
import { BUIContext } from '../analytics/useAnalytics';
+import { useResolvedHref } from '../hooks/useResolvedHref';
import type { UseAnalyticsFn } from '../analytics/types';
/** @public */
@@ -70,7 +71,7 @@ export function BUIProvider(props: BUIProviderProps) {
function RoutedContent({ children }: { children: ReactNode }) {
const navigate = useNavigate();
return (
-
+
{children}
);
diff --git a/packages/ui/src/recipes/PluginHeaderAndHeader.stories.tsx b/packages/ui/src/recipes/PluginHeaderAndHeader.stories.tsx
index a43252a9b7..66e659e28c 100644
--- a/packages/ui/src/recipes/PluginHeaderAndHeader.stories.tsx
+++ b/packages/ui/src/recipes/PluginHeaderAndHeader.stories.tsx
@@ -15,6 +15,10 @@
*/
import preview from '../../../../.storybook/preview';
+import {
+ Header as CoreHeader,
+ Page as CorePage,
+} from '@backstage/core-components';
import type { StoryFn } from '@storybook/react-vite';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../provider';
@@ -49,7 +53,7 @@ import {
// ---------------------------------------------------------------------------
const PageContent = () => (
-
+
@@ -58,6 +62,19 @@ const PageContent = () => (
);
+const LongPageContent = () => (
+
+
+
+
+
+
+ {Array.from({ length: 40 }, (_, i) => (
+
+ ))}
+
+);
+
// ---------------------------------------------------------------------------
// Shared layout decorator
// ---------------------------------------------------------------------------
@@ -91,6 +108,28 @@ export const NoHeader = meta.story({
),
});
+export const NoHeaderWithTabs = meta.story({
+ decorators: [withLayout],
+ render: () => (
+ <>
+ }
+ title="APIs"
+ tabs={[
+ { id: 'overview', label: 'Overview', href: '/apis' },
+ {
+ id: 'definitions',
+ label: 'Definitions',
+ href: '/apis/definitions',
+ },
+ { id: 'consumers', label: 'Consumers', href: '/apis/consumers' },
+ ]}
+ />
+
+ >
+ ),
+});
+
export const SimpleHeader = meta.story({
decorators: [withLayout],
render: () => (
@@ -102,6 +141,44 @@ export const SimpleHeader = meta.story({
),
});
+export const CoreComponentsHeader = meta.story({
+ decorators: [withLayout],
+ render: () => (
+ <>
+ } title="APIs" />
+
+
+
+
+ >
+ ),
+});
+
+export const CoreComponentsHeaderWithTabs = meta.story({
+ decorators: [withLayout],
+ render: () => (
+ <>
+ }
+ title="APIs"
+ tabs={[
+ { id: 'overview', label: 'Overview', href: '/apis' },
+ {
+ id: 'definitions',
+ label: 'Definitions',
+ href: '/apis/definitions',
+ },
+ { id: 'consumers', label: 'Consumers', href: '/apis/consumers' },
+ ]}
+ />
+
+
+
+
+ >
+ ),
+});
+
export const WithTabs = meta.story({
decorators: [withLayout],
render: () => (
@@ -157,52 +234,6 @@ export const WithTabs = meta.story({
),
});
-export const WithBreadcrumb = meta.story({
- decorators: [withLayout],
- render: () => (
- <>
- }
- title="CI/CD"
- titleLink="/"
- tabs={[
- { id: 'builds', label: 'Builds', href: '/builds' },
- { id: 'pipelines', label: 'Pipelines', href: '/pipelines' },
- { id: 'deployments', label: 'Deployments', href: '/deployments' },
- { id: 'settings', label: 'Settings', href: '/settings' },
- ]}
- customActions={
- <>
- }
- aria-label="Refresh"
- />
- >
- }
- />
-
- }>
- Download logs
-
- }>
- Re-run pipeline
-
- >
- }
- />
-
- >
- ),
-});
-
const subTabs: HeaderNavTabItem[] = [
{ id: 'summary', label: 'Summary', href: '/summary' },
{ id: 'steps', label: 'Steps', href: '/steps' },
@@ -244,11 +275,65 @@ export const WithSubTabs = meta.story({
}
/>
+ }>
+ Download logs
+
+ }>
+ Re-run pipeline
+
+ >
+ }
+ />
+
+ >
+ );
+ },
+});
+
+export const WithSubTabsAndTags = meta.story({
+ decorators: [
+ (Story: StoryFn) => (
+
+
+
+
+
+ ),
+ ],
+ render: () => {
+ return (
+ <>
+ }
+ title="CI/CD"
+ titleLink="/"
+ tabs={[
+ { id: 'builds', label: 'Builds', href: '/builds' },
+ { id: 'pipelines', label: 'Pipelines', href: '/pipelines' },
+ { id: 'deployments', label: 'Deployments', href: '/deployments' },
+ { id: 'settings', label: 'Settings', href: '/settings' },
+ ]}
+ customActions={
+ <>
+ }
+ aria-label="Refresh"
+ />
+ >
+ }
+ />
+
@@ -266,3 +351,57 @@ export const WithSubTabs = meta.story({
);
},
});
+
+export const WithStickyHeader = meta.story({
+ decorators: [
+ (Story: StoryFn) => (
+
+
+
+
+
+ ),
+ ],
+ render: () => {
+ return (
+ <>
+ }
+ title="CI/CD"
+ titleLink="/"
+ tabs={[
+ { id: 'builds', label: 'Builds', href: '/builds' },
+ { id: 'pipelines', label: 'Pipelines', href: '/pipelines' },
+ { id: 'deployments', label: 'Deployments', href: '/deployments' },
+ { id: 'settings', label: 'Settings', href: '/settings' },
+ ]}
+ customActions={
+ <>
+ }
+ aria-label="Refresh"
+ />
+ >
+ }
+ />
+
+ }>
+ Download logs
+
+ }>
+ Re-run pipeline
+
+ >
+ }
+ />
+
+ >
+ );
+ },
+});
diff --git a/packages/ui/src/setupTests.ts b/packages/ui/src/setupTests.ts
new file mode 100644
index 0000000000..5f11bab1c2
--- /dev/null
+++ b/packages/ui/src/setupTests.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '@testing-library/jest-dom';
diff --git a/packages/yarn-plugin/CHANGELOG.md b/packages/yarn-plugin/CHANGELOG.md
index e46866dce0..2ad6867f03 100644
--- a/packages/yarn-plugin/CHANGELOG.md
+++ b/packages/yarn-plugin/CHANGELOG.md
@@ -1,5 +1,13 @@
# yarn-plugin-backstage
+## 0.0.12
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/errors@1.3.1
+ - @backstage/cli-common@0.2.2
+
## 0.0.12-next.0
### Patch Changes
diff --git a/packages/yarn-plugin/package.json b/packages/yarn-plugin/package.json
index 9d5354c6b3..b862e94abc 100644
--- a/packages/yarn-plugin/package.json
+++ b/packages/yarn-plugin/package.json
@@ -1,6 +1,6 @@
{
"name": "yarn-plugin-backstage",
- "version": "0.0.12-next.0",
+ "version": "0.0.12",
"description": "Yarn plugin for working with Backstage monorepos",
"backstage": {
"role": "node-library"
diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md
index 390f921a89..f0bbce5604 100644
--- a/plugins/api-docs/CHANGELOG.md
+++ b/plugins/api-docs/CHANGELOG.md
@@ -1,5 +1,34 @@
# @backstage/plugin-api-docs
+## 0.14.1
+
+### Patch Changes
+
+- f635139: Limited `@remixicon/react` dependency to versions below 4.9.0 due to a license change in that release.
+- 44d77e9: Removed separate nav item extensions. Sidebar entries are now provided via `title` and `icon` on each plugin's page extension.
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/core-components@0.18.10
+ - @backstage/ui@0.15.0
+ - @backstage/plugin-catalog@2.0.5
+ - @backstage/frontend-plugin-api@0.17.0
+ - @backstage/core-plugin-api@1.12.6
+ - @backstage/plugin-catalog-react@3.0.0
+ - @backstage/plugin-catalog-common@1.1.10
+ - @backstage/plugin-permission-react@0.5.1
+
+## 0.14.1-next.1
+
+### Patch Changes
+
+- f635139: Limited `@remixicon/react` dependency to versions below 4.9.0 due to a license change in that release.
+- Updated dependencies
+ - @backstage/ui@0.15.0-next.1
+ - @backstage/frontend-plugin-api@0.17.0-next.1
+ - @backstage/catalog-model@1.8.1-next.1
+ - @backstage/core-plugin-api@1.12.6-next.1
+ - @backstage/plugin-catalog-react@2.1.5-next.1
+
## 0.14.1-next.0
### Patch Changes
diff --git a/plugins/api-docs/README-alpha.md b/plugins/api-docs/README-alpha.md
index a4c3979787..939999aa71 100644
--- a/plugins/api-docs/README-alpha.md
+++ b/plugins/api-docs/README-alpha.md
@@ -32,7 +32,6 @@ To link that a component provides or consumes an API, see the [`providesApis`](h
- [Packages](#packages)
- [Routes](#routes)
- [Extensions](#extensions)
- - [Apis Nav Item](#apis-nav-item)
- [Apis Explorer Page](#apis-explore-page)
- [Apis Entity Cards](#apis-entities-cards)
- [Has Apis Entity Card](#has-apis-entity-card)
@@ -135,94 +134,6 @@ Route binding is also possible through code. For more information, see [this](ht
### Extensions
-#### Apis Nav Item
-
-This [nav item](https://backstage.io/docs/reference/frontend-plugin-api.createnavitemextension) extension adds a link to the Apis Explorer page in the main app sidebar.
-
-| Kind | Namespace | Name | Id |
-| ---------- | ---------- | ---- | ------------------- |
-| `nav-item` | `api-docs` | - | `nav-item:api-docs` |
-
-##### Disable
-
-This extension is enabled by default when you install the `api-docs` plugin, but you can disable it and prevent it from showing up in the sidebar by setting the following configuration:
-
-```yaml
-# app-config.yaml
-app:
- extensions:
- # this is the extension id and it follows the naming pattern bellow:
- # /:
- # example disabling the apis docs nav item extension
- - nav-item:api-docs: false
- # or
- # - nav-item:api-docs:
- # disabled: true
-```
-
-To enable the extension again, simple remove the previous `nav-item:api-docs: false` configuration or do:
-
-```yaml
-# app-config.yaml
-app:
- extensions:
- # this is the extension id and it follows the naming pattern bellow:
- # /:
- - nav-item:api-docs
- # or
- # - nav-item:api-docs: true
- # or
- # - nav-item:api-docs:
- # disabled: false
-```
-
-##### Config
-
-The apis nav item can be customized under the `app.extensions.nav-item:api-docs.config` key in `app-config.yaml`. Configurations include:
-
-```yaml
-# app-config.yaml
-# example configuring the apis docs nav item extension
-app:
- extensions:
- # this is the extension id and it follows the naming pattern bellow:
- # /:
- - nav-item:api-docs:
- config:
- # The nav item title text, defaults to "APIs"
- title: 'Apis Explorer'
- # The nav item path text, defaults to "/api-docs"
- path: '/apis-explorer'
-```
-
-##### Override
-
-The apis nav item icon can only be changed by overriding the extension, as the icon cannot be changed via the `app-config.yaml` file.
-
-Here is an example overriding the nav item extension with a custom icon component:
-
-```tsx
-import {
- createFrontendModule,
- createNavItemExtension,
-} from '@backstage/backstage-plugin-api';
-import { MyCustomApiDocsIcon } from './components';
-
-export default createFrontendModule({
- pluginId: 'api-docs',
- extensions: [
- createNavItemExtension({
- // It's your choice whether to use the original extension's title or a different one
- title: 'APIs',
- // Setting a custom icon component
- icon: MyCustomApiDocsIcon,
- }),
- ],
-});
-```
-
-For more information about where to place extension overrides, see the official [documentation](https://backstage.io/docs/frontend-system/architecture/extension-overrides).
-
#### Apis Explore Page
This `api-docs` plugin installs an "Apis Explore" page extension that helps you visualize apis registered in the Backstage software catalog.
@@ -235,9 +146,6 @@ This `api-docs` plugin installs an "Apis Explore" page extension that helps you
The explore page extension is enable by default when you install the `api-docs` plugin, for disabling it, set the configuration below:
-> [!CAUTION]
-> The `api-docs` plugin also install a sidebar item that points to this page, remember to disable the nav item as well otherwise it will point to a not found page.
-
```yaml
# app-config.yaml
# example disabling the apis docs explorer page
diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json
index 16bace2eca..85d267d861 100644
--- a/plugins/api-docs/package.json
+++ b/plugins/api-docs/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-api-docs",
- "version": "0.14.1-next.0",
+ "version": "0.14.1",
"description": "A Backstage plugin that helps represent API entities in the frontend",
"backstage": {
"role": "frontend-plugin",
@@ -67,7 +67,7 @@
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.61",
- "@remixicon/react": "^4.6.0",
+ "@remixicon/react": ">=4.6.0 <4.9.0",
"graphiql": "^3.9.0",
"graphql": "^16.0.0",
"graphql-ws": "^5.4.1",
diff --git a/plugins/api-docs/report-alpha.api.md b/plugins/api-docs/report-alpha.api.md
index 630e6aef89..4e3930d765 100644
--- a/plugins/api-docs/report-alpha.api.md
+++ b/plugins/api-docs/report-alpha.api.md
@@ -15,7 +15,6 @@ import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/core-plugin-api';
import { FilterPredicate } from '@backstage/filter-predicates';
-import { IconComponent } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { JSXElementConstructor } from 'react';
@@ -472,31 +471,6 @@ const _default: OverridableFrontendPlugin<
filter?: string | FilterPredicate | ((entity: Entity) => boolean);
};
}>;
- 'nav-item:api-docs': OverridableExtensionDefinition<{
- kind: 'nav-item';
- name: undefined;
- config: {
- title: string | undefined;
- };
- configInput: {
- title?: string | undefined;
- };
- output: ExtensionDataRef<
- {
- title: string;
- icon: IconComponent;
- routeRef: RouteRef_2;
- },
- 'core.nav-item.target',
- {}
- >;
- inputs: {};
- params: {
- title: string;
- icon: IconComponent;
- routeRef: RouteRef_2;
- };
- }>;
'page:api-docs': OverridableExtensionDefinition<{
config: {
initiallySelectedFilter: 'all' | 'owned' | 'starred' | undefined;
diff --git a/plugins/api-docs/src/alpha.tsx b/plugins/api-docs/src/alpha.tsx
index af4190bd13..b810be016e 100644
--- a/plugins/api-docs/src/alpha.tsx
+++ b/plugins/api-docs/src/alpha.tsx
@@ -18,7 +18,6 @@ import Grid from '@material-ui/core/Grid';
import {
ApiBlueprint,
- NavItemBlueprint,
PageBlueprint,
createFrontendPlugin,
} from '@backstage/frontend-plugin-api';
@@ -39,14 +38,6 @@ import {
EntityContentBlueprint,
} from '@backstage/plugin-catalog-react/alpha';
-const apiDocsNavItem = NavItemBlueprint.make({
- params: {
- title: 'APIs',
- routeRef: rootRoute,
- icon: () => ,
- },
-});
-
const apiDocsConfigApi = ApiBlueprint.make({
name: 'config',
params: defineParams =>
@@ -76,6 +67,8 @@ const apiDocsExplorerPage = PageBlueprint.makeWithOverrides({
return originalFactory({
path: '/api-docs',
routeRef: rootRoute,
+ title: 'APIs',
+ icon: ,
loader: () =>
import('./components/ApiExplorerPage/DefaultApiExplorerPage').then(
m => (
@@ -222,7 +215,6 @@ export default createFrontendPlugin({
registerApi: registerComponentRouteRef,
},
extensions: [
- apiDocsNavItem,
apiDocsConfigApi,
apiDocsExplorerPage,
apiDocsHasApisEntityCard,
diff --git a/plugins/app-backend/CHANGELOG.md b/plugins/app-backend/CHANGELOG.md
index f75147fe3a..0ed2c5904a 100644
--- a/plugins/app-backend/CHANGELOG.md
+++ b/plugins/app-backend/CHANGELOG.md
@@ -1,5 +1,19 @@
# @backstage/plugin-app-backend
+## 0.5.14
+
+### Patch Changes
+
+- 744fa1f: Removed duplicated entries that appeared in both `dependencies` and `devDependencies`.
+- 0c5e41f: Removed unused dependencies that had no imports in source code.
+- Updated dependencies
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+ - @backstage/config@1.3.8
+ - @backstage/config-loader@1.10.11
+ - @backstage/plugin-app-node@0.1.45
+
## 0.5.14-next.0
### Patch Changes
diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json
index 309cf63d4c..cc16bcd431 100644
--- a/plugins/app-backend/package.json
+++ b/plugins/app-backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-app-backend",
- "version": "0.5.14-next.0",
+ "version": "0.5.14",
"description": "A Backstage backend plugin that serves the Backstage frontend app",
"backstage": {
"role": "backend-plugin",
diff --git a/plugins/app-backend/src/lib/assets/StaticAssetsStore.test.ts b/plugins/app-backend/src/lib/assets/StaticAssetsStore.test.ts
index bd7a01e5e8..c796d0acb2 100644
--- a/plugins/app-backend/src/lib/assets/StaticAssetsStore.test.ts
+++ b/plugins/app-backend/src/lib/assets/StaticAssetsStore.test.ts
@@ -19,12 +19,12 @@ import { StaticAssetsStore } from './StaticAssetsStore';
jest.setTimeout(60_000);
-describe('StaticAssetsStore', () => {
- const databases = TestDatabases.create();
+const databases = TestDatabases.create();
- it.each(databases.eachSupportedId())(
- 'should store and retrieve assets, %p',
- async databaseId => {
+describe.each(databases.eachSupportedId())(
+ 'StaticAssetsStore, %p',
+ databaseId => {
+ it('should store and retrieve assets', async () => {
const knex = await databases.init(databaseId);
const store = await StaticAssetsStore.create({
@@ -61,12 +61,9 @@ describe('StaticAssetsStore', () => {
await expect(
store.getAsset('does-not-exist.txt'),
).resolves.toBeUndefined();
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'should update assets timestamps, but not contents, %p',
- async databaseId => {
+ it('should update assets timestamps, but not contents', async () => {
const knex = await databases.init(databaseId);
const store = await StaticAssetsStore.create({
@@ -112,12 +109,9 @@ describe('StaticAssetsStore', () => {
const sameBar = await store.getAsset('bar');
expect(oldBar!.lastModifiedAt).toEqual(sameBar!.lastModifiedAt);
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'should trim old assets, %p',
- async databaseId => {
+ it('should trim old assets', async () => {
const knex = await databases.init(databaseId);
const store = await StaticAssetsStore.create({
@@ -157,12 +151,9 @@ describe('StaticAssetsStore', () => {
await expect(store.getAsset('new')).resolves.toBeDefined();
await expect(store.getAsset('old')).resolves.toBeUndefined();
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'should isolate assets in namespace, %p',
- async databaseId => {
+ it('should isolate assets in namespace', async () => {
const knex = await databases.init(databaseId);
const store = await StaticAssetsStore.create({
@@ -197,6 +188,6 @@ describe('StaticAssetsStore', () => {
await otherStore.trimAssets({ maxAgeSeconds: 0 });
await expect(otherStore.getAsset('bar')).resolves.not.toBeDefined();
- },
- );
-});
+ });
+ },
+);
diff --git a/plugins/app-backend/src/migrations.test.ts b/plugins/app-backend/src/migrations.test.ts
index eb327e3f6a..41a84cce4e 100644
--- a/plugins/app-backend/src/migrations.test.ts
+++ b/plugins/app-backend/src/migrations.test.ts
@@ -41,101 +41,95 @@ async function migrateUntilBefore(knex: Knex, target: string): Promise {
jest.setTimeout(60_000);
-describe('migrations', () => {
- const databases = TestDatabases.create();
+const databases = TestDatabases.create();
- it.each(databases.eachSupportedId())(
- '20211229105307_init.js, %p',
- async databaseId => {
- const knex = await databases.init(databaseId);
+describe.each(databases.eachSupportedId())('migrations, %p', databaseId => {
+ it('20211229105307_init.js', async () => {
+ const knex = await databases.init(databaseId);
- await migrateUntilBefore(knex, '20211229105307_init.js');
- await migrateUpOnce(knex);
+ await migrateUntilBefore(knex, '20211229105307_init.js');
+ await migrateUpOnce(knex);
- await knex('static_assets_cache').insert({
+ await knex('static_assets_cache').insert({
+ path: 'main.js',
+ content: Buffer.from('some-script'),
+ last_modified_at: knex.fn.now(),
+ });
+
+ await expect(knex('static_assets_cache')).resolves.toEqual([
+ {
path: 'main.js',
content: Buffer.from('some-script'),
- last_modified_at: knex.fn.now(),
- });
+ last_modified_at: expect.anything(),
+ },
+ ]);
- await expect(knex('static_assets_cache')).resolves.toEqual([
- {
- path: 'main.js',
- content: Buffer.from('some-script'),
- last_modified_at: expect.anything(),
- },
- ]);
+ await migrateDownOnce(knex);
- await migrateDownOnce(knex);
+ // This looks odd - you might expect a .toThrow at the end but that
+ // actually is flaky for some reason specifically on sqlite when
+ // performing multiple runs in sequence
+ await expect(knex('static_assets_cache')).rejects.toEqual(
+ expect.anything(),
+ );
- // This looks odd - you might expect a .toThrow at the end but that
- // actually is flaky for some reason specifically on sqlite when
- // performing multiple runs in sequence
- await expect(knex('static_assets_cache')).rejects.toEqual(
- expect.anything(),
- );
+ await knex.destroy();
+ });
- await knex.destroy();
- },
- );
+ it('20240113144027_assets-namespace.js', async () => {
+ const knex = await databases.init(databaseId);
- it.each(databases.eachSupportedId())(
- '20240113144027_assets-namespace.js, %p',
- async databaseId => {
- const knex = await databases.init(databaseId);
+ await migrateUntilBefore(knex, '20240113144027_assets-namespace.js');
- await migrateUntilBefore(knex, '20240113144027_assets-namespace.js');
+ await knex('static_assets_cache').insert({
+ path: 'main.js',
+ content: Buffer.from('some-script'),
+ last_modified_at: knex.fn.now(),
+ });
- await knex('static_assets_cache').insert({
+ await migrateUpOnce(knex);
+
+ await expect(knex('static_assets_cache')).resolves.toEqual([
+ {
path: 'main.js',
content: Buffer.from('some-script'),
- last_modified_at: knex.fn.now(),
- });
+ namespace: 'default',
+ last_modified_at: expect.anything(),
+ },
+ ]);
- await migrateUpOnce(knex);
+ await knex('static_assets_cache').insert({
+ path: 'main.js',
+ content: Buffer.from('other-script'),
+ namespace: 'other',
+ last_modified_at: knex.fn.now(),
+ });
- await expect(knex('static_assets_cache')).resolves.toEqual([
- {
- path: 'main.js',
- content: Buffer.from('some-script'),
- namespace: 'default',
- last_modified_at: expect.anything(),
- },
- ]);
-
- await knex('static_assets_cache').insert({
+ await expect(knex('static_assets_cache')).resolves.toEqual([
+ {
+ path: 'main.js',
+ content: Buffer.from('some-script'),
+ namespace: 'default',
+ last_modified_at: expect.anything(),
+ },
+ {
path: 'main.js',
content: Buffer.from('other-script'),
namespace: 'other',
- last_modified_at: knex.fn.now(),
- });
+ last_modified_at: expect.anything(),
+ },
+ ]);
- await expect(knex('static_assets_cache')).resolves.toEqual([
- {
- path: 'main.js',
- content: Buffer.from('some-script'),
- namespace: 'default',
- last_modified_at: expect.anything(),
- },
- {
- path: 'main.js',
- content: Buffer.from('other-script'),
- namespace: 'other',
- last_modified_at: expect.anything(),
- },
- ]);
+ await migrateDownOnce(knex);
- await migrateDownOnce(knex);
+ await expect(knex('static_assets_cache')).resolves.toEqual([
+ {
+ path: 'main.js',
+ content: Buffer.from('some-script'),
+ last_modified_at: expect.anything(),
+ },
+ ]);
- await expect(knex('static_assets_cache')).resolves.toEqual([
- {
- path: 'main.js',
- content: Buffer.from('some-script'),
- last_modified_at: expect.anything(),
- },
- ]);
-
- await knex.destroy();
- },
- );
+ await knex.destroy();
+ });
});
diff --git a/plugins/app-node/CHANGELOG.md b/plugins/app-node/CHANGELOG.md
index 78d363cf1f..4da4e28244 100644
--- a/plugins/app-node/CHANGELOG.md
+++ b/plugins/app-node/CHANGELOG.md
@@ -1,5 +1,13 @@
# @backstage/plugin-app-node
+## 0.1.45
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/config-loader@1.10.11
+
## 0.1.45-next.0
### Patch Changes
diff --git a/plugins/app-node/package.json b/plugins/app-node/package.json
index 9b6fbcdd80..7256ce33f4 100644
--- a/plugins/app-node/package.json
+++ b/plugins/app-node/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-app-node",
- "version": "0.1.45-next.0",
+ "version": "0.1.45",
"description": "Node.js library for the app plugin",
"backstage": {
"role": "node-library",
diff --git a/plugins/app-react/CHANGELOG.md b/plugins/app-react/CHANGELOG.md
index 55742ce315..dd2bb3ef31 100644
--- a/plugins/app-react/CHANGELOG.md
+++ b/plugins/app-react/CHANGELOG.md
@@ -1,5 +1,13 @@
# @backstage/plugin-app-react
+## 0.2.3
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/frontend-plugin-api@0.17.0
+ - @backstage/core-plugin-api@1.12.6
+
## 0.2.3-next.0
### Patch Changes
diff --git a/plugins/app-react/package.json b/plugins/app-react/package.json
index 4de445bda2..b0500eb81f 100644
--- a/plugins/app-react/package.json
+++ b/plugins/app-react/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-app-react",
- "version": "0.2.3-next.0",
+ "version": "0.2.3",
"description": "Web library for the app plugin",
"backstage": {
"role": "web-library",
diff --git a/plugins/app-visualizer/CHANGELOG.md b/plugins/app-visualizer/CHANGELOG.md
index c3865209e5..435e6c2c3a 100644
--- a/plugins/app-visualizer/CHANGELOG.md
+++ b/plugins/app-visualizer/CHANGELOG.md
@@ -1,5 +1,28 @@
# @backstage/plugin-app-visualizer
+## 0.2.4
+
+### Patch Changes
+
+- e2d9831: Tightened React Aria dependency version ranges from `^` to `~` to prevent unintended minor version upgrades.
+- f635139: Limited `@remixicon/react` dependency to versions below 4.9.0 due to a license change in that release.
+- 44d77e9: Removed separate nav item extensions. Sidebar entries are now provided via `title` and `icon` on each plugin's page extension.
+- Updated dependencies
+ - @backstage/core-components@0.18.10
+ - @backstage/ui@0.15.0
+ - @backstage/frontend-plugin-api@0.17.0
+ - @backstage/core-plugin-api@1.12.6
+
+## 0.2.4-next.1
+
+### Patch Changes
+
+- f635139: Limited `@remixicon/react` dependency to versions below 4.9.0 due to a license change in that release.
+- Updated dependencies
+ - @backstage/ui@0.15.0-next.1
+ - @backstage/frontend-plugin-api@0.17.0-next.1
+ - @backstage/core-plugin-api@1.12.6-next.1
+
## 0.2.4-next.0
### Patch Changes
diff --git a/plugins/app-visualizer/README.md b/plugins/app-visualizer/README.md
index 865cd66746..cf15cdce71 100644
--- a/plugins/app-visualizer/README.md
+++ b/plugins/app-visualizer/README.md
@@ -4,7 +4,6 @@ A plugin to help explore the structure of your Backstage app.
This plugin provides the following extensions:
-| ID | Type | Description | Default Config |
-| ------------------------- | --------- | ------------------------------------ | ------------------------- |
-| `page:app-visualizer` | `Page` | The app visualizer page | `{ path: '/visualizer' }` |
-| `nav-item:app-visualizer` | `NavItem` | Nav item for the app visualizer page | |
+| ID | Type | Description | Default Config |
+| --------------------- | ------ | ----------------------- | ------------------------- |
+| `page:app-visualizer` | `Page` | The app visualizer page | `{ path: '/visualizer' }` |
diff --git a/plugins/app-visualizer/package.json b/plugins/app-visualizer/package.json
index 0c710db45f..4630e4caa5 100644
--- a/plugins/app-visualizer/package.json
+++ b/plugins/app-visualizer/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-app-visualizer",
- "version": "0.2.4-next.0",
+ "version": "0.2.4",
"description": "Visualizes the Backstage app structure",
"backstage": {
"role": "frontend-plugin",
@@ -38,7 +38,7 @@
"@backstage/core-plugin-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/ui": "workspace:^",
- "@remixicon/react": "^4.6.0",
+ "@remixicon/react": ">=4.6.0 <4.9.0",
"react-aria-components": "~1.17.0"
},
"devDependencies": {
diff --git a/plugins/app-visualizer/report.api.md b/plugins/app-visualizer/report.api.md
index f8636d8b4e..07a8fcef62 100644
--- a/plugins/app-visualizer/report.api.md
+++ b/plugins/app-visualizer/report.api.md
@@ -7,7 +7,6 @@ import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
-import { IconComponent } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
@@ -19,31 +18,6 @@ const visualizerPlugin: OverridableFrontendPlugin<
{},
{},
{
- 'nav-item:app-visualizer': OverridableExtensionDefinition<{
- kind: 'nav-item';
- name: undefined;
- config: {
- title: string | undefined;
- };
- configInput: {
- title?: string | undefined;
- };
- output: ExtensionDataRef<
- {
- title: string;
- icon: IconComponent;
- routeRef: RouteRef;
- },
- 'core.nav-item.target',
- {}
- >;
- inputs: {};
- params: {
- title: string;
- icon: IconComponent;
- routeRef: RouteRef;
- };
- }>;
'page:app-visualizer': OverridableExtensionDefinition<{
kind: 'page';
name: undefined;
diff --git a/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx b/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx
index e88f994627..2406d36a68 100644
--- a/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx
+++ b/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx
@@ -19,7 +19,6 @@ import {
ExtensionDataRef,
coreExtensionData,
ApiBlueprint,
- NavItemBlueprint,
useApi,
routeResolutionApiRef,
appTreeApiRef,
@@ -86,7 +85,6 @@ const getOutputColor = createOutputColorGenerator(
[coreExtensionData.routePath.id]: '#ffeb3b',
[coreExtensionData.routeRef.id]: '#9c27b0',
[ApiBlueprint.dataRefs.factory.id]: '#2196f3',
- [NavItemBlueprint.dataRefs.target.id]: '#ff9800',
},
['#90caf9', '#ffcc80', '#a5d6a7', '#ef9a9a', '#fff59d', '#ce93d8', '#e6ee9c'],
@@ -335,7 +333,6 @@ const legendMap = {
'Utility API': ApiBlueprint.dataRefs.factory,
'Route Path': coreExtensionData.routePath,
'Route Ref': coreExtensionData.routeRef,
- 'Nav Target': NavItemBlueprint.dataRefs.target,
};
function Legend() {
diff --git a/plugins/app-visualizer/src/plugin.tsx b/plugins/app-visualizer/src/plugin.tsx
index fab8479c54..7a33d7f32e 100644
--- a/plugins/app-visualizer/src/plugin.tsx
+++ b/plugins/app-visualizer/src/plugin.tsx
@@ -17,7 +17,6 @@
import {
createFrontendPlugin,
createRouteRef,
- NavItemBlueprint,
PageBlueprint,
PluginHeaderActionBlueprint,
SubPageBlueprint,
@@ -31,6 +30,7 @@ const appVisualizerPage = PageBlueprint.make({
path: '/visualizer',
routeRef: rootRouteRef,
title: 'Visualizer',
+ icon: ,
},
});
@@ -82,14 +82,6 @@ const copyTreeAsJson = PluginHeaderActionBlueprint.make({
},
});
-export const appVisualizerNavItem = NavItemBlueprint.make({
- params: {
- title: 'Visualizer',
- icon: () => ,
- routeRef: rootRouteRef,
- },
-});
-
/** @public */
export const visualizerPlugin = createFrontendPlugin({
pluginId: 'app-visualizer',
@@ -101,7 +93,6 @@ export const visualizerPlugin = createFrontendPlugin({
appVisualizerTreePage,
appVisualizerDetailedPage,
appVisualizerTextPage,
- appVisualizerNavItem,
copyTreeAsJson,
],
});
diff --git a/plugins/app/CHANGELOG.md b/plugins/app/CHANGELOG.md
index 131e04473e..ac6b61b1d2 100644
--- a/plugins/app/CHANGELOG.md
+++ b/plugins/app/CHANGELOG.md
@@ -1,5 +1,70 @@
# @backstage/plugin-app
+## 0.4.6
+
+### Patch Changes
+
+- a345820: The `app/routes` redirect config now supports path parameter substitution in the `to` target. Named params (`:userId`) and splat params (`*`) captured by the `from` path are replaced in the `to` string before navigating, making it possible to express redirects like:
+
+ ```yaml
+ app:
+ extensions:
+ - app/routes:
+ config:
+ redirects:
+ - from: /users/:userId
+ to: /profile/:userId
+ - from: /old-docs
+ to: /docs/*
+ ```
+
+- d1be10c: Migrated React Aria imports from individual packages (`@react-aria/toast`, `@react-aria/button`, `@react-stately/toast`) to the monopackages (`react-aria`, `react-stately`).
+- e2d9831: Tightened React Aria dependency version ranges from `^` to `~` to prevent unintended minor version upgrades.
+- f635139: Limited `@remixicon/react` dependency to versions below 4.9.0 due to a license change in that release.
+- 2ba8c10: Following the removal of `NavItemBlueprint` in `@backstage/frontend-plugin-api`, the built-in app nav was updated to keep accepting legacy `nav-item` extensions so older plugins continue to work until they migrate.
+- cad156e: Replaced old config schema values from existing extensions and blueprints.
+- 085133f: The `zod` dependency has been bumped from `^3.25.76 || ^4.0.0` to `^4.0.0`, since `configSchema` requires the full Zod v4 package for JSON Schema support.
+- Updated dependencies
+ - @backstage/core-components@0.18.10
+ - @backstage/ui@0.15.0
+ - @backstage/frontend-plugin-api@0.17.0
+ - @backstage/core-plugin-api@1.12.6
+ - @backstage/filter-predicates@0.1.3
+ - @backstage/plugin-app-react@0.2.3
+ - @backstage/integration-react@1.2.18
+ - @backstage/plugin-permission-react@0.5.1
+
+## 0.4.6-next.2
+
+### Patch Changes
+
+- a345820: The `app/routes` redirect config now supports path parameter substitution in the `to` target. Named params (`:userId`) and splat params (`*`) captured by the `from` path are replaced in the `to` string before navigating, making it possible to express redirects like:
+
+ ```yaml
+ app:
+ extensions:
+ - app/routes:
+ config:
+ redirects:
+ - from: /users/:userId
+ to: /profile/:userId
+ - from: /old-docs
+ to: /docs/*
+ ```
+
+- Updated dependencies
+ - @backstage/ui@0.15.0-next.3
+
+## 0.4.6-next.1
+
+### Patch Changes
+
+- f635139: Limited `@remixicon/react` dependency to versions below 4.9.0 due to a license change in that release.
+- Updated dependencies
+ - @backstage/ui@0.15.0-next.1
+ - @backstage/frontend-plugin-api@0.17.0-next.1
+ - @backstage/core-plugin-api@1.12.6-next.1
+
## 0.4.6-next.0
### Patch Changes
diff --git a/plugins/app/package.json b/plugins/app/package.json
index 65c8fbda8d..ce2c20af65 100644
--- a/plugins/app/package.json
+++ b/plugins/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-app",
- "version": "0.4.6-next.0",
+ "version": "0.4.6",
"backstage": {
"role": "frontend-plugin",
"pluginId": "app",
@@ -66,7 +66,7 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.61",
"@react-hookz/web": "^24.0.0",
- "@remixicon/react": "^4.6.0",
+ "@remixicon/react": ">=4.6.0 <4.9.0",
"motion": "^12.0.0",
"react-aria": "~3.48.0",
"react-stately": "~3.46.0",
diff --git a/plugins/app/src/extensions/AppNav.test.tsx b/plugins/app/src/extensions/AppNav.test.tsx
new file mode 100644
index 0000000000..c1434d7bfc
--- /dev/null
+++ b/plugins/app/src/extensions/AppNav.test.tsx
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { screen, waitFor, within } from '@testing-library/react';
+import { renderTestApp } from '@backstage/frontend-test-utils';
+import {
+ PageBlueprint,
+ createExtension,
+ createRouteRef,
+} from '@backstage/frontend-plugin-api';
+import { legacyNavItemTargetDataRef } from './legacyNavItem';
+
+const DEFAULT_CONFIG = {
+ app: { baseUrl: 'http://localhost:3000' },
+ backend: { baseUrl: 'http://localhost:7007' },
+};
+
+const mockRouteRef = createRouteRef();
+
+const mockPage = PageBlueprint.make({
+ name: 'my-plugin',
+ params: {
+ title: 'My Plugin',
+ icon: icon,
+ path: '/my-plugin',
+ routeRef: mockRouteRef,
+ },
+});
+
+const mockLegacyNavItem = createExtension({
+ kind: 'nav-item',
+ name: 'my-plugin',
+ attachTo: { id: 'app/nav', input: 'items' },
+ output: [legacyNavItemTargetDataRef],
+ factory: () => [
+ legacyNavItemTargetDataRef({
+ title: 'Legacy Nav Title',
+ icon: () => legacy icon,
+ routeRef: mockRouteRef,
+ }),
+ ],
+});
+
+describe('AppNav', () => {
+ it('should show a nav item for a page with title and icon', async () => {
+ renderTestApp({
+ extensions: [mockPage],
+ config: DEFAULT_CONFIG,
+ });
+
+ await waitFor(() => {
+ expect(
+ within(screen.getByRole('navigation')).getByText('My Plugin'),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('should merge legacy nav item metadata when page has no explicit title', async () => {
+ const pageWithoutTitle = PageBlueprint.make({
+ name: 'legacy-plugin',
+ params: {
+ path: '/legacy-plugin',
+ routeRef: mockRouteRef,
+ icon: page icon,
+ },
+ });
+
+ renderTestApp({
+ extensions: [pageWithoutTitle, mockLegacyNavItem],
+ config: DEFAULT_CONFIG,
+ });
+
+ await waitFor(() => {
+ expect(
+ within(screen.getByRole('navigation')).getByText('Legacy Nav Title'),
+ ).toBeInTheDocument();
+ });
+ });
+});
diff --git a/plugins/app/src/extensions/AppNav.tsx b/plugins/app/src/extensions/AppNav.tsx
index 09203303e5..4b5c456436 100644
--- a/plugins/app/src/extensions/AppNav.tsx
+++ b/plugins/app/src/extensions/AppNav.tsx
@@ -18,7 +18,6 @@ import {
createExtension,
coreExtensionData,
createExtensionInput,
- NavItemBlueprint,
routeResolutionApiRef,
appTreeApiRef,
IconComponent,
@@ -27,6 +26,7 @@ import {
RouteResolutionApi,
useApi,
} from '@backstage/frontend-plugin-api';
+import { legacyNavItemTargetDataRef } from './legacyNavItem';
import {
NavContentBlueprint,
NavContentComponent,
@@ -248,7 +248,7 @@ export const AppNav = createExtension({
name: 'nav',
attachTo: { id: 'app/layout', input: 'nav' },
inputs: {
- items: createExtensionInput([NavItemBlueprint.dataRefs.target]),
+ items: createExtensionInput([legacyNavItemTargetDataRef]),
content: createExtensionInput([NavContentBlueprint.dataRefs.component], {
singleton: true,
optional: true,
@@ -264,7 +264,7 @@ export const AppNav = createExtension({
yield coreExtensionData.reactElement(
- item.get(NavItemBlueprint.dataRefs.target),
+ item.get(legacyNavItemTargetDataRef),
)}
Content={Content}
/>,
diff --git a/plugins/app/src/extensions/AppRoutes.test.tsx b/plugins/app/src/extensions/AppRoutes.test.tsx
index 9aa6474189..83b0c596b4 100644
--- a/plugins/app/src/extensions/AppRoutes.test.tsx
+++ b/plugins/app/src/extensions/AppRoutes.test.tsx
@@ -443,6 +443,149 @@ describe('AppRoutes', () => {
});
});
+ it('should substitute named path params in redirect target', async () => {
+ const LocationDisplay = () => {
+ const location = useLocation();
+ return {location.pathname}
;
+ };
+
+ const profilePage = PageBlueprint.make({
+ name: 'profile',
+ params: {
+ path: '/profile/:userId',
+ loader: async () => (
+
+ Profile Page
+
+
+ ),
+ },
+ });
+
+ renderTestApp({
+ extensions: [profilePage],
+ initialRouteEntries: ['/users/alice'],
+ config: {
+ ...DEFAULT_CONFIG,
+ app: {
+ ...DEFAULT_CONFIG.app,
+ extensions: [
+ {
+ 'app/routes': {
+ config: {
+ redirects: [
+ { from: '/users/:userId', to: '/profile/:userId' },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Profile Page')).toBeInTheDocument();
+ expect(screen.getByTestId('location')).toHaveTextContent(
+ '/profile/alice',
+ );
+ });
+ });
+
+ it('should substitute splat param in redirect target', async () => {
+ const LocationDisplay = () => {
+ const location = useLocation();
+ return {location.pathname}
;
+ };
+
+ const docsPage = PageBlueprint.make({
+ name: 'docs',
+ params: {
+ path: '/docs',
+ loader: async () => (
+
+ Docs Page
+
+
+ ),
+ },
+ });
+
+ renderTestApp({
+ extensions: [docsPage],
+ initialRouteEntries: ['/d/default/component/my-entity'],
+ config: {
+ ...DEFAULT_CONFIG,
+ app: {
+ ...DEFAULT_CONFIG.app,
+ extensions: [
+ {
+ 'app/routes': {
+ config: {
+ redirects: [{ from: '/d', to: '/docs/*' }],
+ },
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Docs Page')).toBeInTheDocument();
+ expect(screen.getByTestId('location')).toHaveTextContent(
+ '/docs/default/component/my-entity',
+ );
+ });
+ });
+
+ it('should not corrupt a longer param when a shorter param is a prefix of it', async () => {
+ const LocationDisplay = () => {
+ const location = useLocation();
+ return {location.pathname}
;
+ };
+
+ const targetPage = PageBlueprint.make({
+ name: 'target',
+ params: {
+ path: '/target/:ab/:a',
+ loader: async () => (
+
+ Target Page
+
+
+ ),
+ },
+ });
+
+ renderTestApp({
+ extensions: [targetPage],
+ initialRouteEntries: ['/source/bar/foo'],
+ config: {
+ ...DEFAULT_CONFIG,
+ app: {
+ ...DEFAULT_CONFIG.app,
+ extensions: [
+ {
+ 'app/routes': {
+ config: {
+ redirects: [{ from: '/source/:ab/:a', to: '/target/:ab/:a' }],
+ },
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Target Page')).toBeInTheDocument();
+ expect(screen.getByTestId('location')).toHaveTextContent(
+ '/target/bar/foo',
+ );
+ });
+ });
+
it('should not interfere with normal routes when redirects are configured', async () => {
const homePage = PageBlueprint.make({
name: 'home',
diff --git a/plugins/app/src/extensions/AppRoutes.tsx b/plugins/app/src/extensions/AppRoutes.tsx
index 75d590283b..04d94ebc35 100644
--- a/plugins/app/src/extensions/AppRoutes.tsx
+++ b/plugins/app/src/extensions/AppRoutes.tsx
@@ -21,7 +21,21 @@ import {
createExtensionInput,
NotFoundErrorPage,
} from '@backstage/frontend-plugin-api';
-import { Navigate, useRoutes } from 'react-router-dom';
+import { Navigate, useParams, useRoutes } from 'react-router-dom';
+
+function RedirectWithParams({ to }: { to: string }) {
+ const params = useParams() as Record;
+ let target = to;
+ for (const [name, value] of Object.entries(params)) {
+ // Use \b (word boundary) for named params so that `:a` doesn't
+ // accidentally match inside `:ab` when both are present.
+ target = target.replace(
+ name === '*' ? /\*/g : new RegExp(`:${name}\\b`, 'g'),
+ value ?? '',
+ );
+ }
+ return ;
+}
export const AppRoutes = createExtension({
name: 'routes',
@@ -54,7 +68,7 @@ export const AppRoutes = createExtension({
redirect.from === '/'
? redirect.from
: `${redirect.from.replace(/\/$/, '')}/*`,
- element: ,
+ element: ,
})),
...inputs.routes.map(route => {
const routePath = route.get(coreExtensionData.routePath);
diff --git a/plugins/app/src/extensions/legacyNavItem.ts b/plugins/app/src/extensions/legacyNavItem.ts
new file mode 100644
index 0000000000..bda23dabe8
--- /dev/null
+++ b/plugins/app/src/extensions/legacyNavItem.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ createExtensionDataRef,
+ IconComponent,
+ RouteRef,
+} from '@backstage/frontend-plugin-api';
+
+/**
+ * @internal
+ *
+ * Data ref for legacy nav-item extensions. Kept for backward compatibility with
+ * extensions created by older versions of the framework.
+ */
+export const legacyNavItemTargetDataRef = createExtensionDataRef<{
+ title: string;
+ icon: IconComponent;
+ routeRef: RouteRef;
+}>().with({ id: 'core.nav-item.target' });
diff --git a/plugins/auth-backend-module-atlassian-provider/CHANGELOG.md b/plugins/auth-backend-module-atlassian-provider/CHANGELOG.md
index b292f81c66..3e68c49b5f 100644
--- a/plugins/auth-backend-module-atlassian-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-atlassian-provider/CHANGELOG.md
@@ -1,5 +1,13 @@
# @backstage/plugin-auth-backend-module-atlassian-provider
+## 0.4.15
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.4.15-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-atlassian-provider/package.json b/plugins/auth-backend-module-atlassian-provider/package.json
index df40314357..c271eea73b 100644
--- a/plugins/auth-backend-module-atlassian-provider/package.json
+++ b/plugins/auth-backend-module-atlassian-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-atlassian-provider",
- "version": "0.4.15-next.0",
+ "version": "0.4.15",
"description": "The atlassian-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-auth0-provider/CHANGELOG.md b/plugins/auth-backend-module-auth0-provider/CHANGELOG.md
index 19c8357e8b..1a24f71bad 100644
--- a/plugins/auth-backend-module-auth0-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-auth0-provider/CHANGELOG.md
@@ -1,5 +1,14 @@
# @backstage/plugin-auth-backend-module-auth0-provider
+## 0.4.1
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.4.1-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-auth0-provider/package.json b/plugins/auth-backend-module-auth0-provider/package.json
index 2677de3bf8..52ae868972 100644
--- a/plugins/auth-backend-module-auth0-provider/package.json
+++ b/plugins/auth-backend-module-auth0-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-auth0-provider",
- "version": "0.4.1-next.0",
+ "version": "0.4.1",
"description": "The auth0-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-aws-alb-provider/CHANGELOG.md b/plugins/auth-backend-module-aws-alb-provider/CHANGELOG.md
index d586aca02a..23b03edb11 100644
--- a/plugins/auth-backend-module-aws-alb-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-aws-alb-provider/CHANGELOG.md
@@ -1,5 +1,15 @@
# @backstage/plugin-auth-backend-module-aws-alb-provider
+## 0.4.16
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-backend@0.29.0
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.4.16-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-aws-alb-provider/package.json b/plugins/auth-backend-module-aws-alb-provider/package.json
index 3d2269d43b..87f08cd9a5 100644
--- a/plugins/auth-backend-module-aws-alb-provider/package.json
+++ b/plugins/auth-backend-module-aws-alb-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-aws-alb-provider",
- "version": "0.4.16-next.0",
+ "version": "0.4.16",
"description": "The aws-alb provider module for the Backstage auth backend.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-azure-easyauth-provider/CHANGELOG.md b/plugins/auth-backend-module-azure-easyauth-provider/CHANGELOG.md
index 3707db1ade..a00d518ab1 100644
--- a/plugins/auth-backend-module-azure-easyauth-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-azure-easyauth-provider/CHANGELOG.md
@@ -1,5 +1,15 @@
# @backstage/plugin-auth-backend-module-azure-easyauth-provider
+## 0.2.20
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.2.20-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-azure-easyauth-provider/package.json b/plugins/auth-backend-module-azure-easyauth-provider/package.json
index 0b2913f7c4..c22fc80846 100644
--- a/plugins/auth-backend-module-azure-easyauth-provider/package.json
+++ b/plugins/auth-backend-module-azure-easyauth-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-azure-easyauth-provider",
- "version": "0.2.20-next.0",
+ "version": "0.2.20",
"description": "The azure-easyauth-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-bitbucket-provider/CHANGELOG.md b/plugins/auth-backend-module-bitbucket-provider/CHANGELOG.md
index 32609c7571..d06963fee9 100644
--- a/plugins/auth-backend-module-bitbucket-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-bitbucket-provider/CHANGELOG.md
@@ -1,5 +1,13 @@
# @backstage/plugin-auth-backend-module-bitbucket-provider
+## 0.3.15
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.3.15-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-bitbucket-provider/package.json b/plugins/auth-backend-module-bitbucket-provider/package.json
index a6557bc3e9..16cc3c89dc 100644
--- a/plugins/auth-backend-module-bitbucket-provider/package.json
+++ b/plugins/auth-backend-module-bitbucket-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-bitbucket-provider",
- "version": "0.3.15-next.0",
+ "version": "0.3.15",
"description": "The bitbucket-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-bitbucket-server-provider/CHANGELOG.md b/plugins/auth-backend-module-bitbucket-server-provider/CHANGELOG.md
index 408c05959a..ab8c387d65 100644
--- a/plugins/auth-backend-module-bitbucket-server-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-bitbucket-server-provider/CHANGELOG.md
@@ -1,5 +1,13 @@
# @backstage/plugin-auth-backend-module-bitbucket-server-provider
+## 0.2.15
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.2.15-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-bitbucket-server-provider/package.json b/plugins/auth-backend-module-bitbucket-server-provider/package.json
index b7c334c171..69bcbbbeaa 100644
--- a/plugins/auth-backend-module-bitbucket-server-provider/package.json
+++ b/plugins/auth-backend-module-bitbucket-server-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-bitbucket-server-provider",
- "version": "0.2.15-next.0",
+ "version": "0.2.15",
"description": "The bitbucket-server-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-cloudflare-access-provider/CHANGELOG.md b/plugins/auth-backend-module-cloudflare-access-provider/CHANGELOG.md
index e6d6679cbf..9d268b6dce 100644
--- a/plugins/auth-backend-module-cloudflare-access-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-cloudflare-access-provider/CHANGELOG.md
@@ -1,5 +1,24 @@
# @backstage/plugin-auth-backend-module-cloudflare-access-provider
+## 0.4.15
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+ - @backstage/config@1.3.8
+
+## 0.4.15-next.1
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/plugin-auth-node@0.7.1-next.1
+
## 0.4.15-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-cloudflare-access-provider/package.json b/plugins/auth-backend-module-cloudflare-access-provider/package.json
index ad1979fae4..a25713a15c 100644
--- a/plugins/auth-backend-module-cloudflare-access-provider/package.json
+++ b/plugins/auth-backend-module-cloudflare-access-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-cloudflare-access-provider",
- "version": "0.4.15-next.0",
+ "version": "0.4.15",
"description": "The cloudflare-access-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
@@ -49,8 +49,7 @@
"@backstage/plugin-auth-backend": "workspace:^",
"@backstage/types": "workspace:^",
"msw": "^2.0.0",
- "node-mocks-http": "^1.0.0",
- "uuid": "^11.0.0"
+ "node-mocks-http": "^1.0.0"
},
"configSchema": "config.d.ts"
}
diff --git a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts
index 07e9860d98..84236a5f14 100644
--- a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts
+++ b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts
@@ -23,7 +23,7 @@ import { SignJWT, exportJWK, generateKeyPair } from 'jose';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { createRequest } from 'node-mocks-http';
-import { v4 as uuid } from 'uuid';
+import { randomUUID as uuid } from 'node:crypto';
import { AuthHelper } from './helpers';
import { CF_JWT_HEADER, CloudflareAccessIdentityProfile } from './types';
diff --git a/plugins/auth-backend-module-gcp-iap-provider/CHANGELOG.md b/plugins/auth-backend-module-gcp-iap-provider/CHANGELOG.md
index 2bb2b22c86..ae8815fb09 100644
--- a/plugins/auth-backend-module-gcp-iap-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-gcp-iap-provider/CHANGELOG.md
@@ -1,5 +1,14 @@
# @backstage/plugin-auth-backend-module-gcp-iap-provider
+## 0.4.15
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.4.15-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-gcp-iap-provider/package.json b/plugins/auth-backend-module-gcp-iap-provider/package.json
index b67ddb9a7b..a625efc789 100644
--- a/plugins/auth-backend-module-gcp-iap-provider/package.json
+++ b/plugins/auth-backend-module-gcp-iap-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-gcp-iap-provider",
- "version": "0.4.15-next.0",
+ "version": "0.4.15",
"description": "A GCP IAP auth provider module for the Backstage auth backend",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-github-provider/CHANGELOG.md b/plugins/auth-backend-module-github-provider/CHANGELOG.md
index ddbfb4caa7..e03ac9792d 100644
--- a/plugins/auth-backend-module-github-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-github-provider/CHANGELOG.md
@@ -1,5 +1,13 @@
# @backstage/plugin-auth-backend-module-github-provider
+## 0.5.3
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.5.3-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-github-provider/package.json b/plugins/auth-backend-module-github-provider/package.json
index 86fabd705e..5911959a09 100644
--- a/plugins/auth-backend-module-github-provider/package.json
+++ b/plugins/auth-backend-module-github-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-github-provider",
- "version": "0.5.3-next.0",
+ "version": "0.5.3",
"description": "The github-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-gitlab-provider/CHANGELOG.md b/plugins/auth-backend-module-gitlab-provider/CHANGELOG.md
index 0c94ec7c20..8122567682 100644
--- a/plugins/auth-backend-module-gitlab-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-gitlab-provider/CHANGELOG.md
@@ -1,5 +1,13 @@
# @backstage/plugin-auth-backend-module-gitlab-provider
+## 0.4.3
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.4.3-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-gitlab-provider/package.json b/plugins/auth-backend-module-gitlab-provider/package.json
index 940850b443..231497afc5 100644
--- a/plugins/auth-backend-module-gitlab-provider/package.json
+++ b/plugins/auth-backend-module-gitlab-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-gitlab-provider",
- "version": "0.4.3-next.0",
+ "version": "0.4.3",
"description": "The gitlab-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-google-provider/CHANGELOG.md b/plugins/auth-backend-module-google-provider/CHANGELOG.md
index 0e9323a1b5..93a0a2d4be 100644
--- a/plugins/auth-backend-module-google-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-google-provider/CHANGELOG.md
@@ -1,5 +1,13 @@
# @backstage/plugin-auth-backend-module-google-provider
+## 0.3.15
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.3.15-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-google-provider/package.json b/plugins/auth-backend-module-google-provider/package.json
index 5dd8b82f2d..46763ec785 100644
--- a/plugins/auth-backend-module-google-provider/package.json
+++ b/plugins/auth-backend-module-google-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-google-provider",
- "version": "0.3.15-next.0",
+ "version": "0.3.15",
"description": "A Google auth provider module for the Backstage auth backend",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-guest-provider/CHANGELOG.md b/plugins/auth-backend-module-guest-provider/CHANGELOG.md
index 2d59bd7b6c..5ef3966781 100644
--- a/plugins/auth-backend-module-guest-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-guest-provider/CHANGELOG.md
@@ -1,5 +1,15 @@
# @backstage/plugin-auth-backend-module-guest-provider
+## 0.2.19
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.2.19-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-guest-provider/package.json b/plugins/auth-backend-module-guest-provider/package.json
index 214fa89629..2ff1249db7 100644
--- a/plugins/auth-backend-module-guest-provider/package.json
+++ b/plugins/auth-backend-module-guest-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-guest-provider",
- "version": "0.2.19-next.0",
+ "version": "0.2.19",
"description": "The guest-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-microsoft-provider/CHANGELOG.md b/plugins/auth-backend-module-microsoft-provider/CHANGELOG.md
index 879bc8a821..ce6423a49f 100644
--- a/plugins/auth-backend-module-microsoft-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-microsoft-provider/CHANGELOG.md
@@ -1,5 +1,13 @@
# @backstage/plugin-auth-backend-module-microsoft-provider
+## 0.3.15
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.3.15-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-microsoft-provider/package.json b/plugins/auth-backend-module-microsoft-provider/package.json
index 6367fb0597..acc1a1192d 100644
--- a/plugins/auth-backend-module-microsoft-provider/package.json
+++ b/plugins/auth-backend-module-microsoft-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-microsoft-provider",
- "version": "0.3.15-next.0",
+ "version": "0.3.15",
"description": "The microsoft-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-oauth2-provider/CHANGELOG.md b/plugins/auth-backend-module-oauth2-provider/CHANGELOG.md
index 3878a38a03..3e19016de5 100644
--- a/plugins/auth-backend-module-oauth2-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-oauth2-provider/CHANGELOG.md
@@ -1,5 +1,13 @@
# @backstage/plugin-auth-backend-module-oauth2-provider
+## 0.4.15
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.4.15-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-oauth2-provider/package.json b/plugins/auth-backend-module-oauth2-provider/package.json
index 8a549f4ee0..d811f4843f 100644
--- a/plugins/auth-backend-module-oauth2-provider/package.json
+++ b/plugins/auth-backend-module-oauth2-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-oauth2-provider",
- "version": "0.4.15-next.0",
+ "version": "0.4.15",
"description": "The oauth2-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-oauth2-proxy-provider/CHANGELOG.md b/plugins/auth-backend-module-oauth2-proxy-provider/CHANGELOG.md
index b8f05eaed4..c16b165fda 100644
--- a/plugins/auth-backend-module-oauth2-proxy-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-oauth2-proxy-provider/CHANGELOG.md
@@ -1,5 +1,14 @@
# @backstage/plugin-auth-backend-module-oauth2-proxy-provider
+## 0.2.20
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.2.20-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-oauth2-proxy-provider/package.json b/plugins/auth-backend-module-oauth2-proxy-provider/package.json
index fc87092dde..16f78bee1c 100644
--- a/plugins/auth-backend-module-oauth2-proxy-provider/package.json
+++ b/plugins/auth-backend-module-oauth2-proxy-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-oauth2-proxy-provider",
- "version": "0.2.20-next.0",
+ "version": "0.2.20",
"description": "The oauth2-proxy-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-oidc-provider/CHANGELOG.md b/plugins/auth-backend-module-oidc-provider/CHANGELOG.md
index 07ac912a17..fce01319c2 100644
--- a/plugins/auth-backend-module-oidc-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-oidc-provider/CHANGELOG.md
@@ -1,5 +1,16 @@
# @backstage/plugin-auth-backend-module-oidc-provider
+## 0.4.16
+
+### Patch Changes
+
+- 744fa1f: Removed duplicated entries that appeared in both `dependencies` and `devDependencies`.
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-backend@0.29.0
+ - @backstage/plugin-auth-node@0.7.1
+ - @backstage/config@1.3.8
+
## 0.4.16-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-oidc-provider/package.json b/plugins/auth-backend-module-oidc-provider/package.json
index 1ef341dbf2..6dd9c564ea 100644
--- a/plugins/auth-backend-module-oidc-provider/package.json
+++ b/plugins/auth-backend-module-oidc-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-oidc-provider",
- "version": "0.4.16-next.0",
+ "version": "0.4.16",
"description": "The oidc-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-okta-provider/CHANGELOG.md b/plugins/auth-backend-module-okta-provider/CHANGELOG.md
index bc01762847..304e41a9e9 100644
--- a/plugins/auth-backend-module-okta-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-okta-provider/CHANGELOG.md
@@ -1,5 +1,13 @@
# @backstage/plugin-auth-backend-module-okta-provider
+## 0.2.15
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.2.15-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-okta-provider/package.json b/plugins/auth-backend-module-okta-provider/package.json
index eb39599a0d..68b1009373 100644
--- a/plugins/auth-backend-module-okta-provider/package.json
+++ b/plugins/auth-backend-module-okta-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-okta-provider",
- "version": "0.2.15-next.0",
+ "version": "0.2.15",
"description": "The okta-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-onelogin-provider/CHANGELOG.md b/plugins/auth-backend-module-onelogin-provider/CHANGELOG.md
index 7a812999a2..57108a7661 100644
--- a/plugins/auth-backend-module-onelogin-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-onelogin-provider/CHANGELOG.md
@@ -1,5 +1,13 @@
# @backstage/plugin-auth-backend-module-onelogin-provider
+## 0.3.15
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.3.15-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-onelogin-provider/package.json b/plugins/auth-backend-module-onelogin-provider/package.json
index 5ac8e516f2..85e547fcd5 100644
--- a/plugins/auth-backend-module-onelogin-provider/package.json
+++ b/plugins/auth-backend-module-onelogin-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-onelogin-provider",
- "version": "0.3.15-next.0",
+ "version": "0.3.15",
"description": "The onelogin-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-openshift-provider/CHANGELOG.md b/plugins/auth-backend-module-openshift-provider/CHANGELOG.md
index a408aab3a3..d55948c67b 100644
--- a/plugins/auth-backend-module-openshift-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-openshift-provider/CHANGELOG.md
@@ -1,5 +1,14 @@
# @backstage/plugin-auth-backend-module-openshift-provider
+## 0.1.7
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.1.7-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-openshift-provider/package.json b/plugins/auth-backend-module-openshift-provider/package.json
index 4ca85e4106..3e1ed7b778 100644
--- a/plugins/auth-backend-module-openshift-provider/package.json
+++ b/plugins/auth-backend-module-openshift-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-openshift-provider",
- "version": "0.1.7-next.0",
+ "version": "0.1.7",
"description": "The OpenShift backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-pinniped-provider/CHANGELOG.md b/plugins/auth-backend-module-pinniped-provider/CHANGELOG.md
index a10df1ca68..5e0001fe30 100644
--- a/plugins/auth-backend-module-pinniped-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-pinniped-provider/CHANGELOG.md
@@ -1,5 +1,14 @@
# @backstage/plugin-auth-backend-module-pinniped-provider
+## 0.3.14
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+ - @backstage/config@1.3.8
+
## 0.3.14-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-pinniped-provider/package.json b/plugins/auth-backend-module-pinniped-provider/package.json
index 8f7d3b0c25..dfc33c8d05 100644
--- a/plugins/auth-backend-module-pinniped-provider/package.json
+++ b/plugins/auth-backend-module-pinniped-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-pinniped-provider",
- "version": "0.3.14-next.0",
+ "version": "0.3.14",
"description": "The pinniped-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend-module-vmware-cloud-provider/CHANGELOG.md b/plugins/auth-backend-module-vmware-cloud-provider/CHANGELOG.md
index 01e5fad6d1..d3e9b869d3 100644
--- a/plugins/auth-backend-module-vmware-cloud-provider/CHANGELOG.md
+++ b/plugins/auth-backend-module-vmware-cloud-provider/CHANGELOG.md
@@ -1,5 +1,14 @@
# @backstage/plugin-auth-backend-module-vmware-cloud-provider
+## 0.5.14
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-auth-node@0.7.1
+
## 0.5.14-next.0
### Patch Changes
diff --git a/plugins/auth-backend-module-vmware-cloud-provider/package.json b/plugins/auth-backend-module-vmware-cloud-provider/package.json
index 127abb6b5d..9be6aa461f 100644
--- a/plugins/auth-backend-module-vmware-cloud-provider/package.json
+++ b/plugins/auth-backend-module-vmware-cloud-provider/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend-module-vmware-cloud-provider",
- "version": "0.5.14-next.0",
+ "version": "0.5.14",
"description": "The vmware-cloud-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md
index 4725757fe0..92d42d5d38 100644
--- a/plugins/auth-backend/CHANGELOG.md
+++ b/plugins/auth-backend/CHANGELOG.md
@@ -1,5 +1,76 @@
# @backstage/plugin-auth-backend
+## 0.29.0
+
+### Minor Changes
+
+- 29d398b: **BREAKING**: Hardened the default allowed patterns for CIMD and DCR to replace the previous permissive `['*']` wildcards with specific defaults for known MCP clients. If you previously relied on the default `['*']` patterns, you will need to explicitly configure the patterns you need in your `app-config.yaml`.
+
+ **CIMD (`experimentalClientIdMetadataDocuments`):**
+
+ - `allowedClientIdPatterns` now defaults to Claude, VS Code, and the built-in Backstage CLI instead of `['*']`
+ - `allowedRedirectUriPatterns` now defaults to loopback addresses (localhost, 127.0.0.1, [::1]) instead of `['*']`
+
+ **DCR (`experimentalDynamicClientRegistration`):**
+
+ - `allowedRedirectUriPatterns` now defaults to Cursor and loopback addresses instead of `['*']`
+
+ If you need to allow additional clients or redirect URIs, you can override these defaults in your `app-config.yaml`:
+
+ ```yaml
+ auth:
+ experimentalClientIdMetadataDocuments:
+ enabled: true
+ allowedClientIdPatterns:
+ - 'https://claude.ai/*'
+ - 'https://vscode.dev/*'
+ - 'https://my-custom-client.example.com/*'
+ allowedRedirectUriPatterns:
+ - 'http://localhost:*'
+ - 'http://127.0.0.1:*'
+ - 'https://my-app.example.com/callback'
+ experimentalDynamicClientRegistration:
+ enabled: true
+ allowedRedirectUriPatterns:
+ - 'cursor://*'
+ - 'http://localhost:*'
+ - 'http://127.0.0.1:*'
+ - 'myapp://*'
+ ```
+
+### Patch Changes
+
+- 9f269d7: Limit the size of fetched client ID metadata documents to prevent oversized responses from being accepted.
+- 3f5e7ec: Improved OIDC error messages to include the rejected redirect URI or client ID, making it easier to debug client registration failures.
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- 27f24a9: Refresh token usage now verifies that the user's catalog entity still exists before issuing a new access token. If the user has been removed from the catalog, the refresh is rejected and the session is revoked. Transient catalog errors reject the refresh but preserve the session for retry. This check can be disabled by setting `auth.experimentalRefreshToken.dangerouslyDisableCatalogPresenceCheck` to `true`.
+- 4f62755: Improved the OAuth consent dialog for MCP authorization by showing more client details, including the client metadata host for CIMD clients, the metadata URL, callback URL, and requested scopes.
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/plugin-auth-node@0.7.1
+ - @backstage/config@1.3.8
+
+## 0.28.1-next.2
+
+### Patch Changes
+
+- 4f62755: Improved the OAuth consent dialog for MCP authorization by showing more client details, including the client metadata host for CIMD clients, the metadata URL, callback URL, and requested scopes.
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1-next.1
+
+## 0.28.1-next.1
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.8.1-next.1
+ - @backstage/plugin-catalog-node@2.2.1-next.1
+ - @backstage/plugin-auth-node@0.7.1-next.1
+
## 0.28.1-next.0
### Patch Changes
diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts
index b35e39d90d..9f71c31de2 100644
--- a/plugins/auth-backend/config.d.ts
+++ b/plugins/auth-backend/config.d.ts
@@ -133,6 +133,17 @@ export interface Config {
* @visibility backend
*/
maxTokensPerUser?: number;
+ /**
+ * Disables the check that verifies the user's catalog entity still
+ * exists when refreshing a token. This is an escape hatch for
+ * Backstage instances that allow sign-in without a corresponding
+ * catalog user entity. Without the check, refresh tokens for
+ * removed or offboarded users remain valid until they naturally
+ * expire.
+ * @default false
+ * @visibility backend
+ */
+ dangerouslyDisableCatalogPresenceCheck?: boolean;
};
/**
@@ -152,7 +163,8 @@ export interface Config {
/**
* A list of allowed URI patterns to use for redirect URIs during
- * dynamic client registration. Defaults to '[*]' which allows any redirect URI.
+ * dynamic client registration.
+ * Defaults to Cursor and loopback addresses (localhost, 127.0.0.1, [::1]).
*/
allowedRedirectUriPatterns?: string[];
};
@@ -172,7 +184,8 @@ export interface Config {
/**
* A list of allowed URI patterns for client_id URLs.
* Uses glob-style pattern matching where `*` matches any characters.
- * Defaults to ['*'] which allows any client_id URL.
+ * Defaults to `['https://claude.ai/*', 'https://vscode.dev/*', '{baseUrl}/.well-known/oauth-client/cli.json']`
+ * where `{baseUrl}` is the auth backend's base URL.
*
* @example ['https://example.com/*', 'https://*.trusted-domain.com/*']
*/
@@ -181,7 +194,7 @@ export interface Config {
/**
* A list of allowed URI patterns for redirect URIs.
* Uses glob-style pattern matching where `*` matches any characters.
- * Defaults to ['*'] which allows any redirect URI.
+ * Defaults to loopback addresses (localhost, 127.0.0.1, [::1]).
*
* @example ['http://localhost:*', 'http://127.0.0.1:*\/callback']
*/
diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json
index a44188cca4..0445c34853 100644
--- a/plugins/auth-backend/package.json
+++ b/plugins/auth-backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend",
- "version": "0.28.1-next.0",
+ "version": "0.29.0",
"description": "A Backstage backend plugin that handles authentication",
"backstage": {
"role": "backend-plugin",
@@ -65,7 +65,6 @@
"matcher": "^4.0.0",
"minimatch": "^10.2.1",
"passport": "^0.7.0",
- "uuid": "^11.0.0",
"zod": "^3.25.76 || ^4.0.0",
"zod-validation-error": "^5.0.0"
},
diff --git a/plugins/auth-backend/src/authPlugin.ts b/plugins/auth-backend/src/authPlugin.ts
index 3e721d18a7..7b74724321 100644
--- a/plugins/auth-backend/src/authPlugin.ts
+++ b/plugins/auth-backend/src/authPlugin.ts
@@ -98,6 +98,8 @@ export const authPlugin = createBackendPlugin({
database,
logger,
lifecycle,
+ catalog,
+ auth,
})
: undefined;
diff --git a/plugins/auth-backend/src/identity/DatabaseKeyStore.test.ts b/plugins/auth-backend/src/identity/DatabaseKeyStore.test.ts
index f86c4a400b..02f1d2d7bd 100644
--- a/plugins/auth-backend/src/identity/DatabaseKeyStore.test.ts
+++ b/plugins/auth-backend/src/identity/DatabaseKeyStore.test.ts
@@ -27,12 +27,12 @@ const keyBase = {
jest.setTimeout(60_000);
-describe('DatabaseKeyStore', () => {
- const databases = TestDatabases.create();
+const databases = TestDatabases.create();
- it.each(databases.eachSupportedId())(
- 'should store a key, %p',
- async databaseId => {
+describe.each(databases.eachSupportedId())(
+ 'DatabaseKeyStore, %p',
+ databaseId => {
+ it('should store a key', async () => {
const knex = await databases.init(databaseId);
await AuthDatabase.runMigrations(knex);
@@ -53,12 +53,9 @@ describe('DatabaseKeyStore', () => {
DateTime.fromJSDate(items[0].createdAt).diffNow('seconds').seconds,
),
).toBeLessThan(10);
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'should remove stored keys, %p',
- async databaseId => {
+ it('should remove stored keys', async () => {
const knex = await databases.init(databaseId);
await AuthDatabase.runMigrations(knex);
@@ -124,6 +121,6 @@ describe('DatabaseKeyStore', () => {
await expect(store.listKeys()).resolves.toEqual({
items: [],
});
- },
- );
-});
+ });
+ },
+);
diff --git a/plugins/auth-backend/src/identity/KeyStores.test.ts b/plugins/auth-backend/src/identity/KeyStores.test.ts
index 13818ff79c..1110abd834 100644
--- a/plugins/auth-backend/src/identity/KeyStores.test.ts
+++ b/plugins/auth-backend/src/identity/KeyStores.test.ts
@@ -24,89 +24,80 @@ import { mockServices, TestDatabases } from '@backstage/backend-test-utils';
jest.setTimeout(60_000);
-describe('KeyStores', () => {
- const databases = TestDatabases.create();
+const databases = TestDatabases.create();
- const defaultConfigOptions = {
- auth: {
- keyStore: {
- provider: 'memory',
- },
+const defaultConfigOptions = {
+ auth: {
+ keyStore: {
+ provider: 'memory',
},
- };
- const defaultConfig = new ConfigReader(defaultConfigOptions);
+ },
+};
+const defaultConfig = new ConfigReader(defaultConfigOptions);
- it.each(databases.eachSupportedId())(
- 'reads auth section from config, %p',
- async databaseId => {
- const knex = await databases.init(databaseId);
- const configSpy = jest.spyOn(defaultConfig, 'getOptionalConfig');
- const keyStore = await KeyStores.fromConfig(defaultConfig, {
- logger: mockServices.logger.mock(),
- database: AuthDatabase.create(mockServices.database({ knex })),
- });
+describe.each(databases.eachSupportedId())('KeyStores, %p', databaseId => {
+ it('reads auth section from config', async () => {
+ const knex = await databases.init(databaseId);
+ const configSpy = jest.spyOn(defaultConfig, 'getOptionalConfig');
+ const keyStore = await KeyStores.fromConfig(defaultConfig, {
+ logger: mockServices.logger.mock(),
+ database: AuthDatabase.create(mockServices.database({ knex })),
+ });
- expect(keyStore).toBeInstanceOf(MemoryKeyStore);
- expect(configSpy).toHaveBeenCalledWith('auth.keyStore');
- expect(
- defaultConfig
- .getOptionalConfig('auth.keyStore')
- ?.getOptionalString('provider'),
- ).toBe(defaultConfigOptions.auth.keyStore.provider);
- },
- );
+ expect(keyStore).toBeInstanceOf(MemoryKeyStore);
+ expect(configSpy).toHaveBeenCalledWith('auth.keyStore');
+ expect(
+ defaultConfig
+ .getOptionalConfig('auth.keyStore')
+ ?.getOptionalString('provider'),
+ ).toBe(defaultConfigOptions.auth.keyStore.provider);
+ });
- it.each(databases.eachSupportedId())(
- 'can handle without auth config, %p',
- async databaseId => {
- const knex = await databases.init(databaseId);
- const keyStore = await KeyStores.fromConfig(new ConfigReader({}), {
- logger: mockServices.logger.mock(),
- database: AuthDatabase.create(mockServices.database({ knex })),
- });
- expect(keyStore).toBeInstanceOf(DatabaseKeyStore);
- },
- );
+ it('can handle without auth config', async () => {
+ const knex = await databases.init(databaseId);
+ const keyStore = await KeyStores.fromConfig(new ConfigReader({}), {
+ logger: mockServices.logger.mock(),
+ database: AuthDatabase.create(mockServices.database({ knex })),
+ });
+ expect(keyStore).toBeInstanceOf(DatabaseKeyStore);
+ });
- it.each(databases.eachSupportedId())(
- 'can handle additional provider config, %p',
- async databaseId => {
- const knex = await databases.init(databaseId);
- jest.spyOn(FirestoreKeyStore, 'verifyConnection').mockResolvedValue();
- const createSpy = jest.spyOn(FirestoreKeyStore, 'create');
+ it('can handle additional provider config', async () => {
+ const knex = await databases.init(databaseId);
+ jest.spyOn(FirestoreKeyStore, 'verifyConnection').mockResolvedValue();
+ const createSpy = jest.spyOn(FirestoreKeyStore, 'create');
- const configOptions = {
- auth: {
- keyStore: {
- provider: 'firestore',
- firestore: {
- projectId: 'my-project',
- keyFilename: 'cred.json',
- path: 'my-path',
- timeout: 100,
- host: 'localhost',
- port: 8088,
- ssl: false,
- },
+ const configOptions = {
+ auth: {
+ keyStore: {
+ provider: 'firestore',
+ firestore: {
+ projectId: 'my-project',
+ keyFilename: 'cred.json',
+ path: 'my-path',
+ timeout: 100,
+ host: 'localhost',
+ port: 8088,
+ ssl: false,
},
},
- };
- const config = new ConfigReader(configOptions);
- const keyStore = await KeyStores.fromConfig(config, {
- logger: mockServices.logger.mock(),
- database: AuthDatabase.create(mockServices.database({ knex })),
- });
+ },
+ };
+ const config = new ConfigReader(configOptions);
+ const keyStore = await KeyStores.fromConfig(config, {
+ logger: mockServices.logger.mock(),
+ database: AuthDatabase.create(mockServices.database({ knex })),
+ });
- expect(keyStore).toBeInstanceOf(FirestoreKeyStore);
- expect(createSpy).toHaveBeenCalledWith(
- configOptions.auth.keyStore.firestore,
- );
- expect(
- config
- .getOptionalConfig('auth.keyStore')
- ?.getOptionalConfig('firestore')
- ?.getOptionalString('projectId'),
- ).toBe(configOptions.auth.keyStore.firestore.projectId);
- },
- );
+ expect(keyStore).toBeInstanceOf(FirestoreKeyStore);
+ expect(createSpy).toHaveBeenCalledWith(
+ configOptions.auth.keyStore.firestore,
+ );
+ expect(
+ config
+ .getOptionalConfig('auth.keyStore')
+ ?.getOptionalConfig('firestore')
+ ?.getOptionalString('projectId'),
+ ).toBe(configOptions.auth.keyStore.firestore.projectId);
+ });
});
diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts
index d94825e322..457f9b793c 100644
--- a/plugins/auth-backend/src/identity/TokenFactory.ts
+++ b/plugins/auth-backend/src/identity/TokenFactory.ts
@@ -16,7 +16,7 @@
import { exportJWK, generateKeyPair, JWK } from 'jose';
import { DateTime } from 'luxon';
-import { v4 as uuid } from 'uuid';
+import { randomUUID as uuid } from 'node:crypto';
import { LoggerService } from '@backstage/backend-plugin-api';
import {
BackstageSignInResult,
diff --git a/plugins/auth-backend/src/migrations.test.ts b/plugins/auth-backend/src/migrations.test.ts
index 5dec51ed85..eeb9cdbba8 100644
--- a/plugins/auth-backend/src/migrations.test.ts
+++ b/plugins/auth-backend/src/migrations.test.ts
@@ -41,179 +41,154 @@ async function migrateUntilBefore(knex: Knex, target: string): Promise {
jest.setTimeout(60_000);
-describe('migrations', () => {
- const databases = TestDatabases.create();
+const databases = TestDatabases.create();
- it.each(databases.eachSupportedId())(
- '20230428155633_sessions.js, %p',
- async databaseId => {
- const knex = await databases.init(databaseId);
+describe.each(databases.eachSupportedId())('migrations, %p', databaseId => {
+ it('20230428155633_sessions.js', async () => {
+ const knex = await databases.init(databaseId);
- await migrateUntilBefore(knex, '20230428155633_sessions.js');
+ await migrateUntilBefore(knex, '20230428155633_sessions.js');
+ await migrateUpOnce(knex);
+
+ // Ensure that large cookies are supported
+ const data = `{"cookie":"${'a'.repeat(100_000)}"}`;
+ await knex
+ .insert({ sid: 'abc', expired: knex.fn.now(), sess: data })
+ .into('sessions');
+ await knex
+ .insert({ sid: 'def', expired: knex.fn.now(), sess: data })
+ .into('sessions');
+
+ await expect(knex('sessions').orderBy('sid', 'asc')).resolves.toEqual([
+ { sid: 'abc', expired: expect.anything(), sess: data },
+ { sid: 'def', expired: expect.anything(), sess: data },
+ ]);
+
+ await migrateDownOnce(knex);
+
+ await knex.destroy();
+ });
+
+ it('20240510120825_user_info.js', async () => {
+ const knex = await databases.init(databaseId);
+
+ await migrateUntilBefore(knex, '20240510120825_user_info.js');
+ await migrateUpOnce(knex);
+
+ const user_info = JSON.stringify({
+ claims: {
+ ent: ['group:default/group1', 'group:default/group2'],
+ },
+ });
+
+ await knex
+ .insert({
+ user_entity_ref: 'user:default/backstage-user',
+ user_info,
+ exp: knex.fn.now(),
+ })
+ .into('user_info');
+
+ await expect(knex('user_info')).resolves.toEqual([
+ {
+ user_entity_ref: 'user:default/backstage-user',
+ user_info,
+ exp: expect.anything(),
+ },
+ ]);
+
+ await migrateDownOnce(knex);
+
+ await knex.destroy();
+ });
+
+ it('20250707164600_user_created_at.js', async () => {
+ const knex = await databases.init(databaseId);
+ await migrateUntilBefore(knex, '20250707164600_user_created_at.js');
+
+ if (knex.client.config.client.includes('sqlite')) {
+ // Sqlite doesn't support adding a column with non-constant default when table has data
+ // so we just test that the migration runs without errors
await migrateUpOnce(knex);
- // Ensure that large cookies are supported
- const data = `{"cookie":"${'a'.repeat(100_000)}"}`;
- await knex
- .insert({ sid: 'abc', expired: knex.fn.now(), sess: data })
- .into('sessions');
- await knex
- .insert({ sid: 'def', expired: knex.fn.now(), sess: data })
- .into('sessions');
+ return;
+ }
- await expect(knex('sessions').orderBy('sid', 'asc')).resolves.toEqual([
- { sid: 'abc', expired: expect.anything(), sess: data },
- { sid: 'def', expired: expect.anything(), sess: data },
- ]);
+ const user_info = JSON.stringify({
+ claims: {
+ ent: ['group:default/group1', 'group:default/group2'],
+ },
+ });
- await migrateDownOnce(knex);
+ await knex
+ .insert({
+ user_entity_ref: 'user:default/backstage-user',
+ user_info,
+ exp: knex.fn.now(),
+ })
+ .into('user_info');
- await knex.destroy();
- },
- );
+ const { exp } = await knex('user_info').first();
- it.each(databases.eachSupportedId())(
- '20240510120825_user_info.js, %p',
- async databaseId => {
- const knex = await databases.init(databaseId);
+ await migrateUpOnce(knex);
- await migrateUntilBefore(knex, '20240510120825_user_info.js');
- await migrateUpOnce(knex);
+ const { created_at, updated_at } = await knex('user_info').first();
- const user_info = JSON.stringify({
- claims: {
- ent: ['group:default/group1', 'group:default/group2'],
- },
- });
+ expect(updated_at).toEqual(exp);
+ expect(created_at).toBeDefined();
- await knex
- .insert({
- user_entity_ref: 'user:default/backstage-user',
- user_info,
- exp: knex.fn.now(),
- })
- .into('user_info');
+ await knex
+ .insert({
+ user_entity_ref: 'user:default/backstage-user',
+ user_info,
+ updated_at: knex.fn.now(),
+ })
+ .into('user_info')
+ .onConflict(['user_entity_ref'])
+ .merge();
- await expect(knex('user_info')).resolves.toEqual([
- {
- user_entity_ref: 'user:default/backstage-user',
- user_info,
- exp: expect.anything(),
- },
- ]);
+ await knex
+ .insert({
+ user_entity_ref: 'user:default/backstage-user-2',
+ user_info,
+ updated_at: knex.fn.now(),
+ })
+ .into('user_info');
- await migrateDownOnce(knex);
+ await expect(
+ knex('user_info').select('created_at', 'updated_at'),
+ ).resolves.toEqual([
+ {
+ created_at: expect.any(Date),
+ updated_at: expect.any(Date),
+ },
+ {
+ created_at: expect.any(Date),
+ updated_at: expect.any(Date),
+ },
+ ]);
- await knex.destroy();
- },
- );
+ await migrateDownOnce(knex);
- it.each(databases.eachSupportedId())(
- '20250707164600_user_created_at.js, %p',
- async databaseId => {
- const knex = await databases.init(databaseId);
- await migrateUntilBefore(knex, '20250707164600_user_created_at.js');
+ await expect(knex('user_info').select('exp')).resolves.toEqual([
+ { exp: expect.any(Date) },
+ { exp: expect.any(Date) },
+ ]);
- if (knex.client.config.client.includes('sqlite')) {
- // Sqlite doesn't support adding a column with non-constant default when table has data
- // so we just test that the migration runs without errors
- await migrateUpOnce(knex);
+ await knex.destroy();
+ });
- return;
- }
+ it('20250909120000_oidc_client_registration.js', async () => {
+ const knex = await databases.init(databaseId);
- const user_info = JSON.stringify({
- claims: {
- ent: ['group:default/group1', 'group:default/group2'],
- },
- });
+ await migrateUntilBefore(
+ knex,
+ '20250909120000_oidc_client_registration.js',
+ );
+ await migrateUpOnce(knex);
- await knex
- .insert({
- user_entity_ref: 'user:default/backstage-user',
- user_info,
- exp: knex.fn.now(),
- })
- .into('user_info');
-
- const { exp } = await knex('user_info').first();
-
- await migrateUpOnce(knex);
-
- const { created_at, updated_at } = await knex('user_info').first();
-
- expect(updated_at).toEqual(exp);
- expect(created_at).toBeDefined();
-
- await knex
- .insert({
- user_entity_ref: 'user:default/backstage-user',
- user_info,
- updated_at: knex.fn.now(),
- })
- .into('user_info')
- .onConflict(['user_entity_ref'])
- .merge();
-
- await knex
- .insert({
- user_entity_ref: 'user:default/backstage-user-2',
- user_info,
- updated_at: knex.fn.now(),
- })
- .into('user_info');
-
- await expect(
- knex('user_info').select('created_at', 'updated_at'),
- ).resolves.toEqual([
- {
- created_at: expect.any(Date),
- updated_at: expect.any(Date),
- },
- {
- created_at: expect.any(Date),
- updated_at: expect.any(Date),
- },
- ]);
-
- await migrateDownOnce(knex);
-
- await expect(knex('user_info').select('exp')).resolves.toEqual([
- { exp: expect.any(Date) },
- { exp: expect.any(Date) },
- ]);
-
- await knex.destroy();
- },
- );
-
- it.each(databases.eachSupportedId())(
- '20250909120000_oidc_client_registration.js, %p',
- async databaseId => {
- const knex = await databases.init(databaseId);
-
- await migrateUntilBefore(
- knex,
- '20250909120000_oidc_client_registration.js',
- );
- await migrateUpOnce(knex);
-
- await knex
- .insert({
- client_id: 'test-client-id',
- client_secret: 'test-client-secret',
- client_name: 'Test Client',
- response_types: JSON.stringify(['code']),
- grant_types: JSON.stringify(['authorization_code']),
- redirect_uris: JSON.stringify(['https://example.com/callback']),
- scope: 'openid profile',
- metadata: JSON.stringify({ description: 'Test client' }),
- })
- .into('oidc_clients');
-
- await expect(
- knex('oidc_clients').where('client_id', 'test-client-id').first(),
- ).resolves.toEqual({
+ await knex
+ .insert({
client_id: 'test-client-id',
client_secret: 'test-client-secret',
client_name: 'Test Client',
@@ -222,230 +197,235 @@ describe('migrations', () => {
redirect_uris: JSON.stringify(['https://example.com/callback']),
scope: 'openid profile',
metadata: JSON.stringify({ description: 'Test client' }),
- });
+ })
+ .into('oidc_clients');
- await knex
- .insert({
- id: 'test-session-id',
- client_id: 'test-client-id',
- user_entity_ref: 'user:default/test-user',
- redirect_uri: 'https://example.com/callback',
- scope: 'openid',
- state: 'test-state',
- response_type: 'code',
- code_challenge: 'test-challenge',
- code_challenge_method: 'S256',
- nonce: 'test-nonce',
- status: 'pending',
- expires_at: new Date(Date.now() + 3600000),
- })
- .into('oauth_authorization_sessions');
+ await expect(
+ knex('oidc_clients').where('client_id', 'test-client-id').first(),
+ ).resolves.toEqual({
+ client_id: 'test-client-id',
+ client_secret: 'test-client-secret',
+ client_name: 'Test Client',
+ response_types: JSON.stringify(['code']),
+ grant_types: JSON.stringify(['authorization_code']),
+ redirect_uris: JSON.stringify(['https://example.com/callback']),
+ scope: 'openid profile',
+ metadata: JSON.stringify({ description: 'Test client' }),
+ });
- await expect(
- knex('oauth_authorization_sessions')
- .where('id', 'test-session-id')
- .first(),
- ).resolves.toEqual(
- expect.objectContaining({
- id: 'test-session-id',
- client_id: 'test-client-id',
- user_entity_ref: 'user:default/test-user',
- redirect_uri: 'https://example.com/callback',
- scope: 'openid',
- state: 'test-state',
- response_type: 'code',
- code_challenge: 'test-challenge',
- code_challenge_method: 'S256',
- nonce: 'test-nonce',
- status: 'pending',
- }),
- );
+ await knex
+ .insert({
+ id: 'test-session-id',
+ client_id: 'test-client-id',
+ user_entity_ref: 'user:default/test-user',
+ redirect_uri: 'https://example.com/callback',
+ scope: 'openid',
+ state: 'test-state',
+ response_type: 'code',
+ code_challenge: 'test-challenge',
+ code_challenge_method: 'S256',
+ nonce: 'test-nonce',
+ status: 'pending',
+ expires_at: new Date(Date.now() + 3600000),
+ })
+ .into('oauth_authorization_sessions');
- await knex
- .insert({
- code: 'test-auth-code',
- session_id: 'test-session-id',
- expires_at: new Date(Date.now() + 600000),
- used: false,
- })
- .into('oidc_authorization_codes');
-
- await expect(
- knex('oidc_authorization_codes')
- .where('code', 'test-auth-code')
- .first(),
- ).resolves.toEqual(
- expect.objectContaining({
- code: 'test-auth-code',
- session_id: 'test-session-id',
- }),
- );
-
- await knex('oauth_authorization_sessions')
+ await expect(
+ knex('oauth_authorization_sessions')
.where('id', 'test-session-id')
- .del();
+ .first(),
+ ).resolves.toEqual(
+ expect.objectContaining({
+ id: 'test-session-id',
+ client_id: 'test-client-id',
+ user_entity_ref: 'user:default/test-user',
+ redirect_uri: 'https://example.com/callback',
+ scope: 'openid',
+ state: 'test-state',
+ response_type: 'code',
+ code_challenge: 'test-challenge',
+ code_challenge_method: 'S256',
+ nonce: 'test-nonce',
+ status: 'pending',
+ }),
+ );
- await expect(
- knex('oidc_authorization_codes').where('session_id', 'test-session-id'),
- ).resolves.toHaveLength(0);
+ await knex
+ .insert({
+ code: 'test-auth-code',
+ session_id: 'test-session-id',
+ expires_at: new Date(Date.now() + 600000),
+ used: false,
+ })
+ .into('oidc_authorization_codes');
- await migrateDownOnce(knex);
+ await expect(
+ knex('oidc_authorization_codes').where('code', 'test-auth-code').first(),
+ ).resolves.toEqual(
+ expect.objectContaining({
+ code: 'test-auth-code',
+ session_id: 'test-session-id',
+ }),
+ );
- const tables = [
- 'oidc_clients',
- 'oauth_authorization_sessions',
- 'oidc_authorization_codes',
- ];
+ await knex('oauth_authorization_sessions')
+ .where('id', 'test-session-id')
+ .del();
- for (const table of tables) {
- await expect(knex.schema.hasTable(table)).resolves.toBe(false);
- }
+ await expect(
+ knex('oidc_authorization_codes').where('session_id', 'test-session-id'),
+ ).resolves.toHaveLength(0);
- await knex.destroy();
- },
- );
+ await migrateDownOnce(knex);
- it.each(databases.eachSupportedId())(
- '20251118120000_oauth_state_text.js, %p',
- async databaseId => {
- const knex = await databases.init(databaseId);
+ const tables = [
+ 'oidc_clients',
+ 'oauth_authorization_sessions',
+ 'oidc_authorization_codes',
+ ];
- await migrateUntilBefore(knex, '20251118120000_oauth_state_text.js');
+ for (const table of tables) {
+ await expect(knex.schema.hasTable(table)).resolves.toBe(false);
+ }
- // First create a client for the foreign key constraint
- await knex
- .insert({
- client_id: 'test-client-id',
- client_secret: 'test-client-secret',
- client_name: 'Test Client',
- response_types: JSON.stringify(['code']),
- grant_types: JSON.stringify(['authorization_code']),
- redirect_uris: JSON.stringify(['https://example.com/callback']),
- })
- .into('oidc_clients');
+ await knex.destroy();
+ });
- // Insert a session with state before migration
- const existingState = 'existing-short-state';
- await knex
- .insert({
- id: 'test-existing-session',
- client_id: 'test-client-id',
- redirect_uri: 'https://example.com/callback',
- state: existingState,
- response_type: 'code',
- status: 'pending',
- expires_at: new Date(Date.now() + 3600000),
- })
- .into('oauth_authorization_sessions');
+ it('20251118120000_oauth_state_text.js', async () => {
+ const knex = await databases.init(databaseId);
- // Apply the migration that changes state to TEXT
- await migrateUpOnce(knex);
+ await migrateUntilBefore(knex, '20251118120000_oauth_state_text.js');
- // Verify existing state persists after migration
- await expect(
- knex('oauth_authorization_sessions')
- .where('id', 'test-existing-session')
- .first(),
- ).resolves.toEqual(
- expect.objectContaining({
- id: 'test-existing-session',
- state: existingState,
- }),
- );
+ // First create a client for the foreign key constraint
+ await knex
+ .insert({
+ client_id: 'test-client-id',
+ client_secret: 'test-client-secret',
+ client_name: 'Test Client',
+ response_types: JSON.stringify(['code']),
+ grant_types: JSON.stringify(['authorization_code']),
+ redirect_uris: JSON.stringify(['https://example.com/callback']),
+ })
+ .into('oidc_clients');
- // Test inserting a state parameter longer than 255 characters
- // This is based on the real-world example from the issue
- const longState = 'a'.repeat(280);
+ // Insert a session with state before migration
+ const existingState = 'existing-short-state';
+ await knex
+ .insert({
+ id: 'test-existing-session',
+ client_id: 'test-client-id',
+ redirect_uri: 'https://example.com/callback',
+ state: existingState,
+ response_type: 'code',
+ status: 'pending',
+ expires_at: new Date(Date.now() + 3600000),
+ })
+ .into('oauth_authorization_sessions');
- await knex
- .insert({
- id: 'test-long-state-session',
- client_id: 'test-client-id',
- redirect_uri: 'https://example.com/callback',
- state: longState,
- response_type: 'code',
- status: 'pending',
- expires_at: new Date(Date.now() + 3600000),
- })
- .into('oauth_authorization_sessions');
+ // Apply the migration that changes state to TEXT
+ await migrateUpOnce(knex);
- await expect(
- knex('oauth_authorization_sessions')
- .where('id', 'test-long-state-session')
- .first(),
- ).resolves.toEqual(
- expect.objectContaining({
- id: 'test-long-state-session',
- state: longState,
- }),
- );
+ // Verify existing state persists after migration
+ await expect(
+ knex('oauth_authorization_sessions')
+ .where('id', 'test-existing-session')
+ .first(),
+ ).resolves.toEqual(
+ expect.objectContaining({
+ id: 'test-existing-session',
+ state: existingState,
+ }),
+ );
- await migrateDownOnce(knex);
+ // Test inserting a state parameter longer than 255 characters
+ // This is based on the real-world example from the issue
+ const longState = 'a'.repeat(280);
- await knex.destroy();
- },
- );
+ await knex
+ .insert({
+ id: 'test-long-state-session',
+ client_id: 'test-client-id',
+ redirect_uri: 'https://example.com/callback',
+ state: longState,
+ response_type: 'code',
+ status: 'pending',
+ expires_at: new Date(Date.now() + 3600000),
+ })
+ .into('oauth_authorization_sessions');
- it.each(databases.eachSupportedId())(
- '20251217120000_drop_oidc_clients_fk.js, %p',
- async databaseId => {
- const knex = await databases.init(databaseId);
+ await expect(
+ knex('oauth_authorization_sessions')
+ .where('id', 'test-long-state-session')
+ .first(),
+ ).resolves.toEqual(
+ expect.objectContaining({
+ id: 'test-long-state-session',
+ state: longState,
+ }),
+ );
- await migrateUntilBefore(knex, '20251217120000_drop_oidc_clients_fk.js');
+ await migrateDownOnce(knex);
- // Create a client for DCR sessions
- await knex
- .insert({
- client_id: 'dcr-client-id',
- client_secret: 'test-client-secret',
- client_name: 'DCR Client',
- response_types: JSON.stringify(['code']),
- grant_types: JSON.stringify(['authorization_code']),
- redirect_uris: JSON.stringify(['https://example.com/callback']),
- })
- .into('oidc_clients');
+ await knex.destroy();
+ });
- // Create a DCR session (has matching client in oidc_clients)
- await knex
- .insert({
- id: 'dcr-session',
- client_id: 'dcr-client-id',
- redirect_uri: 'https://example.com/callback',
- response_type: 'code',
- status: 'pending',
- expires_at: new Date(Date.now() + 3600000),
- })
- .into('oauth_authorization_sessions');
+ it('20251217120000_drop_oidc_clients_fk.js', async () => {
+ const knex = await databases.init(databaseId);
- // Apply migration - drops FK constraint
- await migrateUpOnce(knex);
+ await migrateUntilBefore(knex, '20251217120000_drop_oidc_clients_fk.js');
- // Now we can insert a CIMD session (URL-based client_id not in oidc_clients)
- await knex
- .insert({
- id: 'cimd-session',
- client_id: 'https://example.com/.well-known/oauth-client/cli',
- redirect_uri: 'http://localhost:8080/callback',
- response_type: 'code',
- status: 'pending',
- expires_at: new Date(Date.now() + 3600000),
- })
- .into('oauth_authorization_sessions');
+ // Create a client for DCR sessions
+ await knex
+ .insert({
+ client_id: 'dcr-client-id',
+ client_secret: 'test-client-secret',
+ client_name: 'DCR Client',
+ response_types: JSON.stringify(['code']),
+ grant_types: JSON.stringify(['authorization_code']),
+ redirect_uris: JSON.stringify(['https://example.com/callback']),
+ })
+ .into('oidc_clients');
- // Verify both sessions exist
- await expect(
- knex('oauth_authorization_sessions').select('id').orderBy('id'),
- ).resolves.toEqual([{ id: 'cimd-session' }, { id: 'dcr-session' }]);
+ // Create a DCR session (has matching client in oidc_clients)
+ await knex
+ .insert({
+ id: 'dcr-session',
+ client_id: 'dcr-client-id',
+ redirect_uri: 'https://example.com/callback',
+ response_type: 'code',
+ status: 'pending',
+ expires_at: new Date(Date.now() + 3600000),
+ })
+ .into('oauth_authorization_sessions');
- // Rollback - should delete CIMD sessions and re-add FK
- await migrateDownOnce(knex);
+ // Apply migration - drops FK constraint
+ await migrateUpOnce(knex);
- // CIMD session should be deleted, DCR session should remain
- await expect(
- knex('oauth_authorization_sessions').select('id'),
- ).resolves.toEqual([{ id: 'dcr-session' }]);
+ // Now we can insert a CIMD session (URL-based client_id not in oidc_clients)
+ await knex
+ .insert({
+ id: 'cimd-session',
+ client_id: 'https://example.com/.well-known/oauth-client/cli',
+ redirect_uri: 'http://localhost:8080/callback',
+ response_type: 'code',
+ status: 'pending',
+ expires_at: new Date(Date.now() + 3600000),
+ })
+ .into('oauth_authorization_sessions');
- await knex.destroy();
- },
- );
+ // Verify both sessions exist
+ await expect(
+ knex('oauth_authorization_sessions').select('id').orderBy('id'),
+ ).resolves.toEqual([{ id: 'cimd-session' }, { id: 'dcr-session' }]);
+
+ // Rollback - should delete CIMD sessions and re-add FK
+ await migrateDownOnce(knex);
+
+ // CIMD session should be deleted, DCR session should remain
+ await expect(
+ knex('oauth_authorization_sessions').select('id'),
+ ).resolves.toEqual([{ id: 'dcr-session' }]);
+
+ await knex.destroy();
+ });
});
diff --git a/plugins/auth-backend/src/providers/router.test.ts b/plugins/auth-backend/src/providers/router.test.ts
index 3a1fde881e..b312a3f1d8 100644
--- a/plugins/auth-backend/src/providers/router.test.ts
+++ b/plugins/auth-backend/src/providers/router.test.ts
@@ -33,7 +33,7 @@ describe('Auth origin filtering', () => {
});
it('Will explode, invalid origin domain', () => {
- const origin = 'https://test-1234.examplee.net';
+ const origin = 'https://test-1234.wrong-example.net';
expect(createOriginFilter(config)(origin)).toBeFalsy();
});
diff --git a/plugins/auth-backend/src/service/CimdClient.test.ts b/plugins/auth-backend/src/service/CimdClient.test.ts
index 03d453e5e9..8c21ec3167 100644
--- a/plugins/auth-backend/src/service/CimdClient.test.ts
+++ b/plugins/auth-backend/src/service/CimdClient.test.ts
@@ -328,6 +328,29 @@ describe('CimdClient', () => {
).rejects.toThrow('Invalid client metadata document');
});
+ it('should throw for oversized JSON without content-length', async () => {
+ const oversizedMetadata = {
+ client_id: 'https://example.com/oauth-metadata.json',
+ client_name: 'x'.repeat(64 * 1024),
+ redirect_uris: ['http://localhost:8080/callback'],
+ };
+ const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue(
+ new Response(JSON.stringify(oversizedMetadata), {
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+
+ try {
+ await expect(
+ fetchCimdMetadata({
+ clientId: 'https://example.com/oauth-metadata.json',
+ }),
+ ).rejects.toThrow('Client metadata document too large');
+ } finally {
+ fetchMock.mockRestore();
+ }
+ });
+
it('should throw for client_id mismatch', async () => {
const mismatchedMetadata = {
client_id: 'https://different.com/metadata',
diff --git a/plugins/auth-backend/src/service/CimdClient.ts b/plugins/auth-backend/src/service/CimdClient.ts
index 7d8567f97d..eee9b9637f 100644
--- a/plugins/auth-backend/src/service/CimdClient.ts
+++ b/plugins/auth-backend/src/service/CimdClient.ts
@@ -187,6 +187,24 @@ function validateMetadata(
}
}
+async function readCappedResponseBody(response: Response): Promise {
+ if (!response.body) {
+ return '';
+ }
+
+ const chunks: Buffer[] = [];
+ let received = 0;
+ for await (const chunk of response.body) {
+ received += chunk.byteLength;
+ if (received > MAX_RESPONSE_BYTES) {
+ throw new InputError('Client metadata document too large');
+ }
+ chunks.push(Buffer.from(chunk));
+ }
+
+ return Buffer.concat(chunks).toString('utf8');
+}
+
/**
* Fetches and validates a CIMD metadata document.
* @throws InputError if fetching or validation fails
@@ -228,8 +246,12 @@ export async function fetchCimdMetadata(opts: {
let metadata: CimdMetadata;
try {
- metadata = await response.json();
- } catch {
+ const responseBody = await readCappedResponseBody(response);
+ metadata = JSON.parse(responseBody) as CimdMetadata;
+ } catch (error) {
+ if (isError(error) && error.name === 'InputError') {
+ throw error;
+ }
throw new InputError('Invalid client metadata document');
}
diff --git a/plugins/auth-backend/src/service/OfflineAccessService.ts b/plugins/auth-backend/src/service/OfflineAccessService.ts
index e8a8cae1f1..67c0b72d85 100644
--- a/plugins/auth-backend/src/service/OfflineAccessService.ts
+++ b/plugins/auth-backend/src/service/OfflineAccessService.ts
@@ -15,6 +15,7 @@
*/
import {
+ AuthService,
DatabaseService,
LifecycleService,
LoggerService,
@@ -23,7 +24,8 @@ import {
import { AuthenticationError } from '@backstage/errors';
import { readDurationFromConfig } from '@backstage/config';
import { durationToMilliseconds } from '@backstage/types';
-import { v4 as uuid } from 'uuid';
+import { CatalogService } from '@backstage/plugin-catalog-node';
+import { randomUUID as uuid } from 'node:crypto';
import { OfflineSessionDatabase } from '../database/OfflineSessionDatabase';
import {
generateRefreshToken,
@@ -39,12 +41,17 @@ import { TokenIssuer } from '../identity/types';
export class OfflineAccessService {
readonly #offlineSessionDb: OfflineSessionDatabase;
readonly #logger: LoggerService;
+ readonly #dangerouslyDisableCatalogPresenceCheck: boolean;
+ readonly #catalog: CatalogService;
+ readonly #auth: AuthService;
static async create(options: {
config: RootConfigService;
database: DatabaseService;
logger: LoggerService;
lifecycle: LifecycleService;
+ catalog: CatalogService;
+ auth: AuthService;
}): Promise {
const { config, database, logger, lifecycle } = options;
@@ -98,6 +105,11 @@ export class OfflineAccessService {
);
}
+ const dangerouslyDisableCatalogPresenceCheck =
+ config.getOptionalBoolean(
+ 'auth.experimentalRefreshToken.dangerouslyDisableCatalogPresenceCheck',
+ ) ?? false;
+
const knex = await database.getClient();
if (
@@ -135,15 +147,28 @@ export class OfflineAccessService {
clearInterval(cleanupInterval);
});
- return new OfflineAccessService(offlineSessionDb, logger);
+ return new OfflineAccessService(
+ offlineSessionDb,
+ logger,
+ dangerouslyDisableCatalogPresenceCheck,
+ options.catalog,
+ options.auth,
+ );
}
private constructor(
offlineSessionDb: OfflineSessionDatabase,
logger: LoggerService,
+ dangerouslyDisableCatalogPresenceCheck: boolean,
+ catalog: CatalogService,
+ auth: AuthService,
) {
this.#offlineSessionDb = offlineSessionDb;
this.#logger = logger;
+ this.#dangerouslyDisableCatalogPresenceCheck =
+ dangerouslyDisableCatalogPresenceCheck;
+ this.#catalog = catalog;
+ this.#auth = auth;
}
/**
@@ -212,6 +237,33 @@ export class OfflineAccessService {
throw new AuthenticationError('Invalid refresh token');
}
+ if (!this.#dangerouslyDisableCatalogPresenceCheck) {
+ try {
+ const entity = await this.#catalog.getEntityByRef(
+ session.userEntityRef,
+ { credentials: await this.#auth.getOwnServiceCredentials() },
+ );
+ if (!entity) {
+ this.#logger.info(
+ `Rejecting refresh for user ${session.userEntityRef} - catalog entity not found, revoking session ${sessionId}`,
+ );
+ await this.#offlineSessionDb.deleteSession(sessionId);
+ throw new AuthenticationError(
+ 'User entity no longer exists in the catalog',
+ );
+ }
+ } catch (error) {
+ if (error.name === 'AuthenticationError') {
+ throw error;
+ }
+ this.#logger.warn(
+ `Failed to validate catalog user existence for ${session.userEntityRef}, rejecting refresh`,
+ error,
+ );
+ throw new AuthenticationError('Unable to validate user existence');
+ }
+ }
+
const { token: newRefreshToken, hash: newHash } =
await generateRefreshToken(sessionId);
diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts
index 215477656f..d1e4d5262a 100644
--- a/plugins/auth-backend/src/service/OidcRouter.test.ts
+++ b/plugins/auth-backend/src/service/OidcRouter.test.ts
@@ -36,6 +36,7 @@ import { OidcService } from '../service/OidcService';
import { TokenIssuer } from '../identity/types';
import { OfflineAccessService } from './OfflineAccessService';
import { CimdClientInfo, validateCimdUrl } from './CimdClient';
+import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
jest.mock('./CimdClient', () => {
const actual = jest.requireActual('./CimdClient');
@@ -97,6 +98,7 @@ describe('OidcRouter', () => {
auth: {
experimentalDynamicClientRegistration: {
enabled: true,
+ allowedRedirectUriPatterns: ['*'],
},
},
},
@@ -137,7 +139,10 @@ describe('OidcRouter', () => {
};
}
- async function createRouterWithOfflineAccess(databaseId: TestDatabaseId) {
+ async function createRouterWithOfflineAccess(
+ databaseId: TestDatabaseId,
+ refreshTokenConfig?: Record,
+ ) {
const knex = await databases.init(databaseId);
await knex.migrate.latest({
@@ -166,14 +171,23 @@ describe('OidcRouter', () => {
const mockAuth = mockServices.auth.mock();
const mockHttpAuth = mockServices.httpAuth.mock();
+ const mockCatalog = catalogServiceMock.mock();
+ mockCatalog.getEntityByRef.mockResolvedValue({
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'User',
+ metadata: { name: 'test-user', namespace: 'default' },
+ spec: {},
+ });
const mockConfig = mockServices.rootConfig({
data: {
auth: {
experimentalDynamicClientRegistration: {
enabled: true,
+ allowedRedirectUriPatterns: ['*'],
},
experimentalRefreshToken: {
enabled: true,
+ ...refreshTokenConfig,
},
},
},
@@ -186,6 +200,8 @@ describe('OidcRouter', () => {
database: { getClient: async () => knex },
logger: mockServices.logger.mock(),
lifecycle: mockLifecycle,
+ catalog: mockCatalog,
+ auth: mockAuth,
});
const oidcService = OidcService.create({
@@ -221,6 +237,7 @@ describe('OidcRouter', () => {
userInfo: userInfoDatabase,
service: oidcService,
tokenIssuer: mockTokenIssuer,
+ catalog: mockCatalog,
},
};
}
@@ -476,6 +493,7 @@ describe('OidcRouter', () => {
expect(response.body).toEqual({
id: authSession.id,
+ clientId: client.clientId,
clientName: 'Test Client',
scope: 'openid',
redirectUri: 'https://example.com/callback',
@@ -921,8 +939,14 @@ describe('OidcRouter', () => {
});
describe('refresh tokens', () => {
- async function doAuthFlowWithOfflineAccess(databaseId_: TestDatabaseId) {
- const result = await createRouterWithOfflineAccess(databaseId_);
+ async function doAuthFlowWithOfflineAccess(
+ databaseId_: TestDatabaseId,
+ refreshTokenConfig?: Record,
+ ) {
+ const result = await createRouterWithOfflineAccess(
+ databaseId_,
+ refreshTokenConfig,
+ );
const {
mocks: { auth, service, tokenIssuer, httpAuth },
router,
@@ -1120,6 +1144,136 @@ describe('OidcRouter', () => {
})
.expect(400);
});
+
+ describe('catalog user validation', () => {
+ it('should reject refresh when catalog user does not exist', async () => {
+ const { server, tokenResponse, mocks } =
+ await doAuthFlowWithOfflineAccess(databaseId);
+
+ mocks.catalog.getEntityByRef.mockResolvedValueOnce(undefined);
+
+ await request(server)
+ .post('/api/auth/v1/token')
+ .send({
+ grant_type: 'refresh_token',
+ refresh_token: tokenResponse.body.refresh_token,
+ })
+ .expect(400);
+ });
+
+ it('should reject refresh when catalog is unavailable', async () => {
+ const { server, tokenResponse, mocks } =
+ await doAuthFlowWithOfflineAccess(databaseId);
+
+ mocks.catalog.getEntityByRef.mockRejectedValueOnce(
+ new Error('Catalog unavailable'),
+ );
+
+ await request(server)
+ .post('/api/auth/v1/token')
+ .send({
+ grant_type: 'refresh_token',
+ refresh_token: tokenResponse.body.refresh_token,
+ })
+ .expect(400);
+ });
+
+ it('should allow retry after transient catalog failure', async () => {
+ const { server, tokenResponse, mocks } =
+ await doAuthFlowWithOfflineAccess(databaseId);
+
+ mocks.catalog.getEntityByRef.mockRejectedValueOnce(
+ new Error('Catalog unavailable'),
+ );
+
+ // First refresh fails due to catalog error
+ await request(server)
+ .post('/api/auth/v1/token')
+ .send({
+ grant_type: 'refresh_token',
+ refresh_token: tokenResponse.body.refresh_token,
+ })
+ .expect(400);
+
+ // Retry with same token succeeds because session was preserved
+ mocks.catalog.getEntityByRef.mockResolvedValueOnce({
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'User',
+ metadata: { name: 'test-user', namespace: 'default' },
+ spec: {},
+ });
+ mocks.tokenIssuer.issueToken.mockResolvedValue({
+ token: 'mock-refreshed-token',
+ });
+
+ const retryResponse = await request(server)
+ .post('/api/auth/v1/token')
+ .send({
+ grant_type: 'refresh_token',
+ refresh_token: tokenResponse.body.refresh_token,
+ })
+ .expect(200);
+
+ expect(retryResponse.body.access_token).toBe('mock-refreshed-token');
+ });
+
+ it('should not allow retry after user entity not found', async () => {
+ const { server, tokenResponse, mocks } =
+ await doAuthFlowWithOfflineAccess(databaseId);
+
+ mocks.catalog.getEntityByRef.mockResolvedValueOnce(undefined);
+
+ // First refresh fails and session is revoked
+ await request(server)
+ .post('/api/auth/v1/token')
+ .send({
+ grant_type: 'refresh_token',
+ refresh_token: tokenResponse.body.refresh_token,
+ })
+ .expect(400);
+
+ // Retry fails because session was deleted
+ mocks.catalog.getEntityByRef.mockResolvedValueOnce({
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'User',
+ metadata: { name: 'test-user', namespace: 'default' },
+ spec: {},
+ });
+
+ await request(server)
+ .post('/api/auth/v1/token')
+ .send({
+ grant_type: 'refresh_token',
+ refresh_token: tokenResponse.body.refresh_token,
+ })
+ .expect(400);
+ });
+
+ it('should skip catalog check when dangerouslyDisableCatalogPresenceCheck is set', async () => {
+ const { server, tokenResponse, mocks } =
+ await doAuthFlowWithOfflineAccess(databaseId, {
+ dangerouslyDisableCatalogPresenceCheck: true,
+ });
+
+ mocks.catalog.getEntityByRef.mockResolvedValueOnce(undefined);
+ mocks.tokenIssuer.issueToken.mockResolvedValue({
+ token: 'mock-refreshed-token',
+ });
+
+ const refreshResponse = await request(server)
+ .post('/api/auth/v1/token')
+ .send({
+ grant_type: 'refresh_token',
+ refresh_token: tokenResponse.body.refresh_token,
+ })
+ .expect(200);
+
+ expect(refreshResponse.body.access_token).toBe(
+ 'mock-refreshed-token',
+ );
+ expect(mocks.catalog.getEntityByRef).not.toHaveBeenCalled();
+ });
+ });
});
describe('CIMD metadata endpoint', () => {
@@ -1197,6 +1351,8 @@ describe('OidcRouter', () => {
auth: {
experimentalClientIdMetadataDocuments: {
enabled: true,
+ allowedClientIdPatterns: ['*'],
+ allowedRedirectUriPatterns: ['*'],
},
},
},
@@ -1289,6 +1445,8 @@ describe('OidcRouter', () => {
auth: {
experimentalClientIdMetadataDocuments: {
enabled: true,
+ allowedClientIdPatterns: ['*'],
+ allowedRedirectUriPatterns: ['*'],
},
// DCR is NOT enabled
},
diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts
index e7ed82b6bd..5753874b0e 100644
--- a/plugins/auth-backend/src/service/OidcRouter.ts
+++ b/plugins/auth-backend/src/service/OidcRouter.ts
@@ -331,6 +331,7 @@ export class OidcRouter {
return res.json({
id: session.id,
+ clientId: session.clientId,
clientName: session.clientName,
scope: session.scope,
redirectUri: session.redirectUri,
diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcService.test.ts
index c9bb0957ab..2138395297 100644
--- a/plugins/auth-backend/src/service/OidcService.test.ts
+++ b/plugins/auth-backend/src/service/OidcService.test.ts
@@ -224,7 +224,7 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
responseTypes: ['code'],
grantTypes: ['authorization_code'],
scope: 'openid',
@@ -233,7 +233,7 @@ describe('OidcService', () => {
expect(client).toEqual(
expect.objectContaining({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
responseTypes: ['code'],
grantTypes: ['authorization_code'],
scope: 'openid',
@@ -287,6 +287,74 @@ describe('OidcService', () => {
);
});
+ it('should accept IPv6 loopback redirect URI', async () => {
+ const { service } = await createOidcService({
+ databaseId,
+ config: {
+ auth: {
+ experimentalDynamicClientRegistration: {
+ allowedRedirectUriPatterns: [
+ 'http://[::1]:*',
+ 'http://[::1]/*',
+ ],
+ },
+ },
+ },
+ });
+
+ const client = await service.registerClient({
+ clientName: 'Test Client',
+ redirectUris: ['http://[::1]:3000/callback'],
+ });
+
+ expect(client).toEqual(
+ expect.objectContaining({
+ redirectUris: ['http://[::1]:3000/callback'],
+ }),
+ );
+ });
+
+ it('should accept loopback redirect URIs with default patterns', async () => {
+ const { service } = await createOidcService({ databaseId });
+
+ const client = await service.registerClient({
+ clientName: 'Test Client',
+ redirectUris: ['http://localhost:3000/callback'],
+ });
+
+ expect(client).toEqual(
+ expect.objectContaining({
+ redirectUris: ['http://localhost:3000/callback'],
+ }),
+ );
+ });
+
+ it('should accept cursor redirect URIs with default patterns', async () => {
+ const { service } = await createOidcService({ databaseId });
+
+ const client = await service.registerClient({
+ clientName: 'Test Client',
+ redirectUris: ['cursor://callback'],
+ });
+
+ expect(client).toEqual(
+ expect.objectContaining({
+ redirectUris: ['cursor://callback'],
+ }),
+ );
+ });
+
+ it('should reject non-loopback redirect URIs with default patterns', async () => {
+ const { service } = await createOidcService({ databaseId });
+
+ await expect(
+ service.registerClient({
+ clientName: 'Test Client',
+ redirectUris: ['https://example.com/callback'],
+ }),
+ ).rejects.toThrow('Invalid redirect_uri');
+ });
+
it('should reject redirect URIs containing userinfo', async () => {
const { service } = await createOidcService({
databaseId,
@@ -338,12 +406,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
scope: 'openid',
state: 'test-state',
@@ -353,7 +421,7 @@ describe('OidcService', () => {
id: expect.any(String),
clientName: 'Test Client',
scope: 'openid',
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
});
});
@@ -363,7 +431,7 @@ describe('OidcService', () => {
await expect(
service.createAuthorizationSession({
clientId: 'invalid-client',
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
}),
).rejects.toThrow('Invalid client_id');
@@ -374,7 +442,7 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
await expect(
@@ -391,13 +459,13 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
await expect(
service.createAuthorizationSession({
clientId: client.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'token',
}),
).rejects.toThrow('Only authorization code flow is supported');
@@ -408,12 +476,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
codeChallenge: 'test-challenge',
codeChallengeMethod: 'S256',
@@ -427,13 +495,13 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
await expect(
service.createAuthorizationSession({
clientId: client.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
codeChallenge: 'test-challenge',
codeChallengeMethod: 'invalid',
@@ -448,12 +516,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
state: 'test-state',
});
@@ -464,7 +532,7 @@ describe('OidcService', () => {
});
expect(result.redirectUrl).toMatch(
- /^https:\/\/example\.com\/callback\?code=.+&state=test-state$/,
+ /^http:\/\/localhost:8080\/callback\?code=.+&state=test-state$/,
);
});
@@ -484,12 +552,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
});
@@ -511,12 +579,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
});
@@ -540,12 +608,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
scope: 'openid',
state: 'test-state',
@@ -560,7 +628,7 @@ describe('OidcService', () => {
id: authSession.id,
clientId: client.clientId,
clientName: 'Test Client',
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
scope: 'openid',
state: 'test-state',
responseType: 'code',
@@ -573,12 +641,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
});
@@ -599,12 +667,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
});
@@ -627,12 +695,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
});
@@ -664,12 +732,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
});
@@ -691,12 +759,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
});
@@ -722,12 +790,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
scope: 'openid',
});
@@ -741,7 +809,7 @@ describe('OidcService', () => {
const tokenResult = await service.exchangeCodeForToken({
code,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
grantType: 'authorization_code',
});
@@ -760,7 +828,7 @@ describe('OidcService', () => {
await expect(
service.exchangeCodeForToken({
code: 'test-code',
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
grantType: 'client_credentials',
}),
).rejects.toThrow('Unsupported grant type');
@@ -773,7 +841,7 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
const codeVerifier = 'test-code-verifier';
@@ -784,7 +852,7 @@ describe('OidcService', () => {
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
codeChallenge,
codeChallengeMethod: 'S256',
@@ -799,7 +867,7 @@ describe('OidcService', () => {
const tokenResult = await service.exchangeCodeForToken({
code,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
grantType: 'authorization_code',
codeVerifier,
});
@@ -812,13 +880,13 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
const codeChallenge = 'test-challenge';
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
codeChallenge,
codeChallengeMethod: 'S256',
@@ -834,7 +902,7 @@ describe('OidcService', () => {
await expect(
service.exchangeCodeForToken({
code,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
grantType: 'authorization_code',
codeVerifier: 'invalid-verifier',
}),
@@ -857,12 +925,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
scope: 'openid offline_access',
});
@@ -876,7 +944,7 @@ describe('OidcService', () => {
const tokenResult = await service.exchangeCodeForToken({
code,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
grantType: 'authorization_code',
});
@@ -921,7 +989,11 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
- experimentalClientIdMetadataDocuments: { enabled: true },
+ experimentalClientIdMetadataDocuments: {
+ enabled: true,
+ allowedClientIdPatterns: ['*'],
+ allowedRedirectUriPatterns: ['*'],
+ },
},
},
});
@@ -950,12 +1022,68 @@ describe('OidcService', () => {
});
describe('createAuthorizationSession with CIMD', () => {
+ it('should accept loopback redirect URIs with default CIMD patterns', async () => {
+ const { service } = await createOidcService({
+ databaseId,
+ config: {
+ auth: {
+ experimentalClientIdMetadataDocuments: {
+ enabled: true,
+ allowedClientIdPatterns: ['*'],
+ },
+ },
+ },
+ });
+
+ const authSession = await service.createAuthorizationSession({
+ clientId: cimdClientId,
+ redirectUri: 'http://localhost:8080/callback',
+ responseType: 'code',
+ scope: 'openid',
+ ...pkceParams,
+ });
+
+ expect(authSession).toEqual({
+ id: expect.any(String),
+ clientName: 'CIMD Test Client',
+ scope: 'openid',
+ redirectUri: 'http://localhost:8080/callback',
+ });
+ });
+
+ it('should reject non-loopback redirect URIs with default CIMD patterns', async () => {
+ const { service } = await createOidcService({
+ databaseId,
+ config: {
+ auth: {
+ experimentalClientIdMetadataDocuments: {
+ enabled: true,
+ allowedClientIdPatterns: ['*'],
+ },
+ },
+ },
+ });
+
+ await expect(
+ service.createAuthorizationSession({
+ clientId: cimdClientId,
+ redirectUri: 'https://example.com/callback',
+ responseType: 'code',
+ ...pkceParams,
+ }),
+ ).rejects.toThrow('Invalid redirect_uri');
+ });
+
it('should create authorization session for CIMD client', async () => {
const { service } = await createOidcService({
databaseId,
config: {
auth: {
- experimentalClientIdMetadataDocuments: { enabled: true },
+ experimentalClientIdMetadataDocuments: {
+ enabled: true,
+ allowedClientIdPatterns: ['*'],
+ allowedRedirectUriPatterns: ['*'],
+ },
},
},
});
@@ -1054,7 +1182,11 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
- experimentalClientIdMetadataDocuments: { enabled: true },
+ experimentalClientIdMetadataDocuments: {
+ enabled: true,
+ allowedClientIdPatterns: ['*'],
+ allowedRedirectUriPatterns: ['*'],
+ },
},
},
});
@@ -1065,7 +1197,7 @@ describe('OidcService', () => {
redirectUri: 'http://unauthorized.com/callback',
responseType: 'code',
}),
- ).rejects.toThrow('Redirect URI not registered');
+ ).rejects.toThrow('not registered in client metadata');
});
it('should throw error when redirect_uri does not match allowedRedirectUriPatterns', async () => {
@@ -1075,6 +1207,7 @@ describe('OidcService', () => {
auth: {
experimentalClientIdMetadataDocuments: {
enabled: true,
+ allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['https://*.example.com/*'],
},
},
@@ -1100,7 +1233,11 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
- experimentalClientIdMetadataDocuments: { enabled: true },
+ experimentalClientIdMetadataDocuments: {
+ enabled: true,
+ allowedClientIdPatterns: ['*'],
+ allowedRedirectUriPatterns: ['*'],
+ },
},
},
});
@@ -1131,7 +1268,11 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
- experimentalClientIdMetadataDocuments: { enabled: true },
+ experimentalClientIdMetadataDocuments: {
+ enabled: true,
+ allowedClientIdPatterns: ['*'],
+ allowedRedirectUriPatterns: ['*'],
+ },
},
},
});
@@ -1143,7 +1284,83 @@ describe('OidcService', () => {
responseType: 'code',
...pkceParams,
}),
- ).rejects.toThrow('Redirect URI not registered');
+ ).rejects.toThrow('not registered in client metadata');
+ });
+
+ it('should accept IPv6 loopback redirect_uri with a different port per RFC 8252', async () => {
+ mockFetchCimdMetadata.mockResolvedValue({
+ ...cimdMetadata,
+ redirectUris: ['http://[::1]/callback'],
+ });
+
+ const { service } = await createOidcService({
+ databaseId,
+ config: {
+ auth: {
+ experimentalClientIdMetadataDocuments: {
+ enabled: true,
+ allowedClientIdPatterns: ['*'],
+ allowedRedirectUriPatterns: [
+ 'http://[::1]:*',
+ 'http://[::1]/*',
+ ],
+ },
+ },
+ },
+ });
+
+ const authSession = await service.createAuthorizationSession({
+ clientId: cimdClientId,
+ redirectUri: 'http://[::1]:54321/callback',
+ responseType: 'code',
+ scope: 'openid',
+ ...pkceParams,
+ });
+
+ expect(authSession).toEqual({
+ id: expect.any(String),
+ clientName: 'CIMD Test Client',
+ scope: 'openid',
+ redirectUri: 'http://[::1]:54321/callback',
+ });
+ });
+
+ it('should accept 127.0.0.1 loopback redirect_uri with a different port per RFC 8252', async () => {
+ mockFetchCimdMetadata.mockResolvedValue({
+ ...cimdMetadata,
+ redirectUris: ['http://127.0.0.1/callback'],
+ });
+
+ const { service } = await createOidcService({
+ databaseId,
+ config: {
+ auth: {
+ experimentalClientIdMetadataDocuments: {
+ enabled: true,
+ allowedClientIdPatterns: ['*'],
+ allowedRedirectUriPatterns: [
+ 'http://127.0.0.1:*',
+ 'http://127.0.0.1/*',
+ ],
+ },
+ },
+ },
+ });
+
+ const authSession = await service.createAuthorizationSession({
+ clientId: cimdClientId,
+ redirectUri: 'http://127.0.0.1:54321/callback',
+ responseType: 'code',
+ scope: 'openid',
+ ...pkceParams,
+ });
+
+ expect(authSession).toEqual({
+ id: expect.any(String),
+ clientName: 'CIMD Test Client',
+ scope: 'openid',
+ redirectUri: 'http://127.0.0.1:54321/callback',
+ });
});
it('should reject redirect_uri when CIMD metadata uses wildcard patterns', async () => {
@@ -1158,6 +1375,7 @@ describe('OidcService', () => {
auth: {
experimentalClientIdMetadataDocuments: {
enabled: true,
+ allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['http://localhost:*'],
},
},
@@ -1171,7 +1389,7 @@ describe('OidcService', () => {
responseType: 'code',
...pkceParams,
}),
- ).rejects.toThrow('Redirect URI not registered');
+ ).rejects.toThrow('not registered in client metadata');
});
it('should reject redirect_uri not exactly matching CIMD metadata', async () => {
@@ -1181,6 +1399,7 @@ describe('OidcService', () => {
auth: {
experimentalClientIdMetadataDocuments: {
enabled: true,
+ allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['http://localhost:*'],
},
},
@@ -1194,7 +1413,7 @@ describe('OidcService', () => {
responseType: 'code',
...pkceParams,
}),
- ).rejects.toThrow('Redirect URI not registered');
+ ).rejects.toThrow('not registered in client metadata');
});
it('should require PKCE for CIMD clients', async () => {
@@ -1202,7 +1421,11 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
- experimentalClientIdMetadataDocuments: { enabled: true },
+ experimentalClientIdMetadataDocuments: {
+ enabled: true,
+ allowedClientIdPatterns: ['*'],
+ allowedRedirectUriPatterns: ['*'],
+ },
},
},
});
@@ -1223,7 +1446,11 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
- experimentalClientIdMetadataDocuments: { enabled: true },
+ experimentalClientIdMetadataDocuments: {
+ enabled: true,
+ allowedClientIdPatterns: ['*'],
+ allowedRedirectUriPatterns: ['*'],
+ },
},
},
});
@@ -1260,7 +1487,11 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
- experimentalClientIdMetadataDocuments: { enabled: true },
+ experimentalClientIdMetadataDocuments: {
+ enabled: true,
+ allowedClientIdPatterns: ['*'],
+ allowedRedirectUriPatterns: ['*'],
+ },
},
},
});
@@ -1308,7 +1539,11 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
- experimentalClientIdMetadataDocuments: { enabled: true },
+ experimentalClientIdMetadataDocuments: {
+ enabled: true,
+ allowedClientIdPatterns: ['*'],
+ allowedRedirectUriPatterns: ['*'],
+ },
},
},
});
@@ -1357,8 +1592,15 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
- experimentalClientIdMetadataDocuments: { enabled: true },
- experimentalDynamicClientRegistration: { enabled: true },
+ experimentalClientIdMetadataDocuments: {
+ enabled: true,
+ allowedClientIdPatterns: ['*'],
+ allowedRedirectUriPatterns: ['*'],
+ },
+ experimentalDynamicClientRegistration: {
+ enabled: true,
+ allowedRedirectUriPatterns: ['*'],
+ },
},
},
});
@@ -1366,13 +1608,13 @@ describe('OidcService', () => {
// Register a DCR client
const dcrClient = await service.registerClient({
clientName: 'DCR Client',
- redirectUris: ['https://example.com/callback'],
+ redirectUris: ['http://localhost:8080/callback'],
});
// Create session with DCR client
const authSession = await service.createAuthorizationSession({
clientId: dcrClient.clientId,
- redirectUri: 'https://example.com/callback',
+ redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
});
@@ -1385,8 +1627,15 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
- experimentalClientIdMetadataDocuments: { enabled: true },
- experimentalDynamicClientRegistration: { enabled: true },
+ experimentalClientIdMetadataDocuments: {
+ enabled: true,
+ allowedClientIdPatterns: ['*'],
+ allowedRedirectUriPatterns: ['*'],
+ },
+ experimentalDynamicClientRegistration: {
+ enabled: true,
+ allowedRedirectUriPatterns: ['*'],
+ },
},
},
});
diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts
index 98a06bc42f..4babac121c 100644
--- a/plugins/auth-backend/src/service/OidcService.ts
+++ b/plugins/auth-backend/src/service/OidcService.ts
@@ -41,11 +41,19 @@ function validateRedirectUri(
const normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
if (!allowedPatterns.some(pattern => matcher.isMatch(normalized, pattern))) {
- throw new InputError('Invalid redirect_uri');
+ throw new InputError(`Invalid redirect_uri '${normalized}'`);
}
}
const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
+const LOOPBACK_REDIRECT_PATTERNS = [
+ 'http://localhost:*',
+ 'http://localhost/*',
+ 'http://127.0.0.1:*',
+ 'http://127.0.0.1/*',
+ 'http://[::1]:*',
+ 'http://[::1]/*',
+];
/**
* RFC 8252 Section 7.3: For loopback redirect URIs, the authorization server
@@ -213,7 +221,11 @@ export class OidcService {
const allowedRedirectUriPatterns = this.config.getOptionalStringArray(
'auth.experimentalDynamicClientRegistration.allowedRedirectUriPatterns',
- ) ?? ['*'];
+ ) ?? [
+ 'cursor://*',
+ 'https://www.cursor.com/*',
+ ...LOOPBACK_REDIRECT_PATTERNS,
+ ];
for (const redirectUri of opts.redirectUris ?? []) {
validateRedirectUri(redirectUri, allowedRedirectUriPatterns);
@@ -297,17 +309,22 @@ export class OidcService {
}
private getCimdConfig() {
+ const enabled =
+ this.config.getOptionalBoolean(
+ 'auth.experimentalClientIdMetadataDocuments.enabled',
+ ) ?? false;
+
+ const cliClientId = `${this.baseUrl}/.well-known/oauth-client/cli.json`;
+
return {
- enabled:
- this.config.getOptionalBoolean(
- 'auth.experimentalClientIdMetadataDocuments.enabled',
- ) ?? false,
+ enabled,
allowedClientIdPatterns: this.config.getOptionalStringArray(
'auth.experimentalClientIdMetadataDocuments.allowedClientIdPatterns',
- ) ?? ['*'],
- allowedRedirectUriPatterns: this.config.getOptionalStringArray(
- 'auth.experimentalClientIdMetadataDocuments.allowedRedirectUriPatterns',
- ) ?? ['*'],
+ ) ?? ['https://claude.ai/*', 'https://vscode.dev/*', cliClientId],
+ allowedRedirectUriPatterns:
+ this.config.getOptionalStringArray(
+ 'auth.experimentalClientIdMetadataDocuments.allowedRedirectUriPatterns',
+ ) ?? LOOPBACK_REDIRECT_PATTERNS,
};
}
@@ -344,7 +361,7 @@ export class OidcService {
matcher.isMatch(opts.clientId, pattern),
)
) {
- throw new InputError('Invalid client_id');
+ throw new InputError(`Invalid client_id '${opts.clientId}'`);
}
const cimdClient = await fetchCimdMetadata({
@@ -356,7 +373,9 @@ export class OidcService {
validateRedirectUri(opts.redirectUri, cimd.allowedRedirectUriPatterns);
if (!matchesRedirectUri(opts.redirectUri, cimdClient.redirectUris)) {
- throw new InputError('Redirect URI not registered');
+ throw new InputError(
+ `Invalid redirect_uri '${opts.redirectUri}', not registered in client metadata`,
+ );
}
}
@@ -377,7 +396,7 @@ export class OidcService {
}
if (opts.redirectUri && !client.redirectUris.includes(opts.redirectUri)) {
- throw new InputError('Invalid redirect_uri');
+ throw new InputError(`Invalid redirect_uri '${opts.redirectUri}'`);
}
return {
diff --git a/plugins/auth-node/CHANGELOG.md b/plugins/auth-node/CHANGELOG.md
index fcb9b67977..cf5906d0de 100644
--- a/plugins/auth-node/CHANGELOG.md
+++ b/plugins/auth-node/CHANGELOG.md
@@ -1,5 +1,26 @@
# @backstage/plugin-auth-node
+## 0.7.1
+
+### Patch Changes
+
+- 744fa1f: Removed duplicated entries that appeared in both `dependencies` and `devDependencies`.
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/catalog-client@1.15.1
+ - @backstage/config@1.3.8
+
+## 0.7.1-next.1
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.8.1-next.1
+
## 0.7.1-next.0
### Patch Changes
diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json
index 8aa8a711a7..c3c0f0aa70 100644
--- a/plugins/auth-node/package.json
+++ b/plugins/auth-node/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-node",
- "version": "0.7.1-next.0",
+ "version": "0.7.1",
"backstage": {
"role": "node-library",
"pluginId": "auth",
@@ -61,7 +61,6 @@
"cookie-parser": "^1.4.6",
"express-promise-router": "^4.1.1",
"msw": "^1.0.0",
- "supertest": "^7.0.0",
- "uuid": "^11.0.0"
+ "supertest": "^7.0.0"
}
}
diff --git a/plugins/auth-node/src/identity/DefaultIdentityClient.test.ts b/plugins/auth-node/src/identity/DefaultIdentityClient.test.ts
index 68765c6bef..4feec0d32c 100644
--- a/plugins/auth-node/src/identity/DefaultIdentityClient.test.ts
+++ b/plugins/auth-node/src/identity/DefaultIdentityClient.test.ts
@@ -23,7 +23,7 @@ import {
import { cloneDeep } from 'lodash';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
-import { v4 as uuid } from 'uuid';
+import { randomUUID as uuid } from 'node:crypto';
import { DefaultIdentityClient } from './DefaultIdentityClient';
import { IdentityApiGetIdentityRequest } from './IdentityApi';
diff --git a/plugins/auth-node/src/identity/IdentityClient.test.ts b/plugins/auth-node/src/identity/IdentityClient.test.ts
index 91cd1759aa..a0c32d6a82 100644
--- a/plugins/auth-node/src/identity/IdentityClient.test.ts
+++ b/plugins/auth-node/src/identity/IdentityClient.test.ts
@@ -24,7 +24,7 @@ import {
import { cloneDeep } from 'lodash';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
-import { v4 as uuid } from 'uuid';
+import { randomUUID as uuid } from 'node:crypto';
import { IdentityClient } from './IdentityClient';
import { DiscoveryService } from '@backstage/backend-plugin-api';
diff --git a/plugins/auth-node/src/types.ts b/plugins/auth-node/src/types.ts
index e5a18bfafc..89a4eb2f0e 100644
--- a/plugins/auth-node/src/types.ts
+++ b/plugins/auth-node/src/types.ts
@@ -37,7 +37,7 @@ export interface BackstageSignInResult {
/**
* Identity information to pass to the client rather than using the
- * information that's embeeded in the token.
+ * information that's embedded in the token.
*/
identity?: BackstageUserIdentity;
}
diff --git a/plugins/auth-react/CHANGELOG.md b/plugins/auth-react/CHANGELOG.md
index 7342ed854b..9a08ae31a1 100644
--- a/plugins/auth-react/CHANGELOG.md
+++ b/plugins/auth-react/CHANGELOG.md
@@ -1,5 +1,14 @@
# @backstage/plugin-auth-react
+## 0.1.27
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/core-components@0.18.10
+ - @backstage/errors@1.3.1
+ - @backstage/core-plugin-api@1.12.6
+
## 0.1.27-next.0
### Patch Changes
diff --git a/plugins/auth-react/package.json b/plugins/auth-react/package.json
index 1e305923e0..15a94cebb3 100644
--- a/plugins/auth-react/package.json
+++ b/plugins/auth-react/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-react",
- "version": "0.1.27-next.0",
+ "version": "0.1.27",
"description": "Web library for the auth plugin",
"backstage": {
"role": "web-library",
diff --git a/plugins/auth/CHANGELOG.md b/plugins/auth/CHANGELOG.md
index e0a2904609..298579bfbb 100644
--- a/plugins/auth/CHANGELOG.md
+++ b/plugins/auth/CHANGELOG.md
@@ -1,5 +1,33 @@
# @backstage/plugin-auth
+## 0.1.8
+
+### Patch Changes
+
+- f635139: Limited `@remixicon/react` dependency to versions below 4.9.0 due to a license change in that release.
+- 4f62755: Improved the OAuth consent dialog for MCP authorization by showing more client details, including the client metadata host for CIMD clients, the metadata URL, callback URL, and requested scopes.
+- Updated dependencies
+ - @backstage/ui@0.15.0
+ - @backstage/errors@1.3.1
+ - @backstage/frontend-plugin-api@0.17.0
+
+## 0.1.8-next.2
+
+### Patch Changes
+
+- 4f62755: Improved the OAuth consent dialog for MCP authorization by showing more client details, including the client metadata host for CIMD clients, the metadata URL, callback URL, and requested scopes.
+- Updated dependencies
+ - @backstage/ui@0.15.0-next.3
+
+## 0.1.8-next.1
+
+### Patch Changes
+
+- f635139: Limited `@remixicon/react` dependency to versions below 4.9.0 due to a license change in that release.
+- Updated dependencies
+ - @backstage/ui@0.15.0-next.1
+ - @backstage/frontend-plugin-api@0.17.0-next.1
+
## 0.1.8-next.0
### Patch Changes
diff --git a/plugins/auth/package.json b/plugins/auth/package.json
index 5ea70b4503..5a6ba70af4 100644
--- a/plugins/auth/package.json
+++ b/plugins/auth/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth",
- "version": "0.1.8-next.0",
+ "version": "0.1.8",
"backstage": {
"role": "frontend-plugin",
"pluginId": "auth",
@@ -51,7 +51,7 @@
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/theme": "workspace:^",
"@backstage/ui": "workspace:^",
- "@remixicon/react": "^4.6.0",
+ "@remixicon/react": ">=4.6.0 <4.9.0",
"react-use": "^17.2.4"
},
"devDependencies": {
diff --git a/plugins/auth/src/components/ConsentPage/ConsentPage.module.css b/plugins/auth/src/components/ConsentPage/ConsentPage.module.css
index 049c512c54..99ebb53d0a 100644
--- a/plugins/auth/src/components/ConsentPage/ConsentPage.module.css
+++ b/plugins/auth/src/components/ConsentPage/ConsentPage.module.css
@@ -19,14 +19,35 @@
margin: 0;
}
-.callbackUrl {
- font-family: var(--bui-font-monospace);
+.clientDetails {
+ display: flex;
+ flex-direction: column;
+ gap: var(--bui-space-2);
+ margin: var(--bui-space-3) 0 0;
+}
+
+.clientDetail {
+ display: flex;
+ flex-direction: column;
+ gap: var(--bui-space-1);
+}
+
+.clientDetail dt {
+ font-size: var(--bui-font-size-2);
+ font-weight: 600;
+}
+
+.clientDetail dd {
+ margin: 0;
background: var(--bui-bg-neutral-2);
padding: var(--bui-space-2);
border-radius: var(--bui-radius-2);
word-break: break-all;
font-size: var(--bui-font-size-3);
- margin-top: var(--bui-space-2);
+}
+
+.monospaceValue {
+ font-family: var(--bui-font-monospace);
}
.completedIcon {
diff --git a/plugins/auth/src/components/ConsentPage/ConsentPage.test.tsx b/plugins/auth/src/components/ConsentPage/ConsentPage.test.tsx
new file mode 100644
index 0000000000..22710aafdb
--- /dev/null
+++ b/plugins/auth/src/components/ConsentPage/ConsentPage.test.tsx
@@ -0,0 +1,124 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { configApiRef } from '@backstage/frontend-plugin-api';
+import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+import { screen } from '@testing-library/react';
+import { Route, Routes } from 'react-router-dom';
+import { ConsentPage } from './ConsentPage';
+import { useConsentSession } from './useConsentSession';
+
+jest.mock('./useConsentSession');
+
+const mockUseConsentSession = useConsentSession as jest.MockedFunction<
+ typeof useConsentSession
+>;
+
+function mockLoadedSession(
+ session: Partial<
+ Extract<
+ ReturnType['state'],
+ { status: 'loaded' }
+ >['session']
+ >,
+) {
+ mockUseConsentSession.mockReturnValue({
+ state: {
+ status: 'loaded',
+ session: {
+ id: 'session-id',
+ clientId: 'client-id',
+ redirectUri: 'http://127.0.0.1:8055/callback',
+ ...session,
+ },
+ },
+ handleAction: async () => {},
+ });
+}
+
+async function renderConsentPage() {
+ await renderInTestApp(
+ 'Backstage Example App',
+ },
+ ],
+ ]}
+ >
+
+ } />
+
+ ,
+ {
+ routeEntries: ['/authorize/session-id'],
+ },
+ );
+}
+
+describe('ConsentPage', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('shows CIMD client metadata details', async () => {
+ mockLoadedSession({
+ clientId: 'https://claude.ai/.well-known/oauth-client/cli.json',
+ clientName: 'Claude Code',
+ redirectUri: 'http://127.0.0.1:54136/callback',
+ scope: 'openid offline_access',
+ });
+
+ await renderConsentPage();
+
+ expect(await screen.findByText('Claude Code')).toBeInTheDocument();
+ expect(screen.getByText('Client metadata host')).toBeInTheDocument();
+ expect(screen.getByText('claude.ai')).toBeInTheDocument();
+ expect(screen.getByText('Client metadata URL')).toBeInTheDocument();
+ expect(
+ screen.getByText('https://claude.ai/.well-known/oauth-client/cli.json'),
+ ).toBeInTheDocument();
+ expect(screen.getByText('Callback URL')).toBeInTheDocument();
+ expect(
+ screen.getByText('http://127.0.0.1:54136/callback'),
+ ).toBeInTheDocument();
+ expect(screen.getByText('Requested scopes')).toBeInTheDocument();
+ expect(screen.getByText('openid offline_access')).toBeInTheDocument();
+ });
+
+ it('does not show metadata host details for non-URL client IDs', async () => {
+ mockLoadedSession({
+ clientId: 'dcr-client-id',
+ clientName: 'Registered Client',
+ redirectUri: 'cursor://callback',
+ });
+
+ await renderConsentPage();
+
+ expect(await screen.findByText('Registered Client')).toBeInTheDocument();
+ expect(screen.queryByText('Client metadata host')).not.toBeInTheDocument();
+ expect(screen.queryByText('Client metadata URL')).not.toBeInTheDocument();
+ expect(screen.getByText('Callback URL')).toBeInTheDocument();
+ expect(screen.getByText('cursor://callback')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Make sure you trust this application and recognize the callback URL above. Only authorize applications you trust.',
+ ),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/plugins/auth/src/components/ConsentPage/ConsentPage.tsx b/plugins/auth/src/components/ConsentPage/ConsentPage.tsx
index 59e7b512a5..d24dc4e3b0 100644
--- a/plugins/auth/src/components/ConsentPage/ConsentPage.tsx
+++ b/plugins/auth/src/components/ConsentPage/ConsentPage.tsx
@@ -46,6 +46,14 @@ const ConsentPageLayout = ({ children }: { children: React.ReactNode }) => (
);
+function getUrlHostname(value: string): string | undefined {
+ try {
+ return new URL(value).host;
+ } catch {
+ return undefined;
+ }
+}
+
export const ConsentPage = () => {
const { sessionId } = useParams<{ sessionId: string }>();
const { state, handleAction } = useConsentSession({ sessionId });
@@ -131,6 +139,10 @@ export const ConsentPage = () => {
const session = state.session;
const isSubmitting = state.status === 'submitting';
const appName = session.clientName ?? session.clientId;
+ const metadataHost = getUrlHostname(session.clientId);
+ const trustMessage = metadataHost
+ ? 'Make sure you trust this application and recognize the client metadata host and callback URL above. Only authorize applications you trust.'
+ : 'Make sure you trust this application and recognize the callback URL above. Only authorize applications you trust.';
return (
@@ -160,16 +172,42 @@ export const ConsentPage = () => {
By authorizing this application, you are granting it access to
your {appTitle} account. The application will receive an
access token that allows it to act on your behalf.
-
- {session.redirectUri}
-
+
+ {metadataHost && (
+ <>
+
+
- Client metadata host
+ - {metadataHost}
+
+
+
- Client metadata URL
+ -
+ {session.clientId}
+
+
+ >
+ )}
+
+
- Callback URL
+ -
+ {session.redirectUri}
+
+
+ {session.scope && (
+
+
- Requested scopes
+ -
+ {session.scope}
+
+
+ )}
+
>
}
/>
- Make sure you trust this application and recognize the callback
- URL above. Only authorize applications you trust.
+ {trustMessage}
diff --git a/plugins/auth/src/components/ConsentPage/useConsentSession.ts b/plugins/auth/src/components/ConsentPage/useConsentSession.ts
index d416c66a0b..6d20fda510 100644
--- a/plugins/auth/src/components/ConsentPage/useConsentSession.ts
+++ b/plugins/auth/src/components/ConsentPage/useConsentSession.ts
@@ -30,13 +30,7 @@ interface Session {
clientName?: string;
clientId: string;
redirectUri: string;
- scopes?: string[];
- responseType?: string;
- state?: string;
- nonce?: string;
- codeChallenge?: string;
- codeChallengeMethod?: string;
- expiresAt?: string;
+ scope?: string;
}
type ConsentState =
diff --git a/plugins/bitbucket-cloud-common/CHANGELOG.md b/plugins/bitbucket-cloud-common/CHANGELOG.md
index f83c4dc2b6..58e1efd50d 100644
--- a/plugins/bitbucket-cloud-common/CHANGELOG.md
+++ b/plugins/bitbucket-cloud-common/CHANGELOG.md
@@ -1,5 +1,12 @@
# @backstage/plugin-bitbucket-cloud-common
+## 0.3.10
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/integration@2.0.2
+
## 0.3.10-next.0
### Patch Changes
diff --git a/plugins/bitbucket-cloud-common/package.json b/plugins/bitbucket-cloud-common/package.json
index 224a42ce37..9b3eb6dea4 100644
--- a/plugins/bitbucket-cloud-common/package.json
+++ b/plugins/bitbucket-cloud-common/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-bitbucket-cloud-common",
- "version": "0.3.10-next.0",
+ "version": "0.3.10",
"description": "Common functionalities for bitbucket-cloud plugins",
"backstage": {
"role": "common-library",
diff --git a/plugins/catalog-backend-module-ai-model/.eslintrc.js b/plugins/catalog-backend-module-ai-model/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/catalog-backend-module-ai-model/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/catalog-backend-module-ai-model/CHANGELOG.md b/plugins/catalog-backend-module-ai-model/CHANGELOG.md
new file mode 100644
index 0000000000..57c33288e8
--- /dev/null
+++ b/plugins/catalog-backend-module-ai-model/CHANGELOG.md
@@ -0,0 +1,14 @@
+# @backstage/plugin-catalog-backend-module-ai-model
+
+## 0.1.0
+
+### Minor Changes
+
+- 3664148: Introduced the `AiResource` catalog entity kind. Entity types, validators, type guards, and the model layer are exported from `@backstage/catalog-model/alpha`. Install `@backstage/plugin-catalog-backend-module-ai-model` in your backend to register the kind with the catalog.
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
diff --git a/plugins/catalog-backend-module-ai-model/README.md b/plugins/catalog-backend-module-ai-model/README.md
new file mode 100644
index 0000000000..7b85733ffe
--- /dev/null
+++ b/plugins/catalog-backend-module-ai-model/README.md
@@ -0,0 +1,45 @@
+# @backstage/plugin-catalog-backend-module-ai-model
+
+Adds support for the `AiResource` entity kind to the catalog backend plugin. AI resources represent contextual information consumed by AI coding tools, such as skills and rules.
+
+## Installation
+
+Add the module to your backend:
+
+```ts
+backend.add(import('@backstage/plugin-catalog-backend-module-ai-model'));
+```
+
+## Entity shape
+
+```yaml
+apiVersion: backstage.io/v1alpha1
+kind: AiResource
+metadata:
+ name: frontend-design
+ description: Skill for creating production-grade frontend interfaces
+spec:
+ type: skill
+ lifecycle: production
+ owner: ai-platform-team
+ system: ai-tooling
+ disciplines:
+ - web
+ categories:
+ - framework
+ agents:
+ - claude-code
+ dependsOn:
+ - airesource:default/base-coding-standards
+```
+
+The `type` field determines which spec fields are available. Currently supported types:
+
+- **`skill`** â reusable contextual knowledge for AI coding tools. Supports additional fields: `disciplines`, `categories`, `agents`, `dependsOn`.
+- **`rule`** â governance rules and constraints for AI coding tools. Supports additional fields: `disciplines`, `category` (required), `rationale` (required).
+
+Any other `type` value is accepted with the base spec fields: `type`, `lifecycle`, `owner`, and optionally `system`.
+
+## Accessing skill and rule content
+
+The actual content of skills and rules is not stored in the entity spec. Instead, the source file is referenced via the standard `backstage.io/source-location` annotation, consistent with how other Backstage entities reference their source files. Entity providers that generate `AiResource` entities from `SKILL.md` or rule files should set this annotation to point to the source file.
diff --git a/plugins/catalog-backend-module-ai-model/catalog-info.yaml b/plugins/catalog-backend-module-ai-model/catalog-info.yaml
new file mode 100644
index 0000000000..a9b022ecb5
--- /dev/null
+++ b/plugins/catalog-backend-module-ai-model/catalog-info.yaml
@@ -0,0 +1,10 @@
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: backstage-plugin-catalog-backend-module-ai-model
+ title: '@backstage/plugin-catalog-backend-module-ai-model'
+ description: Adds support for the AIResource entity kind to the catalog backend plugin.
+spec:
+ lifecycle: experimental
+ type: backstage-backend-plugin-module
+ owner: maintainers
diff --git a/plugins/catalog-backend-module-ai-model/package.json b/plugins/catalog-backend-module-ai-model/package.json
new file mode 100644
index 0000000000..ca6159fba6
--- /dev/null
+++ b/plugins/catalog-backend-module-ai-model/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "@backstage/plugin-catalog-backend-module-ai-model",
+ "version": "0.1.0",
+ "description": "Adds support for the AiResource entity kind to the catalog backend plugin.",
+ "backstage": {
+ "role": "backend-plugin-module",
+ "pluginId": "catalog",
+ "pluginPackage": "@backstage/plugin-catalog-backend"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/backstage/backstage",
+ "directory": "plugins/catalog-backend-module-ai-model"
+ },
+ "license": "Apache-2.0",
+ "exports": {
+ ".": "./src/index.ts",
+ "./package.json": "./package.json"
+ },
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "typesVersions": {
+ "*": {
+ "package.json": [
+ "package.json"
+ ]
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "backstage-cli package build",
+ "clean": "backstage-cli package clean",
+ "lint": "backstage-cli package lint",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack",
+ "start": "backstage-cli package start",
+ "test": "backstage-cli package test"
+ },
+ "dependencies": {
+ "@backstage/backend-plugin-api": "workspace:^",
+ "@backstage/catalog-model": "workspace:^",
+ "@backstage/plugin-catalog-node": "workspace:^"
+ },
+ "devDependencies": {
+ "@backstage/backend-test-utils": "workspace:^",
+ "@backstage/cli": "workspace:^"
+ }
+}
diff --git a/plugins/catalog-backend-module-ai-model/report.api.md b/plugins/catalog-backend-module-ai-model/report.api.md
new file mode 100644
index 0000000000..b1249ee146
--- /dev/null
+++ b/plugins/catalog-backend-module-ai-model/report.api.md
@@ -0,0 +1,11 @@
+## API Report File for "@backstage/plugin-catalog-backend-module-ai-model"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+import { BackendFeature } from '@backstage/backend-plugin-api';
+
+// @public
+const catalogModuleAiResourceEntityModel: BackendFeature;
+export default catalogModuleAiResourceEntityModel;
+```
diff --git a/plugins/catalog/src/alpha/navItems.tsx b/plugins/catalog-backend-module-ai-model/src/index.ts
similarity index 60%
rename from plugins/catalog/src/alpha/navItems.tsx
rename to plugins/catalog-backend-module-ai-model/src/index.ts
index e72c20ebf4..fedbd274eb 100644
--- a/plugins/catalog/src/alpha/navItems.tsx
+++ b/plugins/catalog-backend-module-ai-model/src/index.ts
@@ -1,5 +1,5 @@
/*
- * Copyright 2023 The Backstage Authors
+ * Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,16 +14,10 @@
* limitations under the License.
*/
-import HomeIcon from '@material-ui/icons/Home';
-import { NavItemBlueprint } from '@backstage/frontend-plugin-api';
-import { rootRouteRef } from '../routes';
+/**
+ * Adds support for the AiResource entity kind to the catalog backend plugin.
+ *
+ * @packageDocumentation
+ */
-export const catalogNavItem = NavItemBlueprint.make({
- params: {
- routeRef: rootRouteRef,
- title: 'Catalog',
- icon: HomeIcon,
- },
-});
-
-export default [catalogNavItem];
+export { catalogModuleAiResourceEntityModel as default } from './module';
diff --git a/plugins/catalog-backend-module-ai-model/src/module.test.ts b/plugins/catalog-backend-module-ai-model/src/module.test.ts
new file mode 100644
index 0000000000..89fba1fdc3
--- /dev/null
+++ b/plugins/catalog-backend-module-ai-model/src/module.test.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { startTestBackend } from '@backstage/backend-test-utils';
+import { catalogModelExtensionPoint } from '@backstage/plugin-catalog-node/alpha';
+import { catalogModuleAiResourceEntityModel } from './module';
+
+describe('catalogModuleAiResourceEntityModel', () => {
+ it('should register the model source', async () => {
+ const extensionPoint = {
+ setFieldValidators: jest.fn(),
+ setEntityDataParser: jest.fn(),
+ addModelSource: jest.fn(),
+ };
+
+ await startTestBackend({
+ extensionPoints: [[catalogModelExtensionPoint, extensionPoint]],
+ features: [catalogModuleAiResourceEntityModel],
+ });
+
+ expect(extensionPoint.addModelSource).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/plugins/catalog-backend-module-ai-model/src/module.ts b/plugins/catalog-backend-module-ai-model/src/module.ts
new file mode 100644
index 0000000000..0a69a8a6de
--- /dev/null
+++ b/plugins/catalog-backend-module-ai-model/src/module.ts
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createBackendModule } from '@backstage/backend-plugin-api';
+import {
+ CatalogModelSources,
+ aiResourceEntityModel,
+ mcpServerApiEntityModel,
+} from '@backstage/catalog-model/alpha';
+import { catalogModelExtensionPoint } from '@backstage/plugin-catalog-node/alpha';
+
+/**
+ * Registers support for the AiResource entity kind in the catalog.
+ *
+ * @public
+ */
+export const catalogModuleAiResourceEntityModel = createBackendModule({
+ pluginId: 'catalog',
+ moduleId: 'ai-model',
+ register(reg) {
+ reg.registerInit({
+ deps: {
+ model: catalogModelExtensionPoint,
+ },
+ async init({ model }) {
+ model.addModelSource(
+ CatalogModelSources.static([
+ aiResourceEntityModel,
+ mcpServerApiEntityModel,
+ ]),
+ );
+ },
+ });
+ },
+});
diff --git a/plugins/catalog-backend-module-aws/CHANGELOG.md b/plugins/catalog-backend-module-aws/CHANGELOG.md
index 94643dd788..138c5aac31 100644
--- a/plugins/catalog-backend-module-aws/CHANGELOG.md
+++ b/plugins/catalog-backend-module-aws/CHANGELOG.md
@@ -1,5 +1,42 @@
# @backstage/plugin-catalog-backend-module-aws
+## 0.4.23
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/errors@1.3.1
+ - @backstage/integration-aws-node@0.2.0
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/backend-defaults@0.17.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/plugin-kubernetes-common@0.9.12
+ - @backstage/integration@2.0.2
+ - @backstage/config@1.3.8
+ - @backstage/plugin-catalog-common@1.1.10
+
+## 0.4.23-next.2
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/integration-aws-node@0.2.0-next.1
+ - @backstage/backend-plugin-api@1.9.1-next.1
+ - @backstage/backend-defaults@0.17.1-next.2
+
+## 0.4.23-next.1
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.8.1-next.1
+ - @backstage/plugin-catalog-node@2.2.1-next.1
+ - @backstage/plugin-kubernetes-common@0.9.12-next.1
+ - @backstage/backend-defaults@0.17.1-next.1
+
## 0.4.23-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-aws/package.json b/plugins/catalog-backend-module-aws/package.json
index a9482910fc..02ec589cf7 100644
--- a/plugins/catalog-backend-module-aws/package.json
+++ b/plugins/catalog-backend-module-aws/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-aws",
- "version": "0.4.23-next.0",
+ "version": "0.4.23",
"description": "A Backstage catalog backend module that helps integrate towards AWS",
"backstage": {
"role": "backend-plugin-module",
@@ -66,8 +66,7 @@
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"@backstage/plugin-kubernetes-common": "workspace:^",
- "p-limit": "^3.0.2",
- "uuid": "^11.0.0"
+ "p-limit": "^3.0.2"
},
"devDependencies": {
"@aws-sdk/util-stream-node": "^3.350.0",
diff --git a/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts
index fa73188213..a522a7904a 100644
--- a/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts
+++ b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts
@@ -29,7 +29,7 @@ import {
ListObjectsV2Output,
S3,
} from '@aws-sdk/client-s3';
-import * as uuid from 'uuid';
+import { randomUUID } from 'node:crypto';
import { getEndpointFromInstructions } from '@aws-sdk/middleware-endpoint';
import {
AwsCredentialsManager,
@@ -137,7 +137,7 @@ export class AwsS3EntityProvider implements EntityProvider {
const logger = this.logger.child({
class: AwsS3EntityProvider.prototype.constructor.name,
taskId,
- taskInstanceId: uuid.v4(),
+ taskInstanceId: randomUUID(),
});
try {
diff --git a/plugins/catalog-backend-module-azure/CHANGELOG.md b/plugins/catalog-backend-module-azure/CHANGELOG.md
index 8e853db168..0e463b6e0f 100644
--- a/plugins/catalog-backend-module-azure/CHANGELOG.md
+++ b/plugins/catalog-backend-module-azure/CHANGELOG.md
@@ -1,5 +1,27 @@
# @backstage/plugin-catalog-backend-module-azure
+## 0.3.17
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/integration@2.0.2
+ - @backstage/config@1.3.8
+ - @backstage/plugin-catalog-common@1.1.10
+ - @backstage/plugin-events-node@0.4.22
+
+## 0.3.17-next.1
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/plugin-catalog-node@2.2.1-next.1
+
## 0.3.17-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-azure/package.json b/plugins/catalog-backend-module-azure/package.json
index 544ec7e677..6b84a1ee9a 100644
--- a/plugins/catalog-backend-module-azure/package.json
+++ b/plugins/catalog-backend-module-azure/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-azure",
- "version": "0.3.17-next.0",
+ "version": "0.3.17",
"description": "A Backstage catalog backend module that helps integrate towards Azure",
"backstage": {
"role": "backend-plugin-module",
@@ -59,8 +59,7 @@
"@backstage/integration": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
- "@backstage/plugin-events-node": "workspace:^",
- "uuid": "^11.0.0"
+ "@backstage/plugin-events-node": "workspace:^"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
diff --git a/plugins/catalog-backend-module-azure/src/providers/AzureBlobStorageEntityProvider.ts b/plugins/catalog-backend-module-azure/src/providers/AzureBlobStorageEntityProvider.ts
index fdb05b0781..c9e5489326 100644
--- a/plugins/catalog-backend-module-azure/src/providers/AzureBlobStorageEntityProvider.ts
+++ b/plugins/catalog-backend-module-azure/src/providers/AzureBlobStorageEntityProvider.ts
@@ -32,7 +32,7 @@ import {
locationSpecToLocationEntity,
} from '@backstage/plugin-catalog-node';
import { LocationSpec } from '@backstage/plugin-catalog-common';
-import * as uuid from 'uuid';
+import { randomUUID } from 'node:crypto';
import { readAzureBlobStorageConfigs } from './config';
import {
AzureBlobStorageIntergation,
@@ -134,7 +134,7 @@ export class AzureBlobStorageEntityProvider implements EntityProvider {
const logger = this.logger.child({
class: AzureBlobStorageEntityProvider.prototype.constructor.name,
taskId,
- taskInstanceId: uuid.v4(),
+ taskInstanceId: randomUUID(),
});
try {
diff --git a/plugins/catalog-backend-module-azure/src/providers/AzureDevOpsEntityProvider.ts b/plugins/catalog-backend-module-azure/src/providers/AzureDevOpsEntityProvider.ts
index 5869d0e2ba..3e626f282f 100644
--- a/plugins/catalog-backend-module-azure/src/providers/AzureDevOpsEntityProvider.ts
+++ b/plugins/catalog-backend-module-azure/src/providers/AzureDevOpsEntityProvider.ts
@@ -29,7 +29,7 @@ import {
import { LocationSpec } from '@backstage/plugin-catalog-common';
import { readAzureDevOpsConfigs } from './config';
import { AzureDevOpsConfig } from './types';
-import * as uuid from 'uuid';
+import { randomUUID } from 'node:crypto';
import { codeSearch, CodeSearchResultItem } from '../lib';
import {
SchedulerService,
@@ -129,7 +129,7 @@ export class AzureDevOpsEntityProvider implements EntityProvider {
const logger = this.logger.child({
class: AzureDevOpsEntityProvider.prototype.constructor.name,
taskId,
- taskInstanceId: uuid.v4(),
+ taskInstanceId: randomUUID(),
});
try {
diff --git a/plugins/catalog-backend-module-backstage-openapi/CHANGELOG.md b/plugins/catalog-backend-module-backstage-openapi/CHANGELOG.md
index 87ce483ac7..9a5514db4e 100644
--- a/plugins/catalog-backend-module-backstage-openapi/CHANGELOG.md
+++ b/plugins/catalog-backend-module-backstage-openapi/CHANGELOG.md
@@ -1,5 +1,27 @@
# @backstage/plugin-catalog-backend-module-backstage-openapi
+## 0.5.14
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/backend-openapi-utils@0.6.9
+ - @backstage/config@1.3.8
+
+## 0.5.14-next.1
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.8.1-next.1
+ - @backstage/plugin-catalog-node@2.2.1-next.1
+
## 0.5.14-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-backstage-openapi/package.json b/plugins/catalog-backend-module-backstage-openapi/package.json
index c1927c5912..ed1ac4e33c 100644
--- a/plugins/catalog-backend-module-backstage-openapi/package.json
+++ b/plugins/catalog-backend-module-backstage-openapi/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-backstage-openapi",
- "version": "0.5.14-next.0",
+ "version": "0.5.14",
"backstage": {
"role": "backend-plugin-module",
"pluginId": "catalog",
@@ -42,7 +42,6 @@
"cross-fetch": "^4.0.0",
"lodash": "^4.17.21",
"openapi-merge": "^1.3.2",
- "uuid": "^11.0.0",
"yaml": "^2.7.0"
},
"devDependencies": {
diff --git a/plugins/catalog-backend-module-backstage-openapi/src/InternalOpenApiDocumentationProvider.ts b/plugins/catalog-backend-module-backstage-openapi/src/InternalOpenApiDocumentationProvider.ts
index d488831ec6..9c9316f686 100644
--- a/plugins/catalog-backend-module-backstage-openapi/src/InternalOpenApiDocumentationProvider.ts
+++ b/plugins/catalog-backend-module-backstage-openapi/src/InternalOpenApiDocumentationProvider.ts
@@ -40,7 +40,7 @@ import {
SchedulerService,
SchedulerServiceTaskRunner,
} from '@backstage/backend-plugin-api';
-import * as uuid from 'uuid';
+import { randomUUID } from 'node:crypto';
import lodash from 'lodash';
const HTTP_VERBS: (keyof PathItemObject)[] = [
@@ -238,7 +238,7 @@ export class InternalOpenApiDocumentationProvider implements EntityProvider {
class:
InternalOpenApiDocumentationProvider.prototype.constructor.name,
taskId,
- taskInstanceId: uuid.v4(),
+ taskInstanceId: randomUUID(),
});
await this.refresh(logger);
},
diff --git a/plugins/catalog-backend-module-bitbucket-cloud/CHANGELOG.md b/plugins/catalog-backend-module-bitbucket-cloud/CHANGELOG.md
index b7e0ca4a18..b52d8d1d21 100644
--- a/plugins/catalog-backend-module-bitbucket-cloud/CHANGELOG.md
+++ b/plugins/catalog-backend-module-bitbucket-cloud/CHANGELOG.md
@@ -1,5 +1,30 @@
# @backstage/plugin-catalog-backend-module-bitbucket-cloud
+## 0.5.11
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/integration@2.0.2
+ - @backstage/config@1.3.8
+ - @backstage/plugin-bitbucket-cloud-common@0.3.10
+ - @backstage/plugin-catalog-common@1.1.10
+ - @backstage/plugin-events-node@0.4.22
+
+## 0.5.11-next.1
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.8.1-next.1
+ - @backstage/plugin-catalog-node@2.2.1-next.1
+
## 0.5.11-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-bitbucket-cloud/package.json b/plugins/catalog-backend-module-bitbucket-cloud/package.json
index 63905bd97f..bf9c4f9b25 100644
--- a/plugins/catalog-backend-module-bitbucket-cloud/package.json
+++ b/plugins/catalog-backend-module-bitbucket-cloud/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-bitbucket-cloud",
- "version": "0.5.11-next.0",
+ "version": "0.5.11",
"description": "A Backstage catalog backend module that helps integrate towards Bitbucket Cloud",
"backstage": {
"role": "backend-plugin-module",
@@ -59,8 +59,7 @@
"@backstage/plugin-bitbucket-cloud-common": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
- "@backstage/plugin-events-node": "workspace:^",
- "uuid": "^11.0.0"
+ "@backstage/plugin-events-node": "workspace:^"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
diff --git a/plugins/catalog-backend-module-bitbucket-cloud/src/providers/BitbucketCloudEntityProvider.ts b/plugins/catalog-backend-module-bitbucket-cloud/src/providers/BitbucketCloudEntityProvider.ts
index 33327e1f33..7a059f8793 100644
--- a/plugins/catalog-backend-module-bitbucket-cloud/src/providers/BitbucketCloudEntityProvider.ts
+++ b/plugins/catalog-backend-module-bitbucket-cloud/src/providers/BitbucketCloudEntityProvider.ts
@@ -44,7 +44,7 @@ import {
BitbucketCloudEntityProviderConfig,
readProviderConfigs,
} from './BitbucketCloudEntityProviderConfig';
-import * as uuid from 'uuid';
+import { randomUUID } from 'node:crypto';
const DEFAULT_BRANCH = 'master';
const TOPIC_REPO_PUSH = 'bitbucketCloud.repo:push';
@@ -154,7 +154,7 @@ export class BitbucketCloudEntityProvider implements EntityProvider {
const logger = this.logger.child({
class: BitbucketCloudEntityProvider.prototype.constructor.name,
taskId,
- taskInstanceId: uuid.v4(),
+ taskInstanceId: randomUUID(),
});
try {
diff --git a/plugins/catalog-backend-module-bitbucket-server/CHANGELOG.md b/plugins/catalog-backend-module-bitbucket-server/CHANGELOG.md
index 2298caf104..45ddfb9900 100644
--- a/plugins/catalog-backend-module-bitbucket-server/CHANGELOG.md
+++ b/plugins/catalog-backend-module-bitbucket-server/CHANGELOG.md
@@ -1,5 +1,29 @@
# @backstage/plugin-catalog-backend-module-bitbucket-server
+## 0.5.11
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/integration@2.0.2
+ - @backstage/config@1.3.8
+ - @backstage/plugin-catalog-common@1.1.10
+ - @backstage/plugin-events-node@0.4.22
+
+## 0.5.11-next.1
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.8.1-next.1
+ - @backstage/plugin-catalog-node@2.2.1-next.1
+
## 0.5.11-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-bitbucket-server/package.json b/plugins/catalog-backend-module-bitbucket-server/package.json
index da623fd60d..e3cffdf993 100644
--- a/plugins/catalog-backend-module-bitbucket-server/package.json
+++ b/plugins/catalog-backend-module-bitbucket-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-bitbucket-server",
- "version": "0.5.11-next.0",
+ "version": "0.5.11",
"backstage": {
"role": "backend-plugin-module",
"pluginId": "catalog",
@@ -53,8 +53,7 @@
"@backstage/integration": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
- "@backstage/plugin-events-node": "workspace:^",
- "uuid": "^11.0.0"
+ "@backstage/plugin-events-node": "workspace:^"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
diff --git a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.ts b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.ts
index 4b02632892..809c2276f2 100644
--- a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.ts
+++ b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.ts
@@ -27,7 +27,7 @@ import {
DeferredEntity,
CatalogService,
} from '@backstage/plugin-catalog-node';
-import * as uuid from 'uuid';
+import { randomUUID } from 'node:crypto';
import { BitbucketServerClient, paginated } from '../lib';
import {
BitbucketServerEntityProviderConfig,
@@ -157,7 +157,7 @@ export class BitbucketServerEntityProvider implements EntityProvider {
const logger = this.logger.child({
class: BitbucketServerEntityProvider.prototype.constructor.name,
taskId,
- taskInstanceId: uuid.v4(),
+ taskInstanceId: randomUUID(),
});
try {
diff --git a/plugins/catalog-backend-module-gcp/CHANGELOG.md b/plugins/catalog-backend-module-gcp/CHANGELOG.md
index f5a816c59d..de82edf85a 100644
--- a/plugins/catalog-backend-module-gcp/CHANGELOG.md
+++ b/plugins/catalog-backend-module-gcp/CHANGELOG.md
@@ -1,5 +1,16 @@
# @backstage/plugin-catalog-backend-module-gcp
+## 0.3.19
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/plugin-kubernetes-common@0.9.12
+ - @backstage/config@1.3.8
+
## 0.3.19-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-gcp/package.json b/plugins/catalog-backend-module-gcp/package.json
index 0ce004cce4..1973c039e4 100644
--- a/plugins/catalog-backend-module-gcp/package.json
+++ b/plugins/catalog-backend-module-gcp/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-gcp",
- "version": "0.3.19-next.0",
+ "version": "0.3.19",
"description": "A Backstage catalog backend module that helps integrate towards GCP",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/catalog-backend-module-gerrit/CHANGELOG.md b/plugins/catalog-backend-module-gerrit/CHANGELOG.md
index 3b45e502ba..ec7c10376b 100644
--- a/plugins/catalog-backend-module-gerrit/CHANGELOG.md
+++ b/plugins/catalog-backend-module-gerrit/CHANGELOG.md
@@ -1,5 +1,26 @@
# @backstage/plugin-catalog-backend-module-gerrit
+## 0.3.14
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/integration@2.0.2
+ - @backstage/config@1.3.8
+ - @backstage/plugin-catalog-common@1.1.10
+
+## 0.3.14-next.1
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/plugin-catalog-node@2.2.1-next.1
+
## 0.3.14-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-gerrit/package.json b/plugins/catalog-backend-module-gerrit/package.json
index 87317eec00..bd8e897511 100644
--- a/plugins/catalog-backend-module-gerrit/package.json
+++ b/plugins/catalog-backend-module-gerrit/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-gerrit",
- "version": "0.3.14-next.0",
+ "version": "0.3.14",
"backstage": {
"role": "backend-plugin-module",
"pluginId": "catalog",
@@ -54,8 +54,7 @@
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"fs-extra": "^11.2.0",
- "p-limit": "^3.1.0",
- "uuid": "^11.0.0"
+ "p-limit": "^3.1.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
diff --git a/plugins/catalog-backend-module-gerrit/src/providers/GerritEntityProvider.ts b/plugins/catalog-backend-module-gerrit/src/providers/GerritEntityProvider.ts
index bd23c4b59c..8bfa6a9fe0 100644
--- a/plugins/catalog-backend-module-gerrit/src/providers/GerritEntityProvider.ts
+++ b/plugins/catalog-backend-module-gerrit/src/providers/GerritEntityProvider.ts
@@ -29,7 +29,7 @@ import {
parseGerritJsonResponse,
ScmIntegrations,
} from '@backstage/integration';
-import * as uuid from 'uuid';
+import { randomUUID } from 'node:crypto';
import pLimit from 'p-limit';
import { readGerritConfigs } from './config';
@@ -128,7 +128,7 @@ export class GerritEntityProvider implements EntityProvider {
const logger = this.logger.child({
class: GerritEntityProvider.prototype.constructor.name,
taskId,
- taskInstanceId: uuid.v4(),
+ taskInstanceId: randomUUID(),
});
try {
diff --git a/plugins/catalog-backend-module-gitea/CHANGELOG.md b/plugins/catalog-backend-module-gitea/CHANGELOG.md
index a5dbfbe95f..3212ec930b 100644
--- a/plugins/catalog-backend-module-gitea/CHANGELOG.md
+++ b/plugins/catalog-backend-module-gitea/CHANGELOG.md
@@ -1,5 +1,26 @@
# @backstage/plugin-catalog-backend-module-gitea
+## 0.1.12
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/integration@2.0.2
+ - @backstage/config@1.3.8
+ - @backstage/plugin-catalog-common@1.1.10
+
+## 0.1.12-next.1
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/plugin-catalog-node@2.2.1-next.1
+
## 0.1.12-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-gitea/package.json b/plugins/catalog-backend-module-gitea/package.json
index 7e6c01ce68..633ca391da 100644
--- a/plugins/catalog-backend-module-gitea/package.json
+++ b/plugins/catalog-backend-module-gitea/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-gitea",
- "version": "0.1.12-next.0",
+ "version": "0.1.12",
"description": "The gitea backend module for the catalog plugin.",
"backstage": {
"role": "backend-plugin-module",
@@ -39,8 +39,7 @@
"@backstage/integration": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
- "p-limit": "^3.0.2",
- "uuid": "^11.0.0"
+ "p-limit": "^3.0.2"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
diff --git a/plugins/catalog-backend-module-gitea/src/providers/GiteaEntityProvider.test.ts b/plugins/catalog-backend-module-gitea/src/providers/GiteaEntityProvider.test.ts
index 539d432793..fbc59a0b1c 100644
--- a/plugins/catalog-backend-module-gitea/src/providers/GiteaEntityProvider.test.ts
+++ b/plugins/catalog-backend-module-gitea/src/providers/GiteaEntityProvider.test.ts
@@ -17,14 +17,17 @@ import { mockServices } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { EntityProviderConnection } from '@backstage/plugin-catalog-node';
import { GiteaEntityProvider } from './GiteaEntityProvider';
-import * as uuid from 'uuid';
+import * as nodeCrypto from 'node:crypto';
import { readGiteaConfigs } from './config';
import { getGiteaApiUrl } from './core';
import { GiteaIntegration } from '@backstage/integration';
jest.mock('./config');
jest.mock('./core');
-jest.mock('uuid');
+jest.mock('node:crypto', () => ({
+ ...jest.requireActual('node:crypto'),
+ randomUUID: jest.fn(),
+}));
describe('GiteaEntityProvider', () => {
const logger = mockServices.logger.mock();
@@ -59,7 +62,7 @@ describe('GiteaEntityProvider', () => {
'https://gitea.example.com/api/v1/',
);
mockScheduler.createScheduledTaskRunner.mockReturnValue(mockTaskRunner);
- (uuid.v4 as jest.Mock).mockReturnValue('test-uuid');
+ (nodeCrypto.randomUUID as jest.Mock).mockReturnValue('test-uuid');
// Mock global fetch
global.fetch = jest.fn();
diff --git a/plugins/catalog-backend-module-gitea/src/providers/GiteaEntityProvider.ts b/plugins/catalog-backend-module-gitea/src/providers/GiteaEntityProvider.ts
index 2f9475af26..a1cf506f52 100644
--- a/plugins/catalog-backend-module-gitea/src/providers/GiteaEntityProvider.ts
+++ b/plugins/catalog-backend-module-gitea/src/providers/GiteaEntityProvider.ts
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import * as uuid from 'uuid';
+import { randomUUID } from 'node:crypto';
import { Config } from '@backstage/config';
import { InputError } from '@backstage/errors';
import {
@@ -141,7 +141,7 @@ export class GiteaEntityProvider implements EntityProvider {
const logger = this.logger.child({
class: GiteaEntityProvider.prototype.constructor.name,
taskId,
- taskInstanceId: uuid.v4(),
+ taskInstanceId: randomUUID(),
});
try {
diff --git a/plugins/catalog-backend-module-github-org/CHANGELOG.md b/plugins/catalog-backend-module-github-org/CHANGELOG.md
index fec36f53c9..724632ee08 100644
--- a/plugins/catalog-backend-module-github-org/CHANGELOG.md
+++ b/plugins/catalog-backend-module-github-org/CHANGELOG.md
@@ -1,5 +1,17 @@
# @backstage/plugin-catalog-backend-module-github-org
+## 0.3.22
+
+### Patch Changes
+
+- d745f1c: Added experimental support for checking suspended users via the GitHub REST API instead of the GraphQL `suspendedAt` field. Enable by setting both `excludeSuspendedUsers: true` and `experimental_checkForSuspendedUsersWithRest: true` in the provider config. When enabled, responses are cached using conditional HTTP requests to minimize REST API rate limit usage.
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-backend-module-github@0.13.2
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/config@1.3.8
+ - @backstage/plugin-events-node@0.4.22
+
## 0.3.22-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-github-org/package.json b/plugins/catalog-backend-module-github-org/package.json
index e6e9952566..914595014b 100644
--- a/plugins/catalog-backend-module-github-org/package.json
+++ b/plugins/catalog-backend-module-github-org/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-github-org",
- "version": "0.3.22-next.0",
+ "version": "0.3.22",
"description": "The github-org backend module for the catalog plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/catalog-backend-module-github-org/src/module.ts b/plugins/catalog-backend-module-github-org/src/module.ts
index 08a557e3b1..831cd6184a 100644
--- a/plugins/catalog-backend-module-github-org/src/module.ts
+++ b/plugins/catalog-backend-module-github-org/src/module.ts
@@ -94,13 +94,14 @@ export const catalogModuleGithubOrgEntityProvider = createBackendModule({
env.registerInit({
deps: {
catalog: catalogProcessingExtensionPoint,
+ cache: coreServices.cache,
config: coreServices.rootConfig,
events: eventsServiceRef,
logger: coreServices.logger,
scheduler: coreServices.scheduler,
},
- async init({ catalog, config, events, logger, scheduler }) {
+ async init({ catalog, cache, config, events, logger, scheduler }) {
const definitions = readDefinitionsFromConfig(config);
for (const definition of definitions) {
@@ -127,6 +128,9 @@ export const catalogModuleGithubOrgEntityProvider = createBackendModule({
definitions.length === 1 && definition.orgs?.length === 1,
pageSizes: definition.pageSizes,
excludeSuspendedUsers: definition.excludeSuspendedUsers,
+ cache,
+ experimental_checkForSuspendedUsersWithRest:
+ definition.experimental_checkForSuspendedUsersWithRest,
}),
);
}
@@ -146,6 +150,7 @@ function readDefinitionsFromConfig(rootConfig: Config): Array<{
organizationMembers?: number;
};
excludeSuspendedUsers?: boolean;
+ experimental_checkForSuspendedUsersWithRest?: boolean;
useVerifiedEmails?: boolean;
}> {
const baseKey = 'catalog.providers.githubOrg';
@@ -176,6 +181,9 @@ function readDefinitionsFromConfig(rootConfig: Config): Array<{
: undefined,
excludeSuspendedUsers:
c.getOptionalBoolean('excludeSuspendedUsers') ?? false,
+ experimental_checkForSuspendedUsersWithRest:
+ c.getOptionalBoolean('experimental_checkForSuspendedUsersWithRest') ??
+ false,
useVerifiedEmails:
c.getOptionalBoolean('defaultUserTransformer.useVerifiedEmails') ?? false,
}));
diff --git a/plugins/catalog-backend-module-github/CHANGELOG.md b/plugins/catalog-backend-module-github/CHANGELOG.md
index 743d9feeba..b468dad756 100644
--- a/plugins/catalog-backend-module-github/CHANGELOG.md
+++ b/plugins/catalog-backend-module-github/CHANGELOG.md
@@ -1,5 +1,31 @@
# @backstage/plugin-catalog-backend-module-github
+## 0.13.2
+
+### Patch Changes
+
+- d745f1c: Added experimental support for checking suspended users via the GitHub REST API instead of the GraphQL `suspendedAt` field. Enable by setting both `excludeSuspendedUsers: true` and `experimental_checkForSuspendedUsersWithRest: true` in the provider config. When enabled, responses are cached using conditional HTTP requests to minimize REST API rate limit usage.
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- aa313f0: The `GithubMultiOrgEntityProvider` now emits entities in a stable order during full mutations. Entities are sorted by entity ref, with the location annotation as a tiebreaker for entities that share the same ref. This prevents entity data from flickering between different GitHub orgs across refresh cycles when `alwaysUseDefaultNamespace` is enabled and teams with identical slugs exist in multiple orgs.
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/integration@2.0.2
+ - @backstage/config@1.3.8
+ - @backstage/plugin-catalog-common@1.1.10
+ - @backstage/plugin-events-node@0.4.22
+
+## 0.13.2-next.1
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.8.1-next.1
+ - @backstage/plugin-catalog-node@2.2.1-next.1
+
## 0.13.2-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-github/config.d.ts b/plugins/catalog-backend-module-github/config.d.ts
index 6a9d5cf808..78bb14d2be 100644
--- a/plugins/catalog-backend-module-github/config.d.ts
+++ b/plugins/catalog-backend-module-github/config.d.ts
@@ -270,6 +270,14 @@ export interface Config {
*/
excludeSuspendedUsers?: boolean;
+ /**
+ * (Optional) When set to true alongside `excludeSuspendedUsers`, use the GitHub REST API
+ * to check for suspended users instead of the GraphQL `suspendedAt` field.
+ * REST responses are cached using conditional HTTP requests to minimize rate limit usage.
+ * Default: `false`.
+ */
+ experimental_checkForSuspendedUsersWithRest?: boolean;
+
/**
* (Optional) Configuration for the default user transformer.
* These options only apply when using the built-in transformer;
@@ -345,6 +353,14 @@ export interface Config {
*/
excludeSuspendedUsers?: boolean;
+ /**
+ * (Optional) When set to true alongside `excludeSuspendedUsers`, use the GitHub REST API
+ * to check for suspended users instead of the GraphQL `suspendedAt` field.
+ * REST responses are cached using conditional HTTP requests to minimize rate limit usage.
+ * Default: `false`.
+ */
+ experimental_checkForSuspendedUsersWithRest?: boolean;
+
/**
* (Optional) Configuration for the default user transformer.
* These options only apply when using the built-in transformer;
diff --git a/plugins/catalog-backend-module-github/package.json b/plugins/catalog-backend-module-github/package.json
index a64968c981..4c7296737b 100644
--- a/plugins/catalog-backend-module-github/package.json
+++ b/plugins/catalog-backend-module-github/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-github",
- "version": "0.13.2-next.0",
+ "version": "0.13.2",
"description": "A Backstage catalog backend module that helps integrate towards GitHub",
"backstage": {
"role": "backend-plugin-module",
@@ -70,8 +70,7 @@
"git-url-parse": "^15.0.0",
"lodash": "^4.17.21",
"minimatch": "^10.2.1",
- "octokit": "^3.0.0",
- "uuid": "^11.0.0"
+ "octokit": "^3.0.0"
},
"devDependencies": {
"@backstage/backend-defaults": "workspace:^",
diff --git a/plugins/catalog-backend-module-github/report.api.md b/plugins/catalog-backend-module-github/report.api.md
index a2642bb1f4..3307b3a39e 100644
--- a/plugins/catalog-backend-module-github/report.api.md
+++ b/plugins/catalog-backend-module-github/report.api.md
@@ -6,6 +6,7 @@
import { AnalyzeOptions } from '@backstage/plugin-catalog-node';
import { AuthService } from '@backstage/backend-plugin-api';
import { BackendFeature } from '@backstage/backend-plugin-api';
+import { CacheService } from '@backstage/backend-plugin-api';
import { CatalogProcessor } from '@backstage/plugin-catalog-node';
import { CatalogProcessorEmit } from '@backstage/plugin-catalog-node';
import { CatalogService } from '@backstage/plugin-catalog-node';
@@ -158,6 +159,8 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
alwaysUseDefaultNamespace?: boolean;
pageSizes?: Partial;
excludeSuspendedUsers?: boolean;
+ cache?: CacheService;
+ experimental_checkForSuspendedUsersWithRest?: boolean;
});
connect(connection: EntityProviderConnection): Promise;
// (undocumented)
@@ -172,8 +175,10 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
// @public
export interface GithubMultiOrgEntityProviderOptions {
alwaysUseDefaultNamespace?: boolean;
+ cache?: CacheService;
events?: EventsService;
excludeSuspendedUsers?: boolean;
+ experimental_checkForSuspendedUsersWithRest?: boolean;
githubCredentialsProvider?: GithubCredentialsProvider;
githubUrl: string;
id: string;
@@ -237,6 +242,8 @@ export class GithubOrgEntityProvider implements EntityProvider {
teamTransformer?: TeamTransformer;
pageSizes?: Partial;
excludeSuspendedUsers?: boolean;
+ cache?: CacheService;
+ experimental_checkForSuspendedUsersWithRest?: boolean;
});
connect(connection: EntityProviderConnection): Promise;
// (undocumented)
@@ -253,8 +260,10 @@ export type GitHubOrgEntityProviderOptions = GithubOrgEntityProviderOptions;
// @public
export interface GithubOrgEntityProviderOptions {
+ cache?: CacheService;
events?: EventsService;
excludeSuspendedUsers?: boolean;
+ experimental_checkForSuspendedUsersWithRest?: boolean;
githubCredentialsProvider?: GithubCredentialsProvider;
id: string;
logger: LoggerService;
diff --git a/plugins/catalog-backend-module-github/src/lib/github.test.ts b/plugins/catalog-backend-module-github/src/lib/github.test.ts
index 2b9c35be85..d6d2aff7bb 100644
--- a/plugins/catalog-backend-module-github/src/lib/github.test.ts
+++ b/plugins/catalog-backend-module-github/src/lib/github.test.ts
@@ -20,7 +20,8 @@ import {
} from '@backstage/backend-test-utils';
import { GroupEntity, UserEntity } from '@backstage/catalog-model';
import { graphql as graphqlOctokit } from '@octokit/graphql';
-import { graphql as graphqlMsw, HttpResponse } from 'msw';
+import { CacheService } from '@backstage/backend-plugin-api';
+import { graphql as graphqlMsw, http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { TeamTransformer, UserTransformer } from './defaultTransformers';
import {
@@ -37,18 +38,18 @@ import {
createRemoveEntitiesOperation,
createReplaceEntitiesOperation,
createGraphqlClient,
+ createRestClient,
getOrganizationTeamsForUser,
+ isSuspended,
+ isGitHubEnterprise,
} from './github';
import { Octokit } from '@octokit/core';
import { throttling } from '@octokit/plugin-throttling';
import { retry } from '@octokit/plugin-retry';
-jest.mock('@octokit/core', () => ({
- ...jest.requireActual('@octokit/core'),
- Octokit: {
- plugin: jest.fn().mockReturnValue({ defaults: jest.fn() }),
- },
-}));
+// Note: We do NOT mock @octokit/core globally because createRestClient
+// needs a real Octokit.plugin. The createGraphqlClient tests use a local
+// jest.spyOn instead.
describe('github', () => {
const server = setupServer();
@@ -216,6 +217,200 @@ describe('github', () => {
getOrganizationUsers(graphql, 'a', 'token', undefined, undefined, true),
).resolves.toEqual(output);
});
+
+ it('reads members excluding suspended users via REST when restClient provided', async () => {
+ const input: QueryResponse = {
+ organization: {
+ membersWithRole: {
+ pageInfo: { hasNextPage: false },
+ nodes: [
+ {
+ login: 'suspended-user',
+ name: 'b',
+ bio: 'c',
+ email: 'd',
+ avatarUrl: 'e',
+ },
+ {
+ login: 'active-user',
+ name: 'b',
+ bio: 'c',
+ email: 'd',
+ avatarUrl: 'e',
+ },
+ ],
+ },
+ },
+ };
+
+ const output = {
+ users: [
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ name: 'active-user',
+ description: 'c',
+ }),
+ spec: {
+ profile: { displayName: 'b', email: 'd', picture: 'e' },
+ memberOf: [],
+ },
+ }),
+ ],
+ };
+
+ server.use(
+ graphqlMsw.query('users', () => HttpResponse.json({ data: input })),
+ );
+
+ const mockRestClient = {
+ request: jest.fn().mockImplementation((route: string, params: any) => {
+ if (route === 'GET /versions') {
+ return Promise.resolve({
+ headers: { 'x-github-enterprise-version': '3.12.0' },
+ });
+ }
+ if (route === 'GET /users/{username}') {
+ if (params.username === 'suspended-user') {
+ return { data: { suspended_at: '2025-01-01T00:00:00Z' } };
+ }
+ return { data: { suspended_at: null } };
+ }
+ return { data: { role: 'member', state: 'active' } };
+ }),
+ } as any;
+
+ await expect(
+ getOrganizationUsers(
+ graphql,
+ 'a',
+ 'token',
+ undefined,
+ undefined,
+ true,
+ mockRestClient,
+ ),
+ ).resolves.toEqual(output);
+ });
+
+ it('reads members excluding org-membership-suspended users via REST', async () => {
+ const input: QueryResponse = {
+ organization: {
+ membersWithRole: {
+ pageInfo: { hasNextPage: false },
+ nodes: [
+ {
+ login: 'org-suspended-user',
+ name: 'b',
+ bio: 'c',
+ email: 'd',
+ avatarUrl: 'e',
+ },
+ {
+ login: 'active-user',
+ name: 'b',
+ bio: 'c',
+ email: 'd',
+ avatarUrl: 'e',
+ },
+ ],
+ },
+ },
+ };
+
+ server.use(
+ graphqlMsw.query('users', () => HttpResponse.json({ data: input })),
+ );
+
+ const mockRestClient = {
+ request: jest.fn().mockImplementation((route: string, params: any) => {
+ if (route === 'GET /versions') {
+ return Promise.resolve({
+ headers: { 'x-github-enterprise-version': '3.12.0' },
+ });
+ }
+ if (route === 'GET /users/{username}') {
+ return { data: { suspended_at: null } };
+ }
+ if (route === 'GET /orgs/{org}/memberships/{username}') {
+ if (params.username === 'org-suspended-user') {
+ return { data: { role: 'suspended', state: 'active' } };
+ }
+ return { data: { role: 'member', state: 'active' } };
+ }
+ return { data: {} };
+ }),
+ } as any;
+
+ const result = await getOrganizationUsers(
+ graphql,
+ 'a',
+ 'token',
+ undefined,
+ undefined,
+ true,
+ mockRestClient,
+ );
+
+ expect(result.users).toHaveLength(1);
+ expect(result.users[0].metadata.name).toBe('active-user');
+ });
+
+ it('skips REST suspended user check on non-enterprise GitHub', async () => {
+ const input: QueryResponse = {
+ organization: {
+ membersWithRole: {
+ pageInfo: { hasNextPage: false },
+ nodes: [
+ {
+ login: 'suspended-user',
+ name: 'b',
+ bio: 'c',
+ email: 'd',
+ avatarUrl: 'e',
+ },
+ {
+ login: 'active-user',
+ name: 'b',
+ bio: 'c',
+ email: 'd',
+ avatarUrl: 'e',
+ },
+ ],
+ },
+ },
+ };
+
+ server.use(
+ graphqlMsw.query('users', () => HttpResponse.json({ data: input })),
+ );
+
+ const nonEnterpriseRestClient = {
+ request: jest.fn().mockImplementation((route: string) => {
+ if (route === 'GET /versions') {
+ return Promise.resolve({ headers: {} });
+ }
+ throw new Error('isSuspended should not be called');
+ }),
+ } as any;
+
+ const result = await getOrganizationUsers(
+ graphql,
+ 'a',
+ 'token',
+ undefined,
+ undefined,
+ true,
+ nonEnterpriseRestClient,
+ );
+
+ // Both users should be returned because the REST check is skipped
+ // on non-enterprise (no suspendedAt field in query either since restClient is provided)
+ expect(result.users).toHaveLength(2);
+ expect(nonEnterpriseRestClient.request).toHaveBeenCalledTimes(1);
+ expect(nonEnterpriseRestClient.request).toHaveBeenCalledWith(
+ 'GET /versions',
+ );
+ });
});
describe('getOrganizationUsers using custom UserTransformer', () => {
@@ -999,23 +1194,40 @@ describe('github', () => {
defaults: graphqlDefaults,
},
}));
- (Octokit.plugin as jest.Mock).mockReturnValue(mockedOctokit);
+
+ let pluginSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ pluginSpy = jest
+ .spyOn(Octokit, 'plugin')
+ .mockReturnValue(mockedOctokit as any);
+ });
+
+ afterEach(() => {
+ pluginSpy.mockRestore();
+ });
const rateLimitOptions = {
method: 'POST',
url: '/graphql',
};
- const client = createGraphqlClient({
- headers,
- baseUrl,
- logger,
- });
+
it('should return a graphql client with throttling and retry', async () => {
+ const client = createGraphqlClient({
+ headers,
+ baseUrl,
+ logger,
+ });
expect(client).toBeDefined();
expect(Octokit.plugin).toHaveBeenCalledWith(throttling, retry);
});
it('should return a graphql client with the correct options', async () => {
+ createGraphqlClient({
+ headers,
+ baseUrl,
+ logger,
+ });
expect(graphqlDefaults).toHaveBeenCalledWith({
baseUrl,
headers,
@@ -1028,6 +1240,8 @@ describe('github', () => {
{ retryCount: 1, expectedResult: true },
{ retryCount: 2, expectedResult: false },
])('should return %s', async ({ retryCount, expectedResult }) => {
+ createGraphqlClient({ headers, baseUrl, logger });
+
const throttleOptions = mockedOctokit.mock.calls[0][0].throttle;
const result = throttleOptions.onRateLimit(
@@ -1047,6 +1261,8 @@ describe('github', () => {
{ retryCount: 1, expectedResult: true },
{ retryCount: 2, expectedResult: false },
])('should return %s', async ({ retryCount, expectedResult }) => {
+ createGraphqlClient({ headers, baseUrl, logger });
+
const throttleOptions = mockedOctokit.mock.calls[0][0].throttle;
const result = throttleOptions.onSecondaryRateLimit(
@@ -1061,6 +1277,407 @@ describe('github', () => {
});
});
+ describe('createRestClient', () => {
+ const baseUrl = 'https://api.github.com';
+ const orgUrl = 'https://github.com/my-org';
+
+ const mockCredentialsProvider = {
+ getCredentials: jest.fn().mockResolvedValue({
+ type: 'token' as const,
+ headers: { authorization: 'token test-token' },
+ }),
+ };
+
+ describe('conditional request caching', () => {
+ function createMockCache(): CacheService & {
+ store: Map;
+ } {
+ const store = new Map();
+ const cache: CacheService & { store: Map } = {
+ store,
+ async get(key: string) {
+ return store.get(key) as any;
+ },
+ async set(key: string, value: unknown) {
+ store.set(key, value);
+ },
+ async delete(key: string) {
+ store.delete(key);
+ },
+ withOptions() {
+ return cache;
+ },
+ };
+ return cache;
+ }
+
+ it('caches responses using last-modified header', async () => {
+ let requestCount = 0;
+ server.use(
+ http.get(`${baseUrl}/users/testuser`, () => {
+ requestCount++;
+ return HttpResponse.json(
+ { login: 'testuser', suspended_at: null },
+ { headers: { 'Last-Modified': 'Thu, 01 Jan 2025 00:00:00 GMT' } },
+ );
+ }),
+ );
+
+ const cache = createMockCache();
+ const octokit = createRestClient({
+ baseUrl,
+ orgUrl,
+ credentialsProvider: mockCredentialsProvider,
+ logger: mockServices.logger.mock(),
+ cache,
+ });
+
+ await octokit.request('GET /users/{username}', {
+ username: 'testuser',
+ });
+
+ expect(requestCount).toBe(1);
+ const cached = cache.store.get(
+ `catalog-backend-module-github:GET:${baseUrl}/users/testuser`,
+ ) as any;
+ expect(cached.lastModified).toBe('Thu, 01 Jan 2025 00:00:00 GMT');
+ expect(cached.data).toEqual({ login: 'testuser', suspended_at: null });
+ });
+
+ it('sends if-modified-since on subsequent requests', async () => {
+ let receivedHeaders: Record = {};
+ server.use(
+ http.get(`${baseUrl}/users/testuser`, ({ request }) => {
+ receivedHeaders = Object.fromEntries(request.headers.entries());
+ return HttpResponse.json(
+ { login: 'testuser', suspended_at: null },
+ { headers: { 'Last-Modified': 'Thu, 01 Jan 2025 00:00:00 GMT' } },
+ );
+ }),
+ );
+
+ const cache = createMockCache();
+ cache.store.set(
+ `catalog-backend-module-github:GET:${baseUrl}/users/testuser`,
+ {
+ lastModified: 'Wed, 01 Jan 2025 00:00:00 GMT',
+ data: { login: 'testuser', suspended_at: null },
+ },
+ );
+
+ const octokit = createRestClient({
+ baseUrl,
+ orgUrl,
+ credentialsProvider: mockCredentialsProvider,
+ logger: mockServices.logger.mock(),
+ cache,
+ });
+
+ await octokit.request('GET /users/{username}', {
+ username: 'testuser',
+ });
+
+ expect(receivedHeaders['if-modified-since']).toBe(
+ 'Wed, 01 Jan 2025 00:00:00 GMT',
+ );
+ });
+
+ it('sends if-none-match when only etag is cached', async () => {
+ let receivedHeaders: Record = {};
+ server.use(
+ http.get(`${baseUrl}/users/testuser`, ({ request }) => {
+ receivedHeaders = Object.fromEntries(request.headers.entries());
+ return HttpResponse.json(
+ { login: 'testuser', suspended_at: null },
+ { headers: { ETag: '"new-etag"' } },
+ );
+ }),
+ );
+
+ const cache = createMockCache();
+ cache.store.set(
+ `catalog-backend-module-github:GET:${baseUrl}/users/testuser`,
+ {
+ etag: '"old-etag"',
+ data: { login: 'testuser', suspended_at: null },
+ },
+ );
+
+ const octokit = createRestClient({
+ baseUrl,
+ orgUrl,
+ credentialsProvider: mockCredentialsProvider,
+ logger: mockServices.logger.mock(),
+ cache,
+ });
+
+ await octokit.request('GET /users/{username}', {
+ username: 'testuser',
+ });
+
+ expect(receivedHeaders['if-none-match']).toBe('"old-etag"');
+ expect(receivedHeaders['if-modified-since']).toBeUndefined();
+ });
+
+ it('returns cached data and headers on 304 response', async () => {
+ const cachedData = { login: 'testuser', suspended_at: null };
+ const cachedHeaders = {
+ 'x-github-enterprise-version': '3.12.0',
+ 'last-modified': 'Thu, 01 Jan 2025 00:00:00 GMT',
+ };
+
+ server.use(
+ http.get(`${baseUrl}/users/testuser`, () => {
+ return new HttpResponse(null, { status: 304 });
+ }),
+ );
+
+ const cache = createMockCache();
+ cache.store.set(
+ `catalog-backend-module-github:GET:${baseUrl}/users/testuser`,
+ {
+ lastModified: 'Thu, 01 Jan 2025 00:00:00 GMT',
+ headers: cachedHeaders,
+ data: cachedData,
+ },
+ );
+
+ const octokit = createRestClient({
+ baseUrl,
+ orgUrl,
+ credentialsProvider: mockCredentialsProvider,
+ logger: mockServices.logger.mock(),
+ cache,
+ });
+
+ const response = await octokit.request('GET /users/{username}', {
+ username: 'testuser',
+ });
+
+ expect(response.data).toEqual(cachedData);
+ expect(response.headers['x-github-enterprise-version']).toBe('3.12.0');
+ });
+
+ it('propagates non-304 errors', async () => {
+ server.use(
+ http.get(`${baseUrl}/users/testuser`, () => {
+ return new HttpResponse(JSON.stringify({ message: 'Not Found' }), {
+ status: 404,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }),
+ );
+
+ const cache = createMockCache();
+ const octokit = createRestClient({
+ baseUrl,
+ orgUrl,
+ credentialsProvider: mockCredentialsProvider,
+ logger: mockServices.logger.mock(),
+ cache,
+ });
+
+ await expect(
+ octokit.request('GET /users/{username}', { username: 'testuser' }),
+ ).rejects.toThrow();
+ });
+
+ it('prefers last-modified over etag for conditional headers', async () => {
+ let receivedHeaders: Record = {};
+ server.use(
+ http.get(`${baseUrl}/users/testuser`, ({ request }) => {
+ receivedHeaders = Object.fromEntries(request.headers.entries());
+ return HttpResponse.json({ login: 'testuser' });
+ }),
+ );
+
+ const cache = createMockCache();
+ cache.store.set(
+ `catalog-backend-module-github:GET:${baseUrl}/users/testuser`,
+ {
+ lastModified: 'Thu, 01 Jan 2025 00:00:00 GMT',
+ etag: '"some-etag"',
+ data: { login: 'testuser' },
+ },
+ );
+
+ const octokit = createRestClient({
+ baseUrl,
+ orgUrl,
+ credentialsProvider: mockCredentialsProvider,
+ logger: mockServices.logger.mock(),
+ cache,
+ });
+
+ await octokit.request('GET /users/{username}', {
+ username: 'testuser',
+ });
+
+ expect(receivedHeaders['if-modified-since']).toBe(
+ 'Thu, 01 Jan 2025 00:00:00 GMT',
+ );
+ expect(receivedHeaders['if-none-match']).toBeUndefined();
+ });
+
+ it('uses distinct cache keys per user', async () => {
+ server.use(
+ http.get(`${baseUrl}/users/:username`, ({ params }) => {
+ return HttpResponse.json(
+ { login: params.username, suspended_at: null },
+ { headers: { 'Last-Modified': 'Thu, 01 Jan 2025 00:00:00 GMT' } },
+ );
+ }),
+ );
+
+ const cache = createMockCache();
+ const octokit = createRestClient({
+ baseUrl,
+ orgUrl,
+ credentialsProvider: mockCredentialsProvider,
+ logger: mockServices.logger.mock(),
+ cache,
+ });
+
+ await octokit.request('GET /users/{username}', {
+ username: 'user-a',
+ });
+ await octokit.request('GET /users/{username}', {
+ username: 'user-b',
+ });
+
+ expect(
+ cache.store.has(
+ `catalog-backend-module-github:GET:${baseUrl}/users/user-a`,
+ ),
+ ).toBe(true);
+ expect(
+ cache.store.has(
+ `catalog-backend-module-github:GET:${baseUrl}/users/user-b`,
+ ),
+ ).toBe(true);
+ });
+
+ it('works without a cache', async () => {
+ server.use(
+ http.get(`${baseUrl}/users/testuser`, () => {
+ return HttpResponse.json({ login: 'testuser' });
+ }),
+ );
+
+ const octokit = createRestClient({
+ baseUrl,
+ orgUrl,
+ credentialsProvider: mockCredentialsProvider,
+ logger: mockServices.logger.mock(),
+ });
+
+ const response = await octokit.request('GET /users/{username}', {
+ username: 'testuser',
+ });
+
+ expect(response.data).toEqual({ login: 'testuser' });
+ });
+ });
+ });
+
+ describe('isSuspended', () => {
+ it('returns true when the user account is suspended', async () => {
+ const octokit = {
+ request: jest.fn().mockImplementation((route: string) => {
+ if (route === 'GET /users/{username}') {
+ return { data: { suspended_at: '2025-01-01T00:00:00Z' } };
+ }
+ return { data: { role: 'member', state: 'active' } };
+ }),
+ } as any;
+
+ await expect(
+ isSuspended('suspended-user', octokit, { org: 'my-org' }),
+ ).resolves.toBe(true);
+ });
+
+ it('returns false for an active user', async () => {
+ const octokit = {
+ request: jest.fn().mockImplementation((route: string) => {
+ if (route === 'GET /users/{username}') {
+ return { data: { suspended_at: null } };
+ }
+ return { data: { role: 'member', state: 'active' } };
+ }),
+ } as any;
+
+ await expect(
+ isSuspended('active-user', octokit, { org: 'my-org' }),
+ ).resolves.toBe(false);
+ });
+
+ it('returns true when org membership is suspended', async () => {
+ const octokit = {
+ request: jest.fn().mockImplementation((route: string) => {
+ if (route === 'GET /users/{username}') {
+ return { data: { suspended_at: null } };
+ }
+ return { data: { role: 'suspended', state: 'active' } };
+ }),
+ } as any;
+
+ await expect(
+ isSuspended('org-suspended', octokit, { org: 'my-org' }),
+ ).resolves.toBe(true);
+ });
+
+ it('checks both user suspension and org membership', async () => {
+ const octokit = {
+ request: jest.fn().mockImplementation((route: string) => {
+ if (route === 'GET /users/{username}') {
+ return { data: { suspended_at: null } };
+ }
+ return { data: { role: 'member', state: 'active' } };
+ }),
+ } as any;
+
+ await isSuspended('some-user', octokit, { org: 'my-org' });
+
+ expect(octokit.request).toHaveBeenCalledTimes(2);
+ expect(octokit.request).toHaveBeenCalledWith('GET /users/{username}', {
+ username: 'some-user',
+ });
+ expect(octokit.request).toHaveBeenCalledWith(
+ 'GET /orgs/{org}/memberships/{username}',
+ { org: 'my-org', username: 'some-user' },
+ );
+ });
+ });
+
+ describe('isGitHubEnterprise', () => {
+ it('returns true when x-github-enterprise-version header is present', async () => {
+ const octokit = {
+ request: jest.fn().mockResolvedValue({
+ headers: { 'x-github-enterprise-version': '3.12.0' },
+ }),
+ } as any;
+
+ await expect(isGitHubEnterprise(octokit)).resolves.toBe(true);
+ expect(octokit.request).toHaveBeenCalledWith('GET /versions');
+ });
+
+ it('returns false when x-github-enterprise-version header is absent', async () => {
+ const octokit = {
+ request: jest.fn().mockResolvedValue({ headers: {} }),
+ } as any;
+
+ await expect(isGitHubEnterprise(octokit)).resolves.toBe(false);
+ });
+
+ it('returns false when the request throws', async () => {
+ const octokit = {
+ request: jest.fn().mockRejectedValue(new Error('Not Found')),
+ } as any;
+
+ await expect(isGitHubEnterprise(octokit)).resolves.toBe(false);
+ });
+ });
+
describe('Page sizes configuration', () => {
const org = 'my-org';
diff --git a/plugins/catalog-backend-module-github/src/lib/github.ts b/plugins/catalog-backend-module-github/src/lib/github.ts
index fc72004d9c..be8407c5c2 100644
--- a/plugins/catalog-backend-module-github/src/lib/github.ts
+++ b/plugins/catalog-backend-module-github/src/lib/github.ts
@@ -15,8 +15,13 @@
*/
import { Entity } from '@backstage/catalog-model';
-import { GithubCredentialType } from '@backstage/integration';
+import {
+ GithubCredentials,
+ GithubCredentialsProvider,
+ GithubCredentialType,
+} from '@backstage/integration';
import { graphql } from '@octokit/graphql';
+import { OctokitResponse, RequestParameters } from '@octokit/types';
import {
defaultOrganizationTeamTransformer,
defaultUserTransformer,
@@ -28,9 +33,10 @@ import { withLocations } from './withLocations';
import { DeferredEntity } from '@backstage/plugin-catalog-node';
import { Octokit } from '@octokit/core';
-import { LoggerService } from '@backstage/backend-plugin-api';
-import { throttling } from '@octokit/plugin-throttling';
+import { CacheService, LoggerService } from '@backstage/backend-plugin-api';
+import { throttling, ThrottlingOptions } from '@octokit/plugin-throttling';
import { retry } from '@octokit/plugin-retry';
+import { JsonValue } from '@backstage/types';
/**
* Configuration for GitHub GraphQL API page sizes.
@@ -185,6 +191,7 @@ export type Connection = {
* @param userTransformer - Optional transformer for user entities
* @param pageSizes - Optional page sizes configuration
* @param excludeSuspendedUsers - Optional flag to exclude suspended users (only for GitHub Enterprise instances)
+ * @param restClient - Optional Octokit REST client; when provided alongside excludeSuspendedUsers, the REST API is used instead of the GraphQL suspendedAt field
*/
export async function getOrganizationUsers(
client: typeof graphql,
@@ -193,8 +200,11 @@ export async function getOrganizationUsers(
userTransformer: UserTransformer = defaultUserTransformer,
pageSizes: GithubPageSizes = DEFAULT_PAGE_SIZES,
excludeSuspendedUsers: boolean = false,
+ restClient?: Octokit,
): Promise<{ users: Entity[] }> {
- const suspendedAtField = excludeSuspendedUsers ? 'suspendedAt,' : '';
+ const useRestForSuspension = excludeSuspendedUsers && !!restClient;
+ const suspendedAtField =
+ excludeSuspendedUsers && !useRestForSuspension ? 'suspendedAt,' : '';
const query = `
query users($org: String!, $email: Boolean!, $cursor: String, $organizationMembersPageSize: Int!) {
organization(login: $org) {
@@ -214,6 +224,18 @@ export async function getOrganizationUsers(
}
}`;
+ let restFilter:
+ | ((user: GithubUser) => Promise | boolean)
+ | undefined;
+
+ if (useRestForSuspension) {
+ const isEnterprise = await isGitHubEnterprise(restClient);
+ if (isEnterprise) {
+ restFilter = async (user: GithubUser) =>
+ !(await isSuspended(user.login, restClient, { org }));
+ }
+ }
+
// There is no user -> teams edge, so we leave the memberships empty for
// now and let the team iteration handle it instead
@@ -228,7 +250,9 @@ export async function getOrganizationUsers(
email: tokenType === 'token',
organizationMembersPageSize: pageSizes.organizationMembers,
},
- filter: u => (excludeSuspendedUsers ? !u.suspendedAt : true),
+ filter: useRestForSuspension
+ ? restFilter
+ : u => (excludeSuspendedUsers ? !u.suspendedAt : true),
});
return { users };
@@ -793,7 +817,7 @@ export async function queryWithPaging<
ctx: TransformerContext,
) => Promise;
variables: Variables;
- filter?: (item: GraphqlType) => boolean;
+ filter?: (item: GraphqlType) => Promise | boolean;
}): Promise {
const { client, query, org, connection, transformer, variables, filter } =
params;
@@ -813,7 +837,7 @@ export async function queryWithPaging<
}
for (const node of conn.nodes) {
- if (filter && !filter(node)) {
+ if (filter && !(await filter(node))) {
continue;
}
const transformedNode = await transformer(node, {
@@ -821,7 +845,6 @@ export async function queryWithPaging<
query,
org,
});
-
if (transformedNode) {
result.push(transformedNode);
}
@@ -928,3 +951,200 @@ export const createGraphqlClient = (args: {
return client;
};
+
+function octokitThrottlingOptions(logger: LoggerService): ThrottlingOptions {
+ return {
+ onRateLimit: (retryAfter, rateLimitData, _, retryCount) => {
+ logger.warn(
+ `Request quota exhausted for request ${rateLimitData?.method} ${rateLimitData?.url}`,
+ );
+
+ if (retryCount < 2) {
+ logger.warn(
+ `Retrying after ${retryAfter} seconds for the ${retryCount} time due to Rate Limit!`,
+ );
+ return true;
+ }
+
+ return false;
+ },
+ onSecondaryRateLimit: (retryAfter, rateLimitData, _, retryCount) => {
+ logger.warn(
+ `Secondary Rate Limit Exhausted for request ${rateLimitData?.method} ${rateLimitData?.url}`,
+ );
+
+ if (retryCount < 2) {
+ logger.warn(
+ `Retrying after ${retryAfter} seconds for the ${retryCount} time due to Secondary Rate Limit!`,
+ );
+ return true;
+ }
+
+ return false;
+ },
+ };
+}
+
+/**
+ * Creates an Octokit REST client with throttling, retry, and optional
+ * conditional request caching.
+ *
+ * @public
+ */
+export function createRestClient(options: {
+ baseUrl: string | undefined;
+ orgUrl: string;
+ credentialsProvider: GithubCredentialsProvider;
+ logger: LoggerService;
+ cache?: CacheService;
+}): Octokit & { auth: () => Promise } {
+ const getCredentials = () =>
+ options.credentialsProvider.getCredentials({
+ url: options.orgUrl,
+ });
+
+ const authStrategy = () => {
+ const auth = () => getCredentials();
+ auth.hook = async (
+ request: (
+ requestOptions: RequestParameters,
+ ) => Promise>,
+ hookOptions: RequestParameters,
+ ) => {
+ const { headers } = await getCredentials();
+ return request({
+ ...hookOptions,
+ headers: { ...hookOptions.headers, ...headers },
+ });
+ };
+ return auth;
+ };
+
+ const ThrottledOctokit = Octokit.plugin(throttling, retry);
+
+ const octokit = new ThrottledOctokit({
+ baseUrl: options.baseUrl,
+ authStrategy,
+ throttle: octokitThrottlingOptions(options.logger),
+ });
+
+ if (options.cache) {
+ installConditionalRequestCache(octokit, options.cache);
+ }
+
+ return octokit as Octokit & { auth: () => Promise };
+}
+
+type CachedGitHubResponse = {
+ lastModified?: string;
+ etag?: string;
+ headers: JsonValue;
+ data: JsonValue;
+};
+
+function installConditionalRequestCache(
+ octokit: Octokit,
+ cache: CacheService,
+): void {
+ octokit.hook.wrap('request', async (request, wrappedOptions) => {
+ const resolvedUrl = (wrappedOptions.url || '').replace(
+ /\{([^}]+)\}/g,
+ (_, key) => encodeURIComponent((wrappedOptions as any)[key]),
+ );
+ const cacheKey = `catalog-backend-module-github:${wrappedOptions.method}:${wrappedOptions.baseUrl}${resolvedUrl}`;
+ const cached = await cache
+ .get(cacheKey)
+ .catch(() => undefined);
+
+ if (cached?.lastModified) {
+ wrappedOptions.headers['if-modified-since'] = cached.lastModified;
+ } else if (cached?.etag) {
+ wrappedOptions.headers['if-none-match'] = cached.etag;
+ }
+
+ try {
+ const response = await request(wrappedOptions);
+
+ const lastModified = response.headers['last-modified'];
+ const etag = response.headers.etag;
+
+ if (lastModified || etag) {
+ cache
+ .set(
+ cacheKey,
+ {
+ lastModified,
+ etag,
+ headers: JSON.parse(JSON.stringify(response.headers)),
+ data: JSON.parse(JSON.stringify(response.data)),
+ },
+ // The TTL can be long here, since we only use the cache for
+ // conditional GitHub requests - it's never returned unless we get a
+ // 304 back from GitHub indicating that it's still correct.
+ { ttl: { years: 1 } },
+ )
+ .catch(() => {});
+ }
+ return response;
+ } catch (error: any) {
+ if (error?.status === 304 && cached) {
+ return {
+ ...error.response,
+ headers: cached.headers,
+ data: cached.data,
+ };
+ }
+ throw error;
+ }
+ });
+}
+
+/**
+ * Checks whether a user is suspended via the REST API.
+ */
+export async function isSuspended(
+ username: string,
+ octokit: Octokit,
+ options: { org: string },
+): Promise {
+ const [userResponse, membershipResponse] = await Promise.all([
+ octokit.request('GET /users/{username}', { username }),
+ octokit.request('GET /orgs/{org}/memberships/{username}', {
+ org: options.org,
+ username,
+ }),
+ ]);
+
+ // Octokit types are based on the public GitHub API, and since public GitHub
+ // doesn't include the ability to suspend users, there's no "suspended_at"
+ // field on the type, nor a "suspended" role on org memberships. However these
+ // fields are present for GitHub Enterprise, so we augment the types to
+ // include them.
+ const userSuspendedAt = (
+ userResponse.data as typeof userResponse.data & { suspended_at?: string }
+ ).suspended_at;
+ const membershipRole = (
+ membershipResponse.data as
+ | typeof membershipResponse.data
+ | {
+ role?: 'suspended';
+ }
+ ).role;
+
+ const userSuspended = !!userSuspendedAt;
+ const orgMembershipSuspended = membershipRole === 'suspended';
+
+ return userSuspended || orgMembershipSuspended;
+}
+
+/**
+ * Checks whether the GitHub instance is a GitHub Enterprise server.
+ */
+export async function isGitHubEnterprise(octokit: Octokit): Promise {
+ try {
+ const response = await octokit.request('GET /versions');
+ return !!response.headers['x-github-enterprise-version'];
+ } catch {
+ return false;
+ }
+}
diff --git a/plugins/catalog-backend-module-github/src/lib/index.ts b/plugins/catalog-backend-module-github/src/lib/index.ts
index 8806604888..14bcd3e962 100644
--- a/plugins/catalog-backend-module-github/src/lib/index.ts
+++ b/plugins/catalog-backend-module-github/src/lib/index.ts
@@ -20,6 +20,7 @@ export {
getOrganizationRepositories,
getOrganizationTeams,
getOrganizationUsers,
+ createRestClient,
type GithubUser,
type GithubTeam,
type GithubPageSizes,
diff --git a/plugins/catalog-backend-module-github/src/providers/GithubEntityProvider.ts b/plugins/catalog-backend-module-github/src/providers/GithubEntityProvider.ts
index 5a830c20b5..a89845465f 100644
--- a/plugins/catalog-backend-module-github/src/providers/GithubEntityProvider.ts
+++ b/plugins/catalog-backend-module-github/src/providers/GithubEntityProvider.ts
@@ -32,7 +32,7 @@ import {
import { LocationSpec } from '@backstage/plugin-catalog-common';
-import * as uuid from 'uuid';
+import { randomUUID } from 'node:crypto';
import {
GithubEntityProviderConfig,
readProviderConfigs,
@@ -189,7 +189,7 @@ export class GithubEntityProvider implements EntityProvider, EventSubscriber {
const logger = this.logger.child({
class: GithubEntityProvider.prototype.constructor.name,
taskId,
- taskInstanceId: uuid.v4(),
+ taskInstanceId: randomUUID(),
});
try {
await this.refresh(logger);
diff --git a/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.test.ts b/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.test.ts
index e80579ee36..a48071e177 100644
--- a/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.test.ts
+++ b/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.test.ts
@@ -29,8 +29,19 @@ import {
} from './GithubMultiOrgEntityProvider';
import { LoggerService } from '@backstage/backend-plugin-api';
import { mockServices } from '@backstage/backend-test-utils';
+import {
+ createRestClient,
+ isGitHubEnterprise,
+ isSuspended,
+} from '../lib/github';
jest.mock('@octokit/graphql');
+jest.mock('../lib/github', () => ({
+ ...jest.requireActual('../lib/github'),
+ createRestClient: jest.fn(),
+ isGitHubEnterprise: jest.fn(),
+ isSuspended: jest.fn(),
+}));
const getAllInstallationsMock = jest.fn();
jest.mock('@backstage/integration', () => ({
@@ -212,7 +223,7 @@ describe('GithubMultiOrgEntityProvider', () => {
});
expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({
- entities: [
+ entities: expect.arrayContaining([
{
entity: {
apiVersion: 'backstage.io/v1alpha1',
@@ -352,7 +363,7 @@ describe('GithubMultiOrgEntityProvider', () => {
},
locationKey: 'github-multi-org-provider:my-id',
},
- ],
+ ]),
type: 'full',
});
});
@@ -526,7 +537,7 @@ describe('GithubMultiOrgEntityProvider', () => {
});
expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({
- entities: [
+ entities: expect.arrayContaining([
{
entity: {
apiVersion: 'backstage.io/v1alpha1',
@@ -666,7 +677,7 @@ describe('GithubMultiOrgEntityProvider', () => {
},
locationKey: 'github-multi-org-provider:my-id',
},
- ],
+ ]),
type: 'full',
});
});
@@ -804,7 +815,7 @@ describe('GithubMultiOrgEntityProvider', () => {
await entityProvider.read();
expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({
- entities: [
+ entities: expect.arrayContaining([
{
entity: {
apiVersion: 'backstage.io/v1alpha1',
@@ -942,7 +953,7 @@ describe('GithubMultiOrgEntityProvider', () => {
},
locationKey: 'github-multi-org-provider:my-id',
},
- ],
+ ]),
type: 'full',
});
});
@@ -2471,5 +2482,146 @@ describe('GithubMultiOrgEntityProvider', () => {
});
});
});
+
+ describe('suspended user handling', () => {
+ let suspendedEvents: EventsService;
+ let suspendedConnection: EntityProviderConnection;
+
+ beforeEach(async () => {
+ const logger = mockServices.logger.mock();
+ suspendedEvents = DefaultEventsService.create({ logger });
+
+ suspendedConnection = {
+ applyMutation: jest.fn(),
+ refresh: jest.fn(),
+ };
+
+ const config = new ConfigReader({
+ integrations: {
+ github: [{ host: 'github.com' }],
+ },
+ });
+
+ const mockGetCredentials = jest.fn().mockReturnValue({
+ headers: { token: 'blah' },
+ type: 'app',
+ });
+
+ (createRestClient as jest.Mock).mockReturnValue({});
+ (isGitHubEnterprise as jest.Mock).mockResolvedValue(true);
+ (isSuspended as jest.Mock).mockResolvedValue(true);
+
+ const entityProvider = GithubMultiOrgEntityProvider.fromConfig(config, {
+ events: suspendedEvents,
+ id: 'my-id',
+ githubCredentialsProvider: { getCredentials: mockGetCredentials },
+ githubUrl: 'https://github.com',
+ logger,
+ orgs: ['orgA', 'orgB'],
+ excludeSuspendedUsers: true,
+ experimental_checkForSuspendedUsersWithRest: true,
+ cache: mockServices.cache.mock(),
+ });
+
+ await entityProvider.connect(suspendedConnection);
+ });
+
+ it('should skip adding a suspended user on member_added event', async () => {
+ await suspendedEvents.publish({
+ topic: 'github.organization',
+ eventPayload: {
+ action: 'member_added',
+ organization: { login: 'orgA' },
+ membership: {
+ user: {
+ name: 'a',
+ node_id: 'f',
+ avatar_url: 'https://example.com/avatar',
+ email: 'a@test.com',
+ login: 'a',
+ },
+ },
+ },
+ });
+
+ expect(suspendedConnection.applyMutation).not.toHaveBeenCalled();
+ expect(isSuspended).toHaveBeenCalledWith('a', expect.anything(), {
+ org: 'orgA',
+ });
+ });
+
+ it('should exclude suspended user on membership event', async () => {
+ const mockClient = jest.fn();
+
+ mockClient.mockResolvedValueOnce({
+ organization: {
+ team: {
+ slug: 'team',
+ combinedSlug: 'orgA/team',
+ name: 'Team',
+ description: 'The team',
+ avatarUrl: 'http://example.com/team.jpeg',
+ parentTeam: null,
+ members: {
+ pageInfo: { hasNextPage: false },
+ nodes: [{ login: 'a' }],
+ },
+ },
+ },
+ });
+
+ (graphql.defaults as jest.Mock).mockReturnValue(mockClient);
+
+ await suspendedEvents.publish({
+ topic: 'github.membership',
+ eventPayload: {
+ action: 'added',
+ team: {
+ name: 'Team',
+ slug: 'team',
+ description: 'The team',
+ html_url: 'https://github.com/orgs/orgA/teams/team',
+ parent: null,
+ },
+ member: {
+ login: 'a',
+ avatar_url: 'https://example.com/avatar',
+ email: 'a@test.com',
+ name: 'a',
+ node_id: 'f',
+ },
+ organization: { login: 'orgA' },
+ },
+ });
+
+ await new Promise(process.nextTick);
+
+ expect(suspendedConnection.applyMutation).toHaveBeenCalledTimes(1);
+ expect(suspendedConnection.applyMutation).toHaveBeenCalledWith({
+ type: 'delta',
+ added: [
+ {
+ locationKey: 'github-multi-org-provider:my-id',
+ entity: expect.objectContaining({
+ kind: 'Group',
+ metadata: expect.objectContaining({ name: 'team' }),
+ }),
+ },
+ ],
+ removed: [
+ {
+ locationKey: 'github-multi-org-provider:my-id',
+ entity: expect.objectContaining({
+ kind: 'User',
+ metadata: expect.objectContaining({ name: 'a' }),
+ }),
+ },
+ ],
+ });
+ expect(isSuspended).toHaveBeenCalledWith('a', expect.anything(), {
+ org: 'orgA',
+ });
+ });
+ });
});
});
diff --git a/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.ts b/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.ts
index 28d598b5b1..21308a6452 100644
--- a/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.ts
+++ b/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.ts
@@ -38,6 +38,7 @@ import {
EntityProviderConnection,
} from '@backstage/plugin-catalog-node';
import { EventParams, EventsService } from '@backstage/plugin-events-node';
+import { Octokit } from '@octokit/core';
import { graphql } from '@octokit/graphql';
import {
InstallationCreatedEvent,
@@ -51,8 +52,8 @@ import {
TeamEditedEvent,
TeamEvent,
} from '@octokit/webhooks-types';
-import { merge } from 'lodash';
-import * as uuid from 'uuid';
+import { memoize, merge } from 'lodash';
+import { randomUUID } from 'node:crypto';
import {
assignGroupsToUsers,
@@ -74,14 +75,18 @@ import {
ANNOTATION_GITHUB_USER_LOGIN,
} from '../lib/annotation';
import {
+ createRestClient,
getOrganizationsFromUser,
getOrganizationTeam,
getOrganizationTeamsForUser,
getOrganizationTeamsFromUsers,
+ isGitHubEnterprise,
+ isSuspended,
} from '../lib/github';
import { splitTeamSlug } from '../lib/util';
import { areGroupEntities, areUserEntities } from '../lib/guards';
import {
+ CacheService,
LoggerService,
SchedulerServiceTaskRunner,
} from '@backstage/backend-plugin-api';
@@ -182,6 +187,21 @@ export interface GithubMultiOrgEntityProviderOptions {
* Only for GitHub Enterprise instances. Will error if used against GitHub.com API.
*/
excludeSuspendedUsers?: boolean;
+
+ /**
+ * Optional cache service used for conditional HTTP request caching when
+ * checking suspended users via the REST API.
+ */
+ cache?: CacheService;
+
+ /**
+ * When set to true alongside `excludeSuspendedUsers`, use the GitHub REST API
+ * to check for suspended users instead of the GraphQL `suspendedAt` field.
+ * REST responses are cached using conditional HTTP requests to minimize rate
+ * limit usage.
+ * @defaultValue false
+ */
+ experimental_checkForSuspendedUsersWithRest?: boolean;
}
type CreateDeltaOperation = (entities: Entity[]) => {
@@ -230,6 +250,9 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
alwaysUseDefaultNamespace: options.alwaysUseDefaultNamespace,
pageSizes: options.pageSizes,
excludeSuspendedUsers: options.excludeSuspendedUsers,
+ cache: options.cache,
+ experimental_checkForSuspendedUsersWithRest:
+ options.experimental_checkForSuspendedUsersWithRest,
});
provider.schedule(options.schedule);
@@ -251,6 +274,8 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
alwaysUseDefaultNamespace?: boolean;
pageSizes?: Partial;
excludeSuspendedUsers?: boolean;
+ cache?: CacheService;
+ experimental_checkForSuspendedUsersWithRest?: boolean;
},
) {}
@@ -266,6 +291,45 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
};
}
+ private get useRestSuspendedCheck(): boolean {
+ return (
+ !!this.options.excludeSuspendedUsers &&
+ !!this.options.experimental_checkForSuspendedUsersWithRest
+ );
+ }
+
+ private readonly restClients = new Map();
+
+ private getRestClient(org: string): Octokit {
+ let client = this.restClients.get(org);
+ if (!client) {
+ client = createRestClient({
+ baseUrl: this.options.gitHubConfig.apiBaseUrl,
+ orgUrl: `${this.options.githubUrl}/${org}`,
+ credentialsProvider: this.options.githubCredentialsProvider,
+ logger: this.options.logger,
+ cache: this.options.cache,
+ });
+ this.restClients.set(org, client);
+ }
+ return client;
+ }
+
+ private isGitHubEnterprise = memoize((org: string) =>
+ isGitHubEnterprise(this.getRestClient(org)),
+ );
+
+ private async shouldExclude(login: string, org: string): Promise {
+ if (!this.useRestSuspendedCheck) {
+ return false;
+ }
+ const restClient = this.getRestClient(org);
+ return (
+ (await this.isGitHubEnterprise(org)) &&
+ (await isSuspended(login, restClient, { org }))
+ );
+ }
+
/** {@inheritdoc @backstage/plugin-catalog-node#EntityProvider.connect} */
async connect(connection: EntityProviderConnection) {
this.connection = connection;
@@ -317,6 +381,7 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
this.options.userTransformer,
pageSizes,
this.options.excludeSuspendedUsers,
+ this.useRestSuspendedCheck ? this.getRestClient(org) : undefined,
);
const { teams } = await getOrganizationTeams(
@@ -351,15 +416,32 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
const { markCommitComplete } = markReadComplete({ allUsers, allTeams });
+ const allEntities = [...allUsers, ...allTeams].map(entity => ({
+ locationKey: `github-multi-org-provider:${this.options.id}`,
+ entity: withLocations(
+ `https://${this.options.gitHubConfig.host}`,
+ entity,
+ ),
+ }));
+ allEntities.sort((a, b) => {
+ const am = a.entity.metadata;
+ const bm = b.entity.metadata;
+ if (am.name !== bm.name) return am.name < bm.name ? -1 : 1;
+ const al = am.annotations?.[ANNOTATION_LOCATION] ?? '';
+ const bl = bm.annotations?.[ANNOTATION_LOCATION] ?? '';
+ if (al !== bl) return al < bl ? -1 : 1;
+ if (a.entity.kind !== b.entity.kind) {
+ return a.entity.kind < b.entity.kind ? -1 : 1;
+ }
+ const ans = am.namespace ?? '';
+ const bns = bm.namespace ?? '';
+ if (ans !== bns) return ans < bns ? -1 : 1;
+ return 0;
+ });
+
await this.connection.applyMutation({
type: 'full',
- entities: [...allUsers, ...allTeams].map(entity => ({
- locationKey: `github-multi-org-provider:${this.options.id}`,
- entity: withLocations(
- `https://${this.options.gitHubConfig.host}`,
- entity,
- ),
- })),
+ entities: allEntities,
});
markCommitComplete();
@@ -470,6 +552,7 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
this.options.userTransformer,
pageSizes,
this.options.excludeSuspendedUsers,
+ this.useRestSuspendedCheck ? this.getRestClient(org) : undefined,
);
const { teams } = await getOrganizationTeams(
@@ -539,6 +622,14 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
node_id,
} = event.membership.user;
const org = event.organization.login;
+
+ if (
+ event.action === 'member_added' &&
+ (await this.shouldExclude(login, org))
+ ) {
+ return;
+ }
+
const { headers } =
await this.options.githubCredentialsProvider.getCredentials({
url: `${this.options.githubUrl}/${org}`,
@@ -712,6 +803,7 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
this.options.userTransformer,
pageSizes,
this.options.excludeSuspendedUsers,
+ this.useRestSuspendedCheck ? this.getRestClient(org) : undefined,
);
const usersFromChangedGroup = isGroupEntity(team)
@@ -841,43 +933,49 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
},
);
- const mutationEntities = [team];
+ const addedEntities: Entity[] = [team];
+ const removedEntities: Entity[] = [];
if (user && isUserEntity(user)) {
- const { orgs } = await getOrganizationsFromUser(client, login);
- const userApplicableOrgs = orgs.filter(o => applicableOrgs.includes(o));
- for (const userOrg of userApplicableOrgs) {
- const { headers: orgHeaders } =
- await this.options.githubCredentialsProvider.getCredentials({
- url: `${this.options.githubUrl}/${userOrg}`,
+ if (await this.shouldExclude(login, org)) {
+ removedEntities.push(user);
+ } else {
+ const { orgs } = await getOrganizationsFromUser(client, login);
+ const userApplicableOrgs = orgs.filter(o => applicableOrgs.includes(o));
+ for (const userOrg of userApplicableOrgs) {
+ const { headers: orgHeaders } =
+ await this.options.githubCredentialsProvider.getCredentials({
+ url: `${this.options.githubUrl}/${userOrg}`,
+ });
+ const orgClient = graphql.defaults({
+ baseUrl: this.options.gitHubConfig.apiBaseUrl,
+ headers: orgHeaders,
});
- const orgClient = graphql.defaults({
- baseUrl: this.options.gitHubConfig.apiBaseUrl,
- headers: orgHeaders,
- });
- const { teams } = await getOrganizationTeamsForUser(
- orgClient,
- userOrg,
- login,
- this.defaultMultiOrgTeamTransformer.bind(this),
- pageSizes,
- );
+ const { teams } = await getOrganizationTeamsForUser(
+ orgClient,
+ userOrg,
+ login,
+ this.defaultMultiOrgTeamTransformer.bind(this),
+ pageSizes,
+ );
- if (areGroupEntities(teams)) {
- assignGroupsToUser(user, teams);
+ if (areGroupEntities(teams)) {
+ assignGroupsToUser(user, teams);
+ }
}
- }
- mutationEntities.push(user);
+ addedEntities.push(user);
+ }
}
- const { added, removed } =
- this.createAddEntitiesOperation(mutationEntities);
+ const materializedAdd = this.createAddEntitiesOperation(addedEntities);
+ const materializedRemove =
+ this.createRemoveEntitiesOperation(removedEntities);
await this.connection.applyMutation({
type: 'delta',
- removed,
- added,
+ removed: [...materializedAdd.removed, ...materializedRemove.removed],
+ added: [...materializedAdd.added, ...materializedRemove.added],
});
}
@@ -894,7 +992,7 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
const logger = this.options.logger.child({
class: GithubMultiOrgEntityProvider.prototype.constructor.name,
taskId: id,
- taskInstanceId: uuid.v4(),
+ taskInstanceId: randomUUID(),
});
try {
diff --git a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts
index 86b0090263..62bbb797b2 100644
--- a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts
+++ b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts
@@ -26,7 +26,12 @@ import {
EventParams,
} from '@backstage/plugin-events-node';
import { graphql } from '@octokit/graphql';
-import { createGraphqlClient } from '../lib/github';
+import {
+ createGraphqlClient,
+ createRestClient,
+ isGitHubEnterprise,
+ isSuspended,
+} from '../lib/github';
import { withLocations } from '../lib/withLocations';
import { GithubOrgEntityProvider } from './GithubOrgEntityProvider';
@@ -34,6 +39,9 @@ jest.mock('@octokit/graphql');
jest.mock('../lib/github', () => ({
...jest.requireActual('../lib/github'),
createGraphqlClient: jest.fn(),
+ createRestClient: jest.fn(),
+ isGitHubEnterprise: jest.fn(),
+ isSuspended: jest.fn(),
}));
describe('GithubOrgEntityProvider', () => {
@@ -1310,5 +1318,255 @@ describe('GithubOrgEntityProvider', () => {
type: 'delta',
});
});
+
+ it('should skip adding a suspended user on member_added event', async () => {
+ const entityProviderConnection: EntityProviderConnection = {
+ applyMutation: jest.fn(),
+ refresh: jest.fn(),
+ };
+
+ const logger = mockServices.logger.mock();
+ const events = DefaultEventsService.create({ logger });
+ const gitHubConfig: GithubIntegrationConfig = {
+ host: 'github.com',
+ };
+
+ const mockGetCredentials = jest.fn().mockReturnValue({
+ headers: { token: 'blah' },
+ type: 'app',
+ token: 'blah',
+ });
+
+ const githubCredentialsProvider: GithubCredentialsProvider = {
+ getCredentials: mockGetCredentials,
+ };
+
+ (createRestClient as jest.Mock).mockReturnValue({});
+ (isGitHubEnterprise as jest.Mock).mockResolvedValue(true);
+ (isSuspended as jest.Mock).mockResolvedValue(true);
+
+ const entityProvider = new GithubOrgEntityProvider({
+ events,
+ id: 'my-id',
+ githubCredentialsProvider,
+ orgUrl: 'https://github.com/backstage',
+ gitHubConfig,
+ logger,
+ excludeSuspendedUsers: true,
+ experimental_checkForSuspendedUsersWithRest: true,
+ cache: mockServices.cache.mock(),
+ });
+
+ await entityProvider.connect(entityProviderConnection);
+
+ const event: EventParams = {
+ topic: 'github.organization',
+ eventPayload: {
+ action: 'member_added',
+ membership: {
+ user: {
+ name: 'githubuser',
+ login: 'githubuser',
+ node_id: 'githubuserId',
+ avatar_url: 'https://avatars.githubusercontent.com/u/83820368',
+ email: 'user1@test.com',
+ },
+ },
+ organization: {
+ login: 'test-org',
+ },
+ },
+ };
+ await events.publish(event);
+
+ expect(entityProviderConnection.applyMutation).not.toHaveBeenCalled();
+ expect(isSuspended).toHaveBeenCalledWith(
+ 'githubuser',
+ expect.anything(),
+ { org: 'test-org' },
+ );
+ });
+
+ it('should not skip member_added when experimental flag is off', async () => {
+ const entityProviderConnection: EntityProviderConnection = {
+ applyMutation: jest.fn(),
+ refresh: jest.fn(),
+ };
+
+ const logger = mockServices.logger.mock();
+ const events = DefaultEventsService.create({ logger });
+ const gitHubConfig: GithubIntegrationConfig = {
+ host: 'github.com',
+ };
+
+ const mockGetCredentials = jest.fn().mockReturnValue({
+ headers: { token: 'blah' },
+ type: 'app',
+ });
+
+ const githubCredentialsProvider: GithubCredentialsProvider = {
+ getCredentials: mockGetCredentials,
+ };
+
+ const entityProvider = new GithubOrgEntityProvider({
+ events,
+ id: 'my-id',
+ githubCredentialsProvider,
+ orgUrl: 'https://github.com/backstage',
+ gitHubConfig,
+ logger,
+ excludeSuspendedUsers: true,
+ });
+
+ await entityProvider.connect(entityProviderConnection);
+
+ const event: EventParams = {
+ topic: 'github.organization',
+ eventPayload: {
+ action: 'member_added',
+ membership: {
+ user: {
+ name: 'githubuser',
+ login: 'githubuser',
+ node_id: 'githubuserId',
+ avatar_url: 'https://avatars.githubusercontent.com/u/83820368',
+ email: 'user1@test.com',
+ },
+ },
+ organization: {
+ login: 'test-org',
+ },
+ },
+ };
+ await events.publish(event);
+
+ expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1);
+ expect(isSuspended).not.toHaveBeenCalled();
+ });
+
+ it.each(['added', 'removed'])(
+ 'should exclude suspended user on membership %s event',
+ async action => {
+ const entityProviderConnection: EntityProviderConnection = {
+ applyMutation: jest.fn(),
+ refresh: jest.fn(),
+ };
+
+ const logger = mockServices.logger.mock();
+ const events = DefaultEventsService.create({ logger });
+ const gitHubConfig: GithubIntegrationConfig = {
+ host: 'github.com',
+ };
+
+ const mockGetCredentials = jest.fn().mockReturnValue({
+ headers: { token: 'blah' },
+ type: 'app',
+ token: 'blah',
+ });
+
+ const githubCredentialsProvider: GithubCredentialsProvider = {
+ getCredentials: mockGetCredentials,
+ };
+
+ (createRestClient as jest.Mock).mockReturnValue({});
+ (isGitHubEnterprise as jest.Mock).mockResolvedValue(true);
+ (isSuspended as jest.Mock).mockResolvedValue(true);
+
+ const entityProvider = new GithubOrgEntityProvider({
+ events,
+ id: 'my-id',
+ githubCredentialsProvider,
+ orgUrl: 'https://github.com/backstage',
+ gitHubConfig,
+ logger,
+ excludeSuspendedUsers: true,
+ experimental_checkForSuspendedUsersWithRest: true,
+ cache: mockServices.cache.mock(),
+ });
+
+ const mockClient = jest.fn();
+
+ mockClient.mockResolvedValueOnce({
+ organization: {
+ team: {
+ slug: 'team',
+ combinedSlug: 'blah/team',
+ name: 'Team',
+ description: 'The one and only team',
+ avatarUrl: 'http://example.com/team.jpeg',
+ parentTeam: {
+ slug: 'parent',
+ combinedSlug: '',
+ members: { pageInfo: { hasNextPage: false }, nodes: [] },
+ },
+ members: {
+ pageInfo: { hasNextPage: false },
+ nodes: [{ login: 'a' }, { login: 'githubuser' }],
+ },
+ },
+ },
+ });
+
+ (graphql.defaults as jest.Mock).mockReturnValue(mockClient);
+ await entityProvider.connect(entityProviderConnection);
+
+ const event: EventParams = {
+ topic: 'github.membership',
+ eventPayload: {
+ action,
+ team: {
+ name: 'New Team',
+ slug: 'new-team',
+ description: 'description from the new team',
+ html_url: 'https://github.com/orgs/test-org/teams/new-team',
+ parent: {
+ slug: 'father-team',
+ },
+ },
+ member: {
+ login: 'githubuser',
+ avatar_url: 'e',
+ email: 'd',
+ name: 'githubuser',
+ node_id: 'githubuserId',
+ },
+ organization: {
+ login: 'test-org',
+ },
+ },
+ };
+
+ await events.publish(event);
+ await new Promise(process.nextTick);
+
+ expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1);
+ expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({
+ added: [
+ {
+ locationKey: 'github-org-provider:my-id',
+ entity: expect.objectContaining({
+ kind: 'Group',
+ metadata: expect.objectContaining({ name: 'team' }),
+ }),
+ },
+ ],
+ removed: [
+ {
+ locationKey: 'github-org-provider:my-id',
+ entity: expect.objectContaining({
+ kind: 'User',
+ metadata: expect.objectContaining({ name: 'githubuser' }),
+ }),
+ },
+ ],
+ type: 'delta',
+ });
+ expect(isSuspended).toHaveBeenCalledWith(
+ 'githubuser',
+ expect.anything(),
+ { org: 'backstage' },
+ );
+ },
+ );
});
});
diff --git a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts
index d13520f112..9e62a87a99 100644
--- a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts
+++ b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts
@@ -15,6 +15,7 @@
*/
import {
+ CacheService,
LoggerService,
SchedulerServiceTaskRunner,
} from '@backstage/backend-plugin-api';
@@ -32,6 +33,7 @@ import {
EntityProviderConnection,
} from '@backstage/plugin-catalog-node';
import { EventParams, EventsService } from '@backstage/plugin-events-node';
+import { Octokit } from '@octokit/core';
import { graphql } from '@octokit/graphql';
import {
MembershipEvent,
@@ -41,7 +43,7 @@ import {
TeamEditedEvent,
TeamEvent,
} from '@octokit/webhooks-types';
-import * as uuid from 'uuid';
+import { randomUUID } from 'node:crypto';
import {
defaultOrganizationTeamTransformer,
defaultUserTransformer,
@@ -52,6 +54,7 @@ import {
createAddEntitiesOperation,
createGraphqlClient,
createRemoveEntitiesOperation,
+ createRestClient,
DEFAULT_PAGE_SIZES,
DeferredEntitiesBuilder,
getOrganizationTeam,
@@ -61,6 +64,8 @@ import {
getOrganizationUsers,
GithubPageSizes,
GithubTeam,
+ isGitHubEnterprise,
+ isSuspended,
} from '../lib/github';
import { areGroupEntities, areUserEntities } from '../lib/guards';
import {
@@ -70,6 +75,7 @@ import {
} from '../lib/org';
import { parseGithubOrgUrl } from '../lib/util';
import { withLocations } from '../lib/withLocations';
+import { memoize } from 'lodash';
const EVENT_TOPICS = [
'github.membership',
@@ -150,6 +156,21 @@ export interface GithubOrgEntityProviderOptions {
* Only for GitHub Enterprise instances. Will error if used against GitHub.com API.
*/
excludeSuspendedUsers?: boolean;
+
+ /**
+ * Optional cache service used for conditional HTTP request caching when
+ * checking suspended users via the REST API.
+ */
+ cache?: CacheService;
+
+ /**
+ * When set to true alongside `excludeSuspendedUsers`, use the GitHub REST API
+ * to check for suspended users instead of the GraphQL `suspendedAt` field.
+ * REST responses are cached using conditional HTTP requests to minimize rate
+ * limit usage.
+ * @defaultValue false
+ */
+ experimental_checkForSuspendedUsersWithRest?: boolean;
}
/**
@@ -159,6 +180,7 @@ export interface GithubOrgEntityProviderOptions {
*/
export class GithubOrgEntityProvider implements EntityProvider {
private readonly credentialsProvider: GithubCredentialsProvider;
+ private cachedRestClient?: Octokit;
private connection?: EntityProviderConnection;
private scheduleFn?: () => Promise;
@@ -189,6 +211,9 @@ export class GithubOrgEntityProvider implements EntityProvider {
events: options.events,
pageSizes: options.pageSizes,
excludeSuspendedUsers: options.excludeSuspendedUsers,
+ cache: options.cache,
+ experimental_checkForSuspendedUsersWithRest:
+ options.experimental_checkForSuspendedUsersWithRest,
});
provider.schedule(options.schedule);
@@ -208,6 +233,8 @@ export class GithubOrgEntityProvider implements EntityProvider {
teamTransformer?: TeamTransformer;
pageSizes?: Partial;
excludeSuspendedUsers?: boolean;
+ cache?: CacheService;
+ experimental_checkForSuspendedUsersWithRest?: boolean;
},
) {
this.credentialsProvider =
@@ -227,6 +254,41 @@ export class GithubOrgEntityProvider implements EntityProvider {
};
}
+ private get useRestSuspendedCheck(): boolean {
+ return (
+ !!this.options.excludeSuspendedUsers &&
+ !!this.options.experimental_checkForSuspendedUsersWithRest
+ );
+ }
+
+ private isGitHubEnterprise = memoize(() =>
+ isGitHubEnterprise(this.getRestClient()),
+ );
+
+ private getRestClient(): Octokit {
+ if (!this.cachedRestClient) {
+ this.cachedRestClient = createRestClient({
+ baseUrl: this.options.gitHubConfig.apiBaseUrl,
+ orgUrl: this.options.orgUrl,
+ credentialsProvider: this.credentialsProvider,
+ logger: this.options.logger,
+ cache: this.options.cache,
+ });
+ }
+ return this.cachedRestClient;
+ }
+
+ private async shouldExclude(login: string, org: string): Promise {
+ if (!this.useRestSuspendedCheck) {
+ return false;
+ }
+ const restClient = this.getRestClient();
+ return (
+ (await this.isGitHubEnterprise()) &&
+ (await isSuspended(login, restClient, { org }))
+ );
+ }
+
/** {@inheritdoc @backstage/plugin-catalog-node#EntityProvider.connect} */
async connect(connection: EntityProviderConnection) {
this.connection = connection;
@@ -263,6 +325,7 @@ export class GithubOrgEntityProvider implements EntityProvider {
const { org } = parseGithubOrgUrl(this.options.orgUrl);
const pageSizes = this.getPageSizes();
+
const { users } = await getOrganizationUsers(
client,
org,
@@ -270,6 +333,7 @@ export class GithubOrgEntityProvider implements EntityProvider {
this.options.userTransformer,
pageSizes,
this.options.excludeSuspendedUsers,
+ this.useRestSuspendedCheck ? this.getRestClient() : undefined,
);
const { teams } = await getOrganizationTeams(
client,
@@ -358,6 +422,7 @@ export class GithubOrgEntityProvider implements EntityProvider {
await this.onMembershipChangedInOrganization(
membershipEvent,
addEntitiesOperation,
+ removeEntitiesOperation,
);
}
@@ -399,6 +464,7 @@ export class GithubOrgEntityProvider implements EntityProvider {
this.options.userTransformer,
pageSizes,
this.options.excludeSuspendedUsers,
+ this.useRestSuspendedCheck ? this.getRestClient() : undefined,
);
if (!isGroupEntity(team)) {
@@ -461,7 +527,8 @@ export class GithubOrgEntityProvider implements EntityProvider {
private async onMembershipChangedInOrganization(
event: MembershipEvent,
- createDeltaOperation: DeferredEntitiesBuilder,
+ addEntitiesOperation: DeferredEntitiesBuilder,
+ removeEntitiesOperation: DeferredEntitiesBuilder,
) {
if (!this.connection) {
throw new Error('Not initialized');
@@ -512,31 +579,47 @@ export class GithubOrgEntityProvider implements EntityProvider {
},
);
- const mutationEntities: Entity[] = [team];
+ const addedEntities: Entity[] = [team];
+ const removedEntities: Entity[] = [];
if (user && isUserEntity(user)) {
- const teamTransformer =
- this.options.teamTransformer || defaultOrganizationTeamTransformer;
- const { teams } = await getOrganizationTeamsForUser(
- client,
- org,
- login,
- teamTransformer,
- pageSizes,
- );
+ if (await this.shouldExclude(login, org)) {
+ removedEntities.push(user);
+ } else {
+ const teamTransformer =
+ this.options.teamTransformer || defaultOrganizationTeamTransformer;
+ const { teams } = await getOrganizationTeamsForUser(
+ client,
+ org,
+ login,
+ teamTransformer,
+ pageSizes,
+ );
- if (areGroupEntities(teams)) {
- assignGroupsToUser(user, teams);
+ if (areGroupEntities(teams)) {
+ assignGroupsToUser(user, teams);
+ }
+
+ addedEntities.push(user);
}
-
- mutationEntities.push(user);
}
- const { added, removed } = createDeltaOperation(org, mutationEntities);
+ const materializedAddOperation = addEntitiesOperation(org, addedEntities);
+ const materializedRemoveOperation = removeEntitiesOperation(
+ org,
+ removedEntities,
+ );
+
await this.connection.applyMutation({
type: 'delta',
- removed,
- added,
+ removed: [
+ ...materializedAddOperation.removed,
+ ...materializedRemoveOperation.removed,
+ ],
+ added: [
+ ...materializedAddOperation.added,
+ ...materializedRemoveOperation.added,
+ ],
});
}
@@ -607,6 +690,14 @@ export class GithubOrgEntityProvider implements EntityProvider {
node_id,
} = event.membership.user;
const org = event.organization.login;
+
+ if (
+ event.action === 'member_added' &&
+ (await this.shouldExclude(login, org))
+ ) {
+ return;
+ }
+
const { headers } = await this.credentialsProvider.getCredentials({
url: this.options.orgUrl,
});
@@ -653,7 +744,7 @@ export class GithubOrgEntityProvider implements EntityProvider {
const logger = this.options.logger.child({
class: GithubOrgEntityProvider.prototype.constructor.name,
taskId: id,
- taskInstanceId: uuid.v4(),
+ taskInstanceId: randomUUID(),
});
try {
diff --git a/plugins/catalog-backend-module-gitlab-org/CHANGELOG.md b/plugins/catalog-backend-module-gitlab-org/CHANGELOG.md
index c79caaaa5a..3835eddc94 100644
--- a/plugins/catalog-backend-module-gitlab-org/CHANGELOG.md
+++ b/plugins/catalog-backend-module-gitlab-org/CHANGELOG.md
@@ -1,5 +1,15 @@
# @backstage/plugin-catalog-backend-module-gitlab-org
+## 0.2.21
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/plugin-catalog-backend-module-gitlab@0.8.3
+ - @backstage/plugin-events-node@0.4.22
+
## 0.2.21-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-gitlab-org/package.json b/plugins/catalog-backend-module-gitlab-org/package.json
index e67bac93ab..3cb6334c04 100644
--- a/plugins/catalog-backend-module-gitlab-org/package.json
+++ b/plugins/catalog-backend-module-gitlab-org/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-gitlab-org",
- "version": "0.2.21-next.0",
+ "version": "0.2.21",
"description": "The gitlab-org backend module for the catalog plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/catalog-backend-module-gitlab/CHANGELOG.md b/plugins/catalog-backend-module-gitlab/CHANGELOG.md
index c3d737c3cc..628db35aa2 100644
--- a/plugins/catalog-backend-module-gitlab/CHANGELOG.md
+++ b/plugins/catalog-backend-module-gitlab/CHANGELOG.md
@@ -1,5 +1,42 @@
# @backstage/plugin-catalog-backend-module-gitlab
+## 0.8.3
+
+### Patch Changes
+
+- 1ecc3ca: Fixed spelling mistakes in internal code
+- 0c5e41f: Removed unused dependencies that had no imports in source code.
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/backend-defaults@0.17.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/integration@2.0.2
+ - @backstage/config@1.3.8
+ - @backstage/plugin-catalog-common@1.1.10
+ - @backstage/plugin-events-node@0.4.22
+
+## 0.8.3-next.2
+
+### Patch Changes
+
+- 1ecc3ca: Fixed spelling mistakes in internal code
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1-next.1
+ - @backstage/backend-defaults@0.17.1-next.2
+
+## 0.8.3-next.1
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.8.1-next.1
+ - @backstage/plugin-catalog-node@2.2.1-next.1
+ - @backstage/backend-defaults@0.17.1-next.1
+
## 0.8.3-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-gitlab/package.json b/plugins/catalog-backend-module-gitlab/package.json
index 8d413c5210..359c35aa9b 100644
--- a/plugins/catalog-backend-module-gitlab/package.json
+++ b/plugins/catalog-backend-module-gitlab/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-gitlab",
- "version": "0.8.3-next.0",
+ "version": "0.8.3",
"description": "A Backstage catalog backend module that helps integrate towards GitLab",
"backstage": {
"role": "backend-plugin-module",
@@ -61,8 +61,7 @@
"@backstage/plugin-catalog-node": "workspace:^",
"@backstage/plugin-events-node": "workspace:^",
"@gitbeaker/rest": "^40.0.3",
- "lodash": "^4.17.21",
- "uuid": "^11.0.0"
+ "lodash": "^4.17.21"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
diff --git a/plugins/catalog-backend-module-gitlab/src/__testUtils__/mocks.ts b/plugins/catalog-backend-module-gitlab/src/__testUtils__/mocks.ts
index ba326971c6..af6626b6eb 100644
--- a/plugins/catalog-backend-module-gitlab/src/__testUtils__/mocks.ts
+++ b/plugins/catalog-backend-module-gitlab/src/__testUtils__/mocks.ts
@@ -1367,9 +1367,9 @@ export const all_groups_response: GitLabGroup[] = [
},
{
id: 8,
- name: 'awsome-group',
+ name: 'awesome-group',
description: '',
- full_path: 'awsome-group',
+ full_path: 'awesome-group',
},
];
diff --git a/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.ts b/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.ts
index 92f4ac797a..3a676866a2 100644
--- a/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.ts
+++ b/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.ts
@@ -30,7 +30,7 @@ import {
} from '@backstage/plugin-catalog-node';
import { EventsService } from '@backstage/plugin-events-node';
import { WebhookProjectSchema, WebhookPushEventSchema } from '@gitbeaker/rest';
-import * as uuid from 'uuid';
+import { randomUUID } from 'node:crypto';
import {
GitLabClient,
GitLabGroup,
@@ -180,7 +180,7 @@ export class GitlabDiscoveryEntityProvider implements EntityProvider {
const logger = this.logger.child({
class: GitlabDiscoveryEntityProvider.prototype.constructor.name,
taskId,
- taskInstanceId: uuid.v4(),
+ taskInstanceId: randomUUID(),
});
try {
@@ -476,12 +476,12 @@ export class GitlabDiscoveryEntityProvider implements EntityProvider {
);
// Modified files will be scheduled to a refresh
- const addedEntities = this.createLocationSpecCommitedFiles(
+ const addedEntities = this.createLocationSpecCommittedFiles(
event.project,
added,
);
- const removedEntities = this.createLocationSpecCommitedFiles(
+ const removedEntities = this.createLocationSpecCommittedFiles(
event.project,
removed,
);
@@ -563,7 +563,7 @@ export class GitlabDiscoveryEntityProvider implements EntityProvider {
* @param addedFiles - The array of added file paths.
* @returns An array of location specs.
*/
- private createLocationSpecCommitedFiles(
+ private createLocationSpecCommittedFiles(
project: WebhookProjectSchema,
addedFiles: string[],
): LocationSpec[] {
diff --git a/plugins/catalog-backend-module-gitlab/src/providers/GitlabOrgDiscoveryEntityProvider.ts b/plugins/catalog-backend-module-gitlab/src/providers/GitlabOrgDiscoveryEntityProvider.ts
index 37b446fd95..7e0f1aed27 100644
--- a/plugins/catalog-backend-module-gitlab/src/providers/GitlabOrgDiscoveryEntityProvider.ts
+++ b/plugins/catalog-backend-module-gitlab/src/providers/GitlabOrgDiscoveryEntityProvider.ts
@@ -31,7 +31,7 @@ import {
} from '@backstage/plugin-catalog-node';
import { EventsService } from '@backstage/plugin-events-node';
import { merge } from 'lodash';
-import * as uuid from 'uuid';
+import { randomUUID } from 'node:crypto';
import {
GitLabClient,
@@ -340,7 +340,7 @@ export class GitlabOrgDiscoveryEntityProvider implements EntityProvider {
const logger = this.logger.child({
class: GitlabOrgDiscoveryEntityProvider.prototype.constructor.name,
taskId,
- taskInstanceId: uuid.v4(),
+ taskInstanceId: randomUUID(),
});
try {
diff --git a/plugins/catalog-backend-module-gitlab/src/providers/config.test.ts b/plugins/catalog-backend-module-gitlab/src/providers/config.test.ts
index f6c8021655..d986019bce 100644
--- a/plugins/catalog-backend-module-gitlab/src/providers/config.test.ts
+++ b/plugins/catalog-backend-module-gitlab/src/providers/config.test.ts
@@ -402,7 +402,7 @@ describe('config', () => {
);
});
- it('valid config with empyt topics', () => {
+ it('valid config with empty topics', () => {
const config = new ConfigReader({
catalog: {
providers: {
diff --git a/plugins/catalog-backend-module-incremental-ingestion/CHANGELOG.md b/plugins/catalog-backend-module-incremental-ingestion/CHANGELOG.md
index a40b250f26..1b35b3ca82 100644
--- a/plugins/catalog-backend-module-incremental-ingestion/CHANGELOG.md
+++ b/plugins/catalog-backend-module-incremental-ingestion/CHANGELOG.md
@@ -1,5 +1,42 @@
# @backstage/plugin-catalog-backend-module-incremental-ingestion
+## 0.7.12
+
+### Patch Changes
+
+- 32f0dfe: On PostgreSQL, `WHERE ref IN ($1, $2, ..., $N)` queries on the `ingestion_mark_entities` table now use `= ANY($1)` with a single array parameter instead. This reduces prepared statement bloat in the query plan cache when the number of entity refs varies between calls.
+- 0c5e41f: Removed unused dependencies that had no imports in source code.
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/backend-defaults@0.17.1
+ - @backstage/plugin-catalog-backend@3.7.0
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/config@1.3.8
+ - @backstage/plugin-events-node@0.4.22
+
+## 0.7.12-next.2
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1-next.1
+ - @backstage/backend-defaults@0.17.1-next.2
+ - @backstage/plugin-catalog-backend@3.7.0-next.2
+
+## 0.7.12-next.1
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.8.1-next.1
+ - @backstage/plugin-catalog-node@2.2.1-next.1
+ - @backstage/backend-defaults@0.17.1-next.1
+ - @backstage/plugin-catalog-backend@3.6.2-next.1
+
## 0.7.12-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-incremental-ingestion/package.json b/plugins/catalog-backend-module-incremental-ingestion/package.json
index 8fa3963d35..d140f396b3 100644
--- a/plugins/catalog-backend-module-incremental-ingestion/package.json
+++ b/plugins/catalog-backend-module-incremental-ingestion/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-incremental-ingestion",
- "version": "0.7.12-next.0",
+ "version": "0.7.12",
"description": "An entity provider for streaming large asset sources into the catalog",
"backstage": {
"role": "backend-plugin-module",
@@ -59,8 +59,7 @@
"express": "^4.22.0",
"express-promise-router": "^4.1.0",
"knex": "^3.0.0",
- "luxon": "^3.0.0",
- "uuid": "^11.0.0"
+ "luxon": "^3.0.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
diff --git a/plugins/catalog-backend-module-incremental-ingestion/src/database/IncrementalIngestionDatabaseManager.test.ts b/plugins/catalog-backend-module-incremental-ingestion/src/database/IncrementalIngestionDatabaseManager.test.ts
index 03a0488cb0..91975edfb5 100644
--- a/plugins/catalog-backend-module-incremental-ingestion/src/database/IncrementalIngestionDatabaseManager.test.ts
+++ b/plugins/catalog-backend-module-incremental-ingestion/src/database/IncrementalIngestionDatabaseManager.test.ts
@@ -16,25 +16,27 @@
import { TestDatabases } from '@backstage/backend-test-utils';
import { IncrementalIngestionDatabaseManager } from './IncrementalIngestionDatabaseManager';
-import { v4 as uuid } from 'uuid';
+import { randomUUID as uuid } from 'node:crypto';
import { DeferredEntity } from '@backstage/plugin-catalog-node';
const migrationsDir = `${__dirname}/../../migrations`;
jest.setTimeout(60_000);
-describe('IncrementalIngestionDatabaseManager', () => {
- const databases = TestDatabases.create({
- ids: ['POSTGRES_18', 'POSTGRES_14', 'SQLITE_3'],
- });
+const databases = TestDatabases.create({
+ ids: ['POSTGRES_18', 'POSTGRES_14', 'SQLITE_3'],
+});
- it.each(databases.eachSupportedId())(
- 'stores and retrieves marks, %p',
- async databaseId => {
+describe.each(databases.eachSupportedId())(
+ 'IncrementalIngestionDatabaseManager, %p',
+ databaseId => {
+ it('stores and retrieves marks', async () => {
const knex = await databases.init(databaseId);
await knex.migrate.latest({ directory: migrationsDir });
- const manager = new IncrementalIngestionDatabaseManager({ client: knex });
+ const manager = new IncrementalIngestionDatabaseManager({
+ client: knex,
+ });
const { ingestionId } = (await manager.createProviderIngestionRecord(
'myProvider',
))!;
@@ -75,16 +77,15 @@ describe('IncrementalIngestionDatabaseManager', () => {
sequence: 1,
},
]);
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'computeRemoved correctly sums total count from count query, %p',
- async databaseId => {
+ it('computeRemoved correctly sums total count from count query', async () => {
const knex = await databases.init(databaseId);
await knex.migrate.latest({ directory: migrationsDir });
- const manager = new IncrementalIngestionDatabaseManager({ client: knex });
+ const manager = new IncrementalIngestionDatabaseManager({
+ client: knex,
+ });
const { ingestionId } = (await manager.createProviderIngestionRecord(
'testProvider',
))!;
@@ -119,6 +120,125 @@ describe('IncrementalIngestionDatabaseManager', () => {
// On PostgreSQL, count queries return strings, so total should be 3 not NaN or string concatenation
expect(result.total).toBe(3);
expect(typeof result.total).toBe('number');
- },
- );
-});
+ });
+
+ it('createMarkEntities handles existing and new refs correctly', async () => {
+ const knex = await databases.init(databaseId);
+ await knex.migrate.latest({ directory: migrationsDir });
+
+ const manager = new IncrementalIngestionDatabaseManager({ client: knex });
+ const { ingestionId } = (await manager.createProviderIngestionRecord(
+ 'testProvider',
+ ))!;
+
+ const markId1 = uuid();
+ await manager.createMark({
+ record: {
+ id: markId1,
+ ingestion_id: ingestionId,
+ sequence: 1,
+ cursor: { data: 1 },
+ },
+ });
+
+ const makeEntity = (name: string): DeferredEntity => ({
+ entity: {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'Component',
+ metadata: { namespace: 'default', name },
+ },
+ });
+
+ // First batch: create 3 entities
+ await manager.createMarkEntities(markId1, [
+ makeEntity('a'),
+ makeEntity('b'),
+ makeEntity('c'),
+ ]);
+
+ const rows1 = await knex('ingestion_mark_entities').select('ref');
+ expect(rows1).toHaveLength(3);
+
+ // Second batch with overlap: b and c already exist, d is new.
+ // Existing refs should be updated to the new mark, new refs inserted.
+ const markId2 = uuid();
+ await manager.createMark({
+ record: {
+ id: markId2,
+ ingestion_id: ingestionId,
+ sequence: 2,
+ cursor: { data: 2 },
+ },
+ });
+
+ await manager.createMarkEntities(markId2, [
+ makeEntity('b'),
+ makeEntity('c'),
+ makeEntity('d'),
+ ]);
+
+ const rows2 = await knex('ingestion_mark_entities')
+ .select('ref', 'ingestion_mark_id')
+ .orderBy('ref');
+ expect(rows2).toHaveLength(4);
+
+ // a stays on markId1, b and c moved to markId2, d is new on markId2
+ expect(
+ rows2.find(r => r.ref === 'component:default/a')?.ingestion_mark_id,
+ ).toBe(markId1);
+ expect(
+ rows2.find(r => r.ref === 'component:default/b')?.ingestion_mark_id,
+ ).toBe(markId2);
+ expect(
+ rows2.find(r => r.ref === 'component:default/c')?.ingestion_mark_id,
+ ).toBe(markId2);
+ expect(
+ rows2.find(r => r.ref === 'component:default/d')?.ingestion_mark_id,
+ ).toBe(markId2);
+ });
+
+ it('deleteEntityRecordsByRef removes matching refs', async () => {
+ const knex = await databases.init(databaseId);
+ await knex.migrate.latest({ directory: migrationsDir });
+
+ const manager = new IncrementalIngestionDatabaseManager({ client: knex });
+ const { ingestionId } = (await manager.createProviderIngestionRecord(
+ 'testProvider',
+ ))!;
+
+ const markId = uuid();
+ await manager.createMark({
+ record: {
+ id: markId,
+ ingestion_id: ingestionId,
+ sequence: 1,
+ cursor: { data: 1 },
+ },
+ });
+
+ const makeEntity = (name: string): DeferredEntity => ({
+ entity: {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'Component',
+ metadata: { namespace: 'default', name },
+ },
+ });
+
+ await manager.createMarkEntities(markId, [
+ makeEntity('x'),
+ makeEntity('y'),
+ makeEntity('z'),
+ ]);
+
+ // Delete two of the three
+ await manager.deleteEntityRecordsByRef([
+ { entityRef: 'component:default/x' },
+ { entityRef: 'component:default/z' },
+ ]);
+
+ const remaining = await knex('ingestion_mark_entities').select('ref');
+ expect(remaining).toHaveLength(1);
+ expect(remaining[0].ref).toBe('component:default/y');
+ });
+ },
+);
diff --git a/plugins/catalog-backend-module-incremental-ingestion/src/database/IncrementalIngestionDatabaseManager.ts b/plugins/catalog-backend-module-incremental-ingestion/src/database/IncrementalIngestionDatabaseManager.ts
index d5f6487c5d..4a290d23a9 100644
--- a/plugins/catalog-backend-module-incremental-ingestion/src/database/IncrementalIngestionDatabaseManager.ts
+++ b/plugins/catalog-backend-module-incremental-ingestion/src/database/IncrementalIngestionDatabaseManager.ts
@@ -18,7 +18,7 @@ import { Knex } from 'knex';
import type { DeferredEntity } from '@backstage/plugin-catalog-node';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { Duration } from 'luxon';
-import { v4 } from 'uuid';
+import { randomUUID as v4 } from 'node:crypto';
import {
IngestionRecord,
IngestionRecordUpdate,
@@ -34,6 +34,17 @@ export class IncrementalIngestionDatabaseManager {
this.client = options.client;
}
+ private whereInArray(column: string, values: string[]) {
+ const isPg = this.client.client.config.client === 'pg';
+ return (qb: Knex.QueryBuilder) => {
+ if (isPg) {
+ qb.whereRaw('?? = ANY(?)', [column, values]);
+ } else {
+ qb.whereIn(column, values);
+ }
+ };
+ }
+
/**
* Performs an update to the ingestion record with matching `id`.
* @param options - IngestionRecordUpdate
@@ -76,24 +87,25 @@ export class IncrementalIngestionDatabaseManager {
tx: Knex.Transaction,
ids: { id: string }[],
) {
- const chunks: { id: string }[][] = [];
- for (let i = 0; i < ids.length; i += 100) {
- const chunk = ids.slice(i, i + 100);
- chunks.push(chunk);
+ if (ids.length === 0) {
+ return 0;
+ }
+
+ const allIds = ids.map(entry => entry.id);
+
+ if (this.client.client.config.client === 'pg') {
+ return await tx('ingestion_mark_entities')
+ .delete()
+ .modify(this.whereInArray('id', allIds));
}
let deleted = 0;
-
- for (const chunk of chunks) {
- const chunkDeleted = await tx('ingestion_mark_entities')
+ for (let i = 0; i < allIds.length; i += 100) {
+ const chunk = allIds.slice(i, i + 100);
+ deleted += await tx('ingestion_mark_entities')
.delete()
- .whereIn(
- 'id',
- chunk.map(entry => entry.id),
- );
- deleted += chunkDeleted;
+ .whereIn('id', chunk);
}
-
return deleted;
}
@@ -276,7 +288,9 @@ export class IncrementalIngestionDatabaseManager {
async deleteEntityRecordsByRef(entities: { entityRef: string }[]) {
const refs = entities.map(e => e.entityRef);
await this.client.transaction(async tx => {
- await tx('ingestion_mark_entities').delete().whereIn('ref', refs);
+ await tx('ingestion_mark_entities')
+ .delete()
+ .modify(this.whereInArray('ref', refs));
});
}
@@ -603,16 +617,18 @@ export class IncrementalIngestionDatabaseManager {
const existingRefsArray = (
await tx<{ ref: string }>('ingestion_mark_entities')
.select('ref')
- .whereIn('ref', refs)
- ).map(e => e.ref);
+ .modify(this.whereInArray('ref', refs))
+ ).map((e: { ref: string }) => e.ref);
const existingRefsSet = new Set(existingRefsArray);
const newRefs = refs.filter(e => !existingRefsSet.has(e));
- await tx('ingestion_mark_entities')
- .update('ingestion_mark_id', markId)
- .whereIn('ref', existingRefsArray);
+ if (existingRefsArray.length > 0) {
+ await tx('ingestion_mark_entities')
+ .update('ingestion_mark_id', markId)
+ .modify(this.whereInArray('ref', existingRefsArray));
+ }
if (newRefs.length > 0) {
await tx('ingestion_mark_entities').insert(
diff --git a/plugins/catalog-backend-module-incremental-ingestion/src/engine/IncrementalIngestionEngine.ts b/plugins/catalog-backend-module-incremental-ingestion/src/engine/IncrementalIngestionEngine.ts
index 2cfa5904f4..0907648be0 100644
--- a/plugins/catalog-backend-module-incremental-ingestion/src/engine/IncrementalIngestionEngine.ts
+++ b/plugins/catalog-backend-module-incremental-ingestion/src/engine/IncrementalIngestionEngine.ts
@@ -20,7 +20,7 @@ import { IterationEngine, IterationEngineOptions } from '../types';
import { IncrementalIngestionDatabaseManager } from '../database/IncrementalIngestionDatabaseManager';
import { performance } from 'node:perf_hooks';
import { Duration } from 'luxon';
-import { v4 } from 'uuid';
+import { randomUUID as v4 } from 'node:crypto';
import { stringifyError, toError } from '@backstage/errors';
import { EventParams } from '@backstage/plugin-events-node';
import { HumanDuration } from '@backstage/types';
diff --git a/plugins/catalog-backend-module-incremental-ingestion/src/module/WrapperProviders.test.ts b/plugins/catalog-backend-module-incremental-ingestion/src/module/WrapperProviders.test.ts
index f4b40ffff4..dfb443f0dc 100644
--- a/plugins/catalog-backend-module-incremental-ingestion/src/module/WrapperProviders.test.ts
+++ b/plugins/catalog-backend-module-incremental-ingestion/src/module/WrapperProviders.test.ts
@@ -23,24 +23,25 @@ import { WrapperProviders } from './WrapperProviders';
jest.setTimeout(60_000);
-describe('WrapperProviders', () => {
- const applyDatabaseMigrations = jest.fn();
- const databases = TestDatabases.create({
- ids: ['POSTGRES_18', 'POSTGRES_14', 'SQLITE_3', 'MYSQL_8'],
- });
- const config = new ConfigReader({});
- const logger = mockServices.logger.mock();
- const scheduler = {
- scheduleTask: jest.fn(),
- };
+const databases = TestDatabases.create({
+ ids: ['POSTGRES_18', 'POSTGRES_14', 'SQLITE_3', 'MYSQL_8'],
+});
- beforeEach(() => {
- jest.clearAllMocks();
- });
+describe.each(databases.eachSupportedId())(
+ 'WrapperProviders, %p',
+ databaseId => {
+ const applyDatabaseMigrations = jest.fn();
+ const config = new ConfigReader({});
+ const logger = mockServices.logger.mock();
+ const scheduler = {
+ scheduleTask: jest.fn(),
+ };
- it.each(databases.eachSupportedId())(
- 'should initialize the providers in order, %p',
- async databaseId => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should initialize the providers in order', async () => {
const client = await databases.init(databaseId);
const provider1: IncrementalEntityProvider = {
@@ -111,6 +112,6 @@ describe('WrapperProviders', () => {
id: 'provider2',
}),
);
- },
- );
-});
+ });
+ },
+);
diff --git a/plugins/catalog-backend-module-ldap/CHANGELOG.md b/plugins/catalog-backend-module-ldap/CHANGELOG.md
index 2a733cc24e..cb887fc6a0 100644
--- a/plugins/catalog-backend-module-ldap/CHANGELOG.md
+++ b/plugins/catalog-backend-module-ldap/CHANGELOG.md
@@ -1,5 +1,27 @@
# @backstage/plugin-catalog-backend-module-ldap
+## 0.12.5
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/config@1.3.8
+ - @backstage/plugin-catalog-common@1.1.10
+
+## 0.12.5-next.1
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.8.1-next.1
+ - @backstage/plugin-catalog-node@2.2.1-next.1
+
## 0.12.5-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-ldap/package.json b/plugins/catalog-backend-module-ldap/package.json
index b634b020ec..ca6bdf8152 100644
--- a/plugins/catalog-backend-module-ldap/package.json
+++ b/plugins/catalog-backend-module-ldap/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-ldap",
- "version": "0.12.5-next.0",
+ "version": "0.12.5",
"description": "A Backstage catalog backend module that helps integrate towards LDAP",
"backstage": {
"role": "backend-plugin-module",
@@ -46,8 +46,7 @@
"@backstage/plugin-catalog-node": "workspace:^",
"@backstage/types": "workspace:^",
"ldapts": "^8.0.6",
- "lodash": "^4.17.21",
- "uuid": "^11.0.0"
+ "lodash": "^4.17.21"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
diff --git a/plugins/catalog-backend-module-ldap/src/ldap/client.ts b/plugins/catalog-backend-module-ldap/src/ldap/client.ts
index 49b6d56238..6285394b13 100644
--- a/plugins/catalog-backend-module-ldap/src/ldap/client.ts
+++ b/plugins/catalog-backend-module-ldap/src/ldap/client.ts
@@ -179,7 +179,7 @@ export class LdapClient {
}
}
- // Check a shema for Google-specific patterns
+ // Check a schema for Google-specific patterns
private checkGoogleSchema(rootDSE: Entry): boolean {
try {
const objectClasses = this.parseSchemaValues(rootDSE.objectClasses);
diff --git a/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts b/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts
index 9e840f08ba..5c16e02d02 100644
--- a/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts
+++ b/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts
@@ -137,7 +137,7 @@ describe('readLdapUsers', () => {
it('override default vendor configs', async () => {
const searchEntries: Entry[] = [
{
- dn: 'dn-vaule',
+ dn: 'dn-value',
uid: 'uid-value',
description: 'description-value',
cn: 'cn-value',
diff --git a/plugins/catalog-backend-module-ldap/src/processors/LdapOrgEntityProvider.ts b/plugins/catalog-backend-module-ldap/src/processors/LdapOrgEntityProvider.ts
index 5edd3b06d0..ccfb5d17d6 100644
--- a/plugins/catalog-backend-module-ldap/src/processors/LdapOrgEntityProvider.ts
+++ b/plugins/catalog-backend-module-ldap/src/processors/LdapOrgEntityProvider.ts
@@ -25,7 +25,7 @@ import {
EntityProviderConnection,
} from '@backstage/plugin-catalog-node';
import { merge } from 'lodash';
-import * as uuid from 'uuid';
+import { randomUUID } from 'node:crypto';
import {
GroupTransformer,
LdapClient,
@@ -324,7 +324,7 @@ export class LdapOrgEntityProvider implements EntityProvider {
const logger = this.options.logger.child({
class: LdapOrgEntityProvider.prototype.constructor.name,
taskId: id,
- taskInstanceId: uuid.v4(),
+ taskInstanceId: randomUUID(),
});
try {
diff --git a/plugins/catalog-backend-module-logs/CHANGELOG.md b/plugins/catalog-backend-module-logs/CHANGELOG.md
index de76c82602..a7b1f34539 100644
--- a/plugins/catalog-backend-module-logs/CHANGELOG.md
+++ b/plugins/catalog-backend-module-logs/CHANGELOG.md
@@ -1,5 +1,22 @@
# @backstage/plugin-catalog-backend-module-logs
+## 0.1.22
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-backend@3.7.0
+ - @backstage/plugin-events-node@0.4.22
+
+## 0.1.22-next.1
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1-next.1
+ - @backstage/plugin-catalog-backend@3.7.0-next.2
+
## 0.1.22-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-logs/package.json b/plugins/catalog-backend-module-logs/package.json
index e0c4f3067e..fb46329596 100644
--- a/plugins/catalog-backend-module-logs/package.json
+++ b/plugins/catalog-backend-module-logs/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-logs",
- "version": "0.1.22-next.0",
+ "version": "0.1.22",
"description": "A module that subscribes to catalog related events and logs them.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/catalog-backend-module-msgraph-incremental/.eslintrc.js b/plugins/catalog-backend-module-msgraph-incremental/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/catalog-backend-module-msgraph-incremental/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/catalog-backend-module-msgraph-incremental/CHANGELOG.md b/plugins/catalog-backend-module-msgraph-incremental/CHANGELOG.md
new file mode 100644
index 0000000000..cd2f1a13f6
--- /dev/null
+++ b/plugins/catalog-backend-module-msgraph-incremental/CHANGELOG.md
@@ -0,0 +1,24 @@
+# @backstage/plugin-catalog-backend-module-msgraph-incremental
+
+## 0.1.0
+
+### Minor Changes
+
+- 2bd0450: **BREAKING**: Disabled user accounts are now filtered out by default. The provider automatically applies an `accountEnabled eq true` filter, combining it with any custom `user.filter` you provide. If you previously included `accountEnabled eq true` in your user filter, it is safe to remove it, but leaving it in will not cause any issues.
+- f1279ea: Introduces a cursor-based incremental ingestion provider for Microsoft Graph that processes users and groups one page at a time. Unlike `MicrosoftGraphOrgEntityProvider`, this module never holds the full dataset in memory â each burst processes a single page (up to 999 users or 100 groups). The `@odata.nextLink` cursor is persisted so a pod restart resumes from the last completed page rather than starting over.
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/plugin-catalog-backend-module-incremental-ingestion@0.7.12
+ - @backstage/plugin-catalog-backend-module-msgraph@0.10.0
+ - @backstage/config@1.3.8
+
+## 0.1.0-next.0
+
+### Minor Changes
+
+- f1279ea: Introduces a cursor-based incremental ingestion provider for Microsoft Graph that processes users and groups one page at a time. Unlike `MicrosoftGraphOrgEntityProvider`, this module never holds the full dataset in memory â each burst processes a single page (up to 999 users or 100 groups). The `@odata.nextLink` cursor is persisted so a pod restart resumes from the last completed page rather than starting over.
diff --git a/plugins/catalog-backend-module-msgraph-incremental/README.md b/plugins/catalog-backend-module-msgraph-incremental/README.md
new file mode 100644
index 0000000000..79b3cdbce4
--- /dev/null
+++ b/plugins/catalog-backend-module-msgraph-incremental/README.md
@@ -0,0 +1,75 @@
+# @backstage/plugin-catalog-backend-module-msgraph-incremental
+
+This module incrementally ingests **users** and **groups** from Microsoft Graph
+into the Backstage catalog, one page at a time. It is suitable for large Azure
+AD tenants where holding the full dataset in memory at once is not practical.
+
+## Features
+
+- **Cursor-based resumption** â the `@odata.nextLink` URL is persisted as the
+ cursor, so a pod restart during ingestion resumes from the last completed page
+ rather than starting over.
+- **Memory-efficient** â each burst processes a single page (up to 999 users
+ or 100 groups), keeping memory usage flat regardless of tenant size.
+- **Photo support** â user profile photos are fetched with a gated pre-check to
+ avoid unnecessary API calls for users without photos.
+- **Transformer extension point** â user, group, organization, and provider
+ config transformers can be customised via the
+ `microsoftGraphIncrementalEntityProviderTransformExtensionPoint`.
+
+## Prerequisites
+
+This module requires the incremental ingestion framework to be installed:
+
+```ts
+backend.add(
+ import('@backstage/plugin-catalog-backend-module-incremental-ingestion'),
+);
+```
+
+## Installation
+
+```ts
+// packages/backend/src/index.ts
+backend.add(
+ import('@backstage/plugin-catalog-backend-module-incremental-ingestion'),
+);
+backend.add(
+ import('@backstage/plugin-catalog-backend-module-msgraph-incremental'),
+);
+```
+
+## Configuration
+
+Uses the same `catalog.providers.microsoftGraphOrg` configuration as
+`@backstage/plugin-catalog-backend-module-msgraph`. See that package's
+documentation for full config reference.
+
+```yaml
+catalog:
+ providers:
+ microsoftGraphOrg:
+ default:
+ tenantId: ${AZURE_TENANT_ID}
+ clientId: ${AZURE_CLIENT_ID}
+ clientSecret: ${AZURE_CLIENT_SECRET}
+ queryMode: advanced
+ user:
+ # accountEnabled eq true is applied by default; add extra filters here
+ filter: "userType eq 'member'"
+ group:
+ filter: 'securityEnabled eq true'
+ schedule:
+ frequency: { hours: 12 }
+ timeout: { hours: 4 }
+```
+
+## Differences from `MicrosoftGraphOrgEntityProvider`
+
+| | `MicrosoftGraphOrgEntityProvider` | This module |
+| -------------------------- | --------------------------------- | ------------------- |
+| Memory usage | Full dataset in RAM | One page at a time |
+| Resume on restart | Starts from scratch | Resumes from cursor |
+| `userGroupMember*` options | Supported | Not supported |
+| `groupIncludeSubGroups` | Supported | Not supported |
+| Suitable for large tenants | No | Yes |
diff --git a/plugins/catalog-backend-module-msgraph-incremental/catalog-info.yaml b/plugins/catalog-backend-module-msgraph-incremental/catalog-info.yaml
new file mode 100644
index 0000000000..a4d80f958b
--- /dev/null
+++ b/plugins/catalog-backend-module-msgraph-incremental/catalog-info.yaml
@@ -0,0 +1,12 @@
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: backstage-plugin-catalog-backend-module-msgraph-incremental
+ title: '@backstage/plugin-catalog-backend-module-msgraph-incremental'
+ description: >-
+ A Backstage catalog backend module that incrementally ingests users and
+ groups from Microsoft Graph, one page at a time
+spec:
+ lifecycle: experimental
+ type: backstage-backend-plugin-module
+ owner: catalog-maintainers
diff --git a/plugins/catalog-backend-module-msgraph-incremental/package.json b/plugins/catalog-backend-module-msgraph-incremental/package.json
new file mode 100644
index 0000000000..e84a57f403
--- /dev/null
+++ b/plugins/catalog-backend-module-msgraph-incremental/package.json
@@ -0,0 +1,67 @@
+{
+ "name": "@backstage/plugin-catalog-backend-module-msgraph-incremental",
+ "version": "0.1.0",
+ "description": "A Backstage catalog backend module that incrementally ingests users and groups from Microsoft Graph",
+ "backstage": {
+ "role": "backend-plugin-module",
+ "pluginId": "catalog",
+ "pluginPackage": "@backstage/plugin-catalog-backend"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "keywords": [
+ "backstage"
+ ],
+ "homepage": "https://backstage.io",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/backstage/backstage",
+ "directory": "plugins/catalog-backend-module-msgraph-incremental"
+ },
+ "license": "Apache-2.0",
+ "exports": {
+ ".": "./src/index.ts",
+ "./alpha": "./src/alpha.ts",
+ "./package.json": "./package.json"
+ },
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "typesVersions": {
+ "*": {
+ "alpha": [
+ "src/alpha.ts"
+ ],
+ "package.json": [
+ "package.json"
+ ]
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "backstage-cli package build",
+ "clean": "backstage-cli package clean",
+ "lint": "backstage-cli package lint",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack",
+ "start": "backstage-cli package start",
+ "test": "backstage-cli package test"
+ },
+ "dependencies": {
+ "@backstage/backend-plugin-api": "workspace:^",
+ "@backstage/catalog-model": "workspace:^",
+ "@backstage/config": "workspace:^",
+ "@backstage/plugin-catalog-backend-module-incremental-ingestion": "workspace:^",
+ "@backstage/plugin-catalog-backend-module-msgraph": "workspace:^",
+ "@backstage/plugin-catalog-node": "workspace:^",
+ "@backstage/types": "workspace:^",
+ "@microsoft/microsoft-graph-types": "^2.6.0",
+ "p-limit": "^3.0.2"
+ },
+ "devDependencies": {
+ "@backstage/backend-test-utils": "workspace:^",
+ "@backstage/cli": "workspace:^"
+ }
+}
diff --git a/plugins/catalog-backend-module-msgraph-incremental/report-alpha.api.md b/plugins/catalog-backend-module-msgraph-incremental/report-alpha.api.md
new file mode 100644
index 0000000000..88b085c8c5
--- /dev/null
+++ b/plugins/catalog-backend-module-msgraph-incremental/report-alpha.api.md
@@ -0,0 +1,7 @@
+## API Report File for "@backstage/plugin-catalog-backend-module-msgraph-incremental"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+// (No @packageDocumentation comment for this package)
+```
diff --git a/plugins/catalog-backend-module-msgraph-incremental/report.api.md b/plugins/catalog-backend-module-msgraph-incremental/report.api.md
new file mode 100644
index 0000000000..2f1e32717c
--- /dev/null
+++ b/plugins/catalog-backend-module-msgraph-incremental/report.api.md
@@ -0,0 +1,96 @@
+## API Report File for "@backstage/plugin-catalog-backend-module-msgraph-incremental"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+import { BackendFeature } from '@backstage/backend-plugin-api';
+import { Config } from '@backstage/config';
+import { EntityIteratorResult } from '@backstage/plugin-catalog-backend-module-incremental-ingestion';
+import { ExtensionPoint } from '@backstage/backend-plugin-api';
+import { GroupTransformer } from '@backstage/plugin-catalog-backend-module-msgraph';
+import { IncrementalEntityProvider } from '@backstage/plugin-catalog-backend-module-incremental-ingestion';
+import { LoggerService } from '@backstage/backend-plugin-api';
+import { MicrosoftGraphClient } from '@backstage/plugin-catalog-backend-module-msgraph';
+import { MicrosoftGraphProviderConfig } from '@backstage/plugin-catalog-backend-module-msgraph';
+import { OrganizationTransformer } from '@backstage/plugin-catalog-backend-module-msgraph';
+import { ProviderConfigTransformer } from '@backstage/plugin-catalog-backend-module-msgraph';
+import { UserTransformer } from '@backstage/plugin-catalog-backend-module-msgraph';
+
+// @public
+const catalogModuleMicrosoftGraphIncrementalEntityProvider: BackendFeature;
+export { catalogModuleMicrosoftGraphIncrementalEntityProvider };
+export default catalogModuleMicrosoftGraphIncrementalEntityProvider;
+
+// @public
+export class MicrosoftGraphIncrementalEntityProvider
+ implements IncrementalEntityProvider
+{
+ constructor(options: {
+ id: string;
+ provider: MicrosoftGraphProviderConfig;
+ logger: LoggerService;
+ userTransformer?: UserTransformer;
+ groupTransformer?: GroupTransformer;
+ organizationTransformer?: OrganizationTransformer;
+ providerConfigTransformer?: ProviderConfigTransformer;
+ });
+ around(burst: (context: MSGraphContext) => Promise): Promise;
+ static fromConfig(
+ configRoot: Config,
+ options: MicrosoftGraphIncrementalEntityProviderOptions,
+ ): MicrosoftGraphIncrementalEntityProvider[];
+ getProviderName(): string;
+ next(
+ input: MSGraphContext,
+ cursor?: MSGraphCursor,
+ ): Promise>;
+}
+
+// @public
+export interface MicrosoftGraphIncrementalEntityProviderOptions {
+ groupTransformer?: GroupTransformer | Record;
+ logger: LoggerService;
+ organizationTransformer?:
+ | OrganizationTransformer
+ | Record;
+ providerConfigTransformer?:
+ | ProviderConfigTransformer
+ | Record;
+ userTransformer?: UserTransformer | Record;
+}
+
+// @public
+export const microsoftGraphIncrementalEntityProviderTransformExtensionPoint: ExtensionPoint;
+
+// @public
+export interface MicrosoftGraphIncrementalEntityProviderTransformsExtensionPoint {
+ setGroupTransformer(
+ transformer: GroupTransformer | Record,
+ ): void;
+ setOrganizationTransformer(
+ transformer:
+ | OrganizationTransformer
+ | Record,
+ ): void;
+ setProviderConfigTransformer(
+ transformer:
+ | ProviderConfigTransformer
+ | Record,
+ ): void;
+ setUserTransformer(
+ transformer: UserTransformer | Record,
+ ): void;
+}
+
+// @public
+export type MSGraphContext = {
+ client: MicrosoftGraphClient;
+ provider: MicrosoftGraphProviderConfig;
+};
+
+// @public
+export type MSGraphCursor = {
+ phase: 'users' | 'groups';
+ nextLink?: string;
+};
+```
diff --git a/plugins/catalog-backend-module-msgraph-incremental/src/MicrosoftGraphIncrementalEntityProvider.test.ts b/plugins/catalog-backend-module-msgraph-incremental/src/MicrosoftGraphIncrementalEntityProvider.test.ts
new file mode 100644
index 0000000000..faf8d1f4a7
--- /dev/null
+++ b/plugins/catalog-backend-module-msgraph-incremental/src/MicrosoftGraphIncrementalEntityProvider.test.ts
@@ -0,0 +1,1048 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ ANNOTATION_LOCATION,
+ ANNOTATION_ORIGIN_LOCATION,
+} from '@backstage/catalog-model';
+import { ConfigReader } from '@backstage/config';
+import {
+ MicrosoftGraphClient,
+ MICROSOFT_GRAPH_GROUP_ID_ANNOTATION,
+ MICROSOFT_GRAPH_USER_ID_ANNOTATION,
+} from '@backstage/plugin-catalog-backend-module-msgraph';
+import { mockServices } from '@backstage/backend-test-utils';
+import {
+ MicrosoftGraphIncrementalEntityProvider,
+ MSGraphContext,
+ MSGraphCursor,
+} from './MicrosoftGraphIncrementalEntityProvider';
+import { getUserPhotoGated, requestOnePage } from './clientHelpers';
+
+jest.mock('./clientHelpers', () => ({
+ requestOnePage: jest.fn(),
+ getUserPhotoGated: jest.fn(),
+}));
+
+const mockRequestOnePage = requestOnePage as jest.MockedFunction<
+ typeof requestOnePage
+>;
+const mockGetUserPhotoGated = getUserPhotoGated as jest.MockedFunction<
+ typeof getUserPhotoGated
+>;
+
+const mockClient = {
+ getOrganization: jest.fn(),
+ getGroupMembers: jest.fn(),
+} as unknown as jest.Mocked;
+
+const logger = mockServices.logger.mock();
+
+const baseProviderConfig = {
+ id: 'default',
+ target: 'https://graph.microsoft.com/v1.0',
+ tenantId: 'tenant-id',
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+};
+
+function makeContext(overrides?: Partial): MSGraphContext {
+ return {
+ client: mockClient,
+ provider: baseProviderConfig as any,
+ ...overrides,
+ };
+}
+
+async function* asyncYield(...items: T[]): AsyncIterable {
+ for (const item of items) yield item;
+}
+
+describe('MicrosoftGraphIncrementalEntityProvider', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(MicrosoftGraphClient, 'create')
+ .mockReturnValue(mockClient as unknown as MicrosoftGraphClient);
+ });
+
+ afterEach(() => jest.resetAllMocks());
+
+ describe('getProviderName', () => {
+ it('returns namespaced name using the provider id', () => {
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'my-tenant',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+ expect(provider.getProviderName()).toBe(
+ 'MicrosoftGraphIncrementalEntityProvider:my-tenant',
+ );
+ });
+ });
+
+ describe('fromConfig', () => {
+ it('creates one provider per provider config entry', () => {
+ const config = new ConfigReader({
+ catalog: {
+ providers: {
+ microsoftGraphOrg: {
+ tenantA: { tenantId: 'a', clientId: 'c', clientSecret: 's' },
+ tenantB: { tenantId: 'b', clientId: 'c', clientSecret: 's' },
+ },
+ },
+ },
+ });
+
+ const providers = MicrosoftGraphIncrementalEntityProvider.fromConfig(
+ config,
+ { logger },
+ );
+
+ expect(providers).toHaveLength(2);
+ expect(providers[0].getProviderName()).toBe(
+ 'MicrosoftGraphIncrementalEntityProvider:tenantA',
+ );
+ expect(providers[1].getProviderName()).toBe(
+ 'MicrosoftGraphIncrementalEntityProvider:tenantB',
+ );
+ });
+
+ it('assigns per-provider transformers when a Record is provided', () => {
+ const config = new ConfigReader({
+ catalog: {
+ providers: {
+ microsoftGraphOrg: {
+ p1: { tenantId: 't', clientId: 'c', clientSecret: 's' },
+ p2: { tenantId: 't', clientId: 'c', clientSecret: 's' },
+ },
+ },
+ },
+ });
+ const transformerA = jest.fn();
+ const transformerB = jest.fn();
+
+ const providers = MicrosoftGraphIncrementalEntityProvider.fromConfig(
+ config,
+ {
+ logger,
+ userTransformer: { p1: transformerA, p2: transformerB },
+ },
+ );
+
+ expect(providers).toHaveLength(2);
+ });
+ });
+
+ describe('around', () => {
+ it('creates the client and passes it to the burst function', async () => {
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+
+ let capturedContext: MSGraphContext | undefined;
+ await provider.around(async ctx => {
+ capturedContext = ctx;
+ });
+
+ expect(MicrosoftGraphClient.create).toHaveBeenCalledWith(
+ baseProviderConfig,
+ );
+ expect(capturedContext?.client).toBe(mockClient);
+ });
+
+ it('applies providerConfigTransformer before creating the client', async () => {
+ const transformedConfig = {
+ ...baseProviderConfig,
+ clientSecret: 'rotated-secret',
+ };
+ const transformer = jest.fn().mockResolvedValue(transformedConfig);
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ providerConfigTransformer: transformer,
+ });
+
+ await provider.around(async () => {});
+
+ expect(transformer).toHaveBeenCalledWith(baseProviderConfig);
+ expect(MicrosoftGraphClient.create).toHaveBeenCalledWith(
+ transformedConfig,
+ );
+ });
+
+ it('warns when unsupported userGroupMember options are set', async () => {
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: {
+ ...baseProviderConfig,
+ userGroupMemberFilter: 'some-filter',
+ } as any,
+ logger,
+ });
+
+ await provider.around(async () => {});
+
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'userGroupMemberFilter/Search/Path are not supported',
+ ),
+ );
+ });
+
+ it('warns when groupIncludeSubGroups is set', async () => {
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: {
+ ...baseProviderConfig,
+ groupIncludeSubGroups: true,
+ } as any,
+ logger,
+ });
+
+ await provider.around(async () => {});
+
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('groupIncludeSubGroups is not supported'),
+ );
+ });
+ });
+
+ describe('next â users phase', () => {
+ it('starts in users phase when cursor is undefined', async () => {
+ mockRequestOnePage.mockResolvedValue({
+ items: [],
+ nextLink: undefined,
+ });
+ (mockClient.getOrganization as jest.Mock).mockResolvedValue({
+ id: 'org-id',
+ displayName: 'My Org',
+ });
+ (mockClient.getGroupMembers as jest.Mock).mockReturnValue(asyncYield());
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+
+ const result = await provider.next(makeContext());
+
+ expect(mockRequestOnePage).toHaveBeenCalledWith(
+ mockClient,
+ 'users',
+ expect.objectContaining({
+ query: expect.objectContaining({ top: 999 }), // USER_PAGE_SIZE
+ }),
+ );
+ // No users â advances straight to groups phase
+ expect(result.done).toBe(false);
+ expect((result.cursor as MSGraphCursor).phase).toBe('groups');
+ });
+
+ it('emits User entities with location annotations', async () => {
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [
+ {
+ id: 'user-id-1',
+ displayName: 'Alice',
+ mail: 'alice@example.com',
+ userPrincipalName: 'alice@example.com',
+ },
+ ],
+ nextLink: 'https://graph.microsoft.com/v1.0/users?$skiptoken=page2',
+ });
+ mockGetUserPhotoGated.mockResolvedValue(undefined);
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+
+ const result = await provider.next(makeContext());
+
+ expect(result.done).toBe(false);
+ expect(result.entities!).toHaveLength(1);
+
+ const entity = result.entities![0].entity;
+ expect(entity.kind).toBe('User');
+ expect(
+ entity.metadata.annotations?.[MICROSOFT_GRAPH_USER_ID_ANNOTATION],
+ ).toBe('user-id-1');
+ expect(entity.metadata.annotations?.[ANNOTATION_LOCATION]).toBe(
+ 'msgraph:default/user-id-1',
+ );
+ expect(entity.metadata.annotations?.[ANNOTATION_ORIGIN_LOCATION]).toBe(
+ 'msgraph:default/user-id-1',
+ );
+ expect(result.entities![0].locationKey).toBe(
+ 'msgraph-org-provider:default',
+ );
+ });
+
+ it('returns users cursor with nextLink when more pages remain', async () => {
+ const nextLink =
+ 'https://graph.microsoft.com/v1.0/users?$skiptoken=page2';
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [
+ { id: 'u1', displayName: 'U1', userPrincipalName: 'u1@example.com' },
+ ],
+ nextLink,
+ });
+ mockGetUserPhotoGated.mockResolvedValue(undefined);
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+
+ const result = await provider.next(makeContext());
+
+ expect(result.done).toBe(false);
+ expect((result.cursor as MSGraphCursor).phase).toBe('users');
+ expect((result.cursor as MSGraphCursor).nextLink).toBe(nextLink);
+ });
+
+ it('transitions to groups phase when last users page has no nextLink', async () => {
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [],
+ nextLink: undefined,
+ });
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+
+ const result = await provider.next(makeContext());
+
+ expect(result.done).toBe(false);
+ expect((result.cursor as MSGraphCursor).phase).toBe('groups');
+ expect((result.cursor as MSGraphCursor).nextLink).toBeUndefined();
+ });
+
+ it('skips users where the transformer returns undefined', async () => {
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [{ id: 'u-no-name', userPrincipalName: '' }],
+ nextLink: undefined,
+ });
+ mockGetUserPhotoGated.mockResolvedValue(undefined);
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ userTransformer: async () => undefined,
+ });
+
+ const result = await provider.next(makeContext());
+
+ expect(result.entities!).toHaveLength(0);
+ });
+
+ it('truncates entity names longer than 63 characters', async () => {
+ const longUPN = `${'a'.repeat(64)}@example.com`;
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [{ id: 'u1', displayName: 'Test', userPrincipalName: longUPN }],
+ nextLink: undefined,
+ });
+ mockGetUserPhotoGated.mockResolvedValue(undefined);
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+
+ const result = await provider.next(makeContext());
+
+ // Entity may be emitted or skipped depending on transformer handling of the UPN;
+ // what matters is that no name exceeds 63 characters.
+ for (const { entity } of result.entities!) {
+ expect(entity.metadata.name.length).toBeLessThanOrEqual(63);
+ }
+ });
+
+ it('skips photo loading when loadUserPhotos is false', async () => {
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [
+ {
+ id: 'u1',
+ displayName: 'Alice',
+ userPrincipalName: 'alice@example.com',
+ },
+ ],
+ nextLink: undefined,
+ });
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: { ...baseProviderConfig, loadUserPhotos: false } as any,
+ logger,
+ });
+
+ await provider.next(
+ makeContext({
+ provider: { ...baseProviderConfig, loadUserPhotos: false } as any,
+ }),
+ );
+
+ expect(mockGetUserPhotoGated).not.toHaveBeenCalled();
+ });
+
+ it('attempts photo loading when loadUserPhotos is not set', async () => {
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [
+ {
+ id: 'u1',
+ displayName: 'Alice',
+ userPrincipalName: 'alice@example.com',
+ },
+ ],
+ nextLink: undefined,
+ });
+ mockGetUserPhotoGated.mockResolvedValue(undefined);
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+
+ await provider.next(makeContext());
+
+ expect(mockGetUserPhotoGated).toHaveBeenCalledWith(mockClient, 'u1', 120);
+ });
+
+ it('skips photo fetch when user has no id to avoid requesting users/undefined/photo', async () => {
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [
+ {
+ // No id field â Graph can theoretically omit it
+ displayName: 'No-ID User',
+ userPrincipalName: 'noid@example.com',
+ },
+ ],
+ nextLink: undefined,
+ });
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+
+ await provider.next(makeContext());
+
+ expect(mockGetUserPhotoGated).not.toHaveBeenCalled();
+ });
+
+ it('continues processing remaining users when a photo load fails and logs the error', async () => {
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [
+ {
+ id: 'u1',
+ displayName: 'Alice',
+ userPrincipalName: 'alice@example.com',
+ },
+ {
+ id: 'u2',
+ displayName: 'Bob',
+ userPrincipalName: 'bob@example.com',
+ },
+ ],
+ nextLink: undefined,
+ });
+ mockGetUserPhotoGated
+ .mockRejectedValueOnce(new Error('Photo service unavailable'))
+ .mockResolvedValueOnce(undefined);
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+
+ const result = await provider.next(makeContext());
+
+ // Both users should still be emitted despite the photo failure
+ expect(result.entities!).toHaveLength(2);
+ expect(logger.debug).toHaveBeenCalledWith(
+ expect.stringContaining('failed to load photo for user u1'),
+ expect.anything(),
+ );
+ });
+ });
+
+ describe('next â groups phase', () => {
+ const groupsCursor: MSGraphCursor = { phase: 'groups' };
+
+ it('emits the tenant root group on the first groups page', async () => {
+ (mockClient.getOrganization as jest.Mock).mockResolvedValue({
+ id: 'org-id',
+ displayName: 'My Org',
+ });
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [],
+ nextLink: undefined,
+ });
+ (mockClient.getGroupMembers as jest.Mock).mockReturnValue(asyncYield());
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+
+ const result = await provider.next(makeContext(), groupsCursor);
+
+ // Root group is emitted (org entity kind=Group, type=root)
+ expect(result.entities!.some(e => e.entity.spec?.type === 'root')).toBe(
+ true,
+ );
+ });
+
+ it('does NOT emit the root group on subsequent pages', async () => {
+ const continuationCursor: MSGraphCursor = {
+ phase: 'groups',
+ nextLink: 'https://graph.microsoft.com/v1.0/groups?$skiptoken=page2',
+ };
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [],
+ nextLink: undefined,
+ });
+ (mockClient.getGroupMembers as jest.Mock).mockReturnValue(asyncYield());
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+
+ await provider.next(makeContext(), continuationCursor);
+
+ expect(mockClient.getOrganization).not.toHaveBeenCalled();
+ });
+
+ it('emits Group entities with location annotations', async () => {
+ (mockClient.getOrganization as jest.Mock).mockResolvedValue({
+ id: 'org-id',
+ displayName: 'My Org',
+ });
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [
+ { id: 'grp-1', displayName: 'Engineering', mail: 'eng@example.com' },
+ ],
+ nextLink: undefined,
+ });
+ (mockClient.getGroupMembers as jest.Mock).mockReturnValue(asyncYield());
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+
+ const result = await provider.next(makeContext(), groupsCursor);
+
+ const groupEntity = result.entities!.find(
+ e =>
+ e.entity.metadata.annotations?.[
+ MICROSOFT_GRAPH_GROUP_ID_ANNOTATION
+ ] === 'grp-1',
+ );
+ expect(groupEntity).toBeDefined();
+ expect(
+ groupEntity!.entity.metadata.annotations?.[ANNOTATION_LOCATION],
+ ).toBe('msgraph:default/grp-1');
+ expect(
+ groupEntity!.entity.metadata.annotations?.[ANNOTATION_ORIGIN_LOCATION],
+ ).toBe('msgraph:default/grp-1');
+ expect(groupEntity!.locationKey).toBe('msgraph-org-provider:default');
+ });
+
+ it('populates spec.members with user refs from getGroupMembers', async () => {
+ (mockClient.getOrganization as jest.Mock).mockResolvedValue({
+ id: 'org-id',
+ displayName: 'My Org',
+ });
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [
+ { id: 'grp-1', displayName: 'Engineering', mail: 'eng@example.com' },
+ ],
+ nextLink: undefined,
+ });
+ (mockClient.getGroupMembers as jest.Mock).mockReturnValue(
+ asyncYield(
+ {
+ '@odata.type': '#microsoft.graph.user',
+ id: 'u1',
+ displayName: 'Alice',
+ userPrincipalName: 'alice@example.com',
+ },
+ {
+ '@odata.type': '#microsoft.graph.user',
+ id: 'u2',
+ displayName: 'Bob',
+ userPrincipalName: 'bob@example.com',
+ },
+ ),
+ );
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+
+ const result = await provider.next(makeContext(), groupsCursor);
+
+ const groupEntity = result.entities!.find(
+ e =>
+ e.entity.metadata.annotations?.[
+ MICROSOFT_GRAPH_GROUP_ID_ANNOTATION
+ ] === 'grp-1',
+ );
+ expect(groupEntity!.entity.spec?.members).toHaveLength(2);
+ expect(groupEntity!.entity.spec?.members).toContain(
+ 'user:default/alice_example.com',
+ );
+ // Verify $select is passed so member objects are never sparse
+ expect(mockClient.getGroupMembers).toHaveBeenCalledWith(
+ 'grp-1',
+ expect.objectContaining({
+ select: expect.arrayContaining([
+ 'id',
+ 'displayName',
+ 'userPrincipalName',
+ ]),
+ }),
+ );
+ });
+
+ it('populates spec.children with nested group refs from getGroupMembers', async () => {
+ (mockClient.getOrganization as jest.Mock).mockResolvedValue({
+ id: 'org-id',
+ displayName: 'My Org',
+ });
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [
+ {
+ id: 'grp-parent',
+ displayName: 'Parent',
+ mail: 'parent@example.com',
+ },
+ ],
+ nextLink: undefined,
+ });
+ (mockClient.getGroupMembers as jest.Mock).mockReturnValue(
+ asyncYield({
+ '@odata.type': '#microsoft.graph.group',
+ id: 'grp-child',
+ displayName: 'Child Group',
+ mail: 'child@example.com',
+ }),
+ );
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+
+ const result = await provider.next(makeContext(), groupsCursor);
+
+ const parentEntity = result.entities!.find(
+ e =>
+ e.entity.metadata.annotations?.[
+ MICROSOFT_GRAPH_GROUP_ID_ANNOTATION
+ ] === 'grp-parent',
+ );
+ expect(parentEntity!.entity.spec?.children).toHaveLength(1);
+ });
+
+ it('omits child group refs when groupFilter is active to avoid dangling references', async () => {
+ const providerWithFilter = {
+ ...baseProviderConfig,
+ groupFilter: 'securityEnabled eq true',
+ };
+ (mockClient.getOrganization as jest.Mock).mockResolvedValue({
+ id: 'org-id',
+ displayName: 'My Org',
+ });
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [
+ {
+ id: 'grp-parent',
+ displayName: 'Parent',
+ mail: 'parent@example.com',
+ },
+ ],
+ nextLink: undefined,
+ });
+ (mockClient.getGroupMembers as jest.Mock).mockReturnValue(
+ asyncYield({
+ '@odata.type': '#microsoft.graph.group',
+ id: 'grp-child',
+ displayName: 'Child Group',
+ mail: 'child@example.com',
+ }),
+ );
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: providerWithFilter as any,
+ logger,
+ });
+
+ const result = await provider.next(
+ { client: mockClient, provider: providerWithFilter as any },
+ groupsCursor,
+ );
+
+ const parentEntity = result.entities!.find(
+ e =>
+ e.entity.metadata.annotations?.[
+ MICROSOFT_GRAPH_GROUP_ID_ANNOTATION
+ ] === 'grp-parent',
+ );
+ expect(parentEntity!.entity.spec?.children).toHaveLength(0);
+ });
+
+ it('logs a warning and skips a child group member when the transformer throws', async () => {
+ const throwingGroupTransformer = jest
+ .fn()
+ .mockResolvedValueOnce({
+ // first call: parent group transform succeeds
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'Group',
+ metadata: {
+ name: 'parent-group',
+ annotations: { 'graph.microsoft.com/group-id': 'grp-parent' },
+ },
+ spec: { type: 'team', children: [], members: [] },
+ })
+ .mockRejectedValueOnce(new Error('Transformer error'));
+
+ (mockClient.getOrganization as jest.Mock).mockResolvedValue({
+ id: 'org-id',
+ displayName: 'My Org',
+ });
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [
+ {
+ id: 'grp-parent',
+ displayName: 'Parent',
+ mail: 'parent@example.com',
+ },
+ ],
+ nextLink: undefined,
+ });
+ (mockClient.getGroupMembers as jest.Mock).mockReturnValue(
+ asyncYield({
+ '@odata.type': '#microsoft.graph.group',
+ id: 'grp-child',
+ displayName: 'Child Group',
+ mail: 'child@example.com',
+ }),
+ );
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ groupTransformer: throwingGroupTransformer,
+ });
+
+ const result = await provider.next(makeContext(), groupsCursor);
+
+ // Parent group still emitted despite child transformer throwing
+ const parentEntity = result.entities!.find(
+ e =>
+ e.entity.metadata.annotations?.[
+ MICROSOFT_GRAPH_GROUP_ID_ANNOTATION
+ ] === 'grp-parent',
+ );
+ expect(parentEntity).toBeDefined();
+ expect(parentEntity!.entity.spec?.children).toHaveLength(0);
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'group member child group grp-child failed to transform, skipping',
+ ),
+ expect.anything(),
+ );
+ });
+
+ it('returns done:false with groups cursor when nextLink is present', async () => {
+ const nextLink =
+ 'https://graph.microsoft.com/v1.0/groups?$skiptoken=page2';
+ (mockClient.getOrganization as jest.Mock).mockResolvedValue({
+ id: 'org-id',
+ displayName: 'My Org',
+ });
+ mockRequestOnePage.mockResolvedValueOnce({ items: [], nextLink });
+ (mockClient.getGroupMembers as jest.Mock).mockReturnValue(asyncYield());
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+
+ const result = await provider.next(makeContext(), groupsCursor);
+
+ expect(result.done).toBe(false);
+ expect((result.cursor as MSGraphCursor).phase).toBe('groups');
+ expect((result.cursor as MSGraphCursor).nextLink).toBe(nextLink);
+ });
+
+ it('returns done:true when the last groups page has no nextLink', async () => {
+ (mockClient.getOrganization as jest.Mock).mockResolvedValue({
+ id: 'org-id',
+ displayName: 'My Org',
+ });
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [],
+ nextLink: undefined,
+ });
+ (mockClient.getGroupMembers as jest.Mock).mockReturnValue(asyncYield());
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+
+ const result = await provider.next(makeContext(), groupsCursor);
+
+ expect(result.done).toBe(true);
+ });
+
+ it('continues processing remaining groups when root group fetch fails', async () => {
+ (mockClient.getOrganization as jest.Mock).mockRejectedValue(
+ new Error('Organization not found'),
+ );
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [
+ { id: 'grp-1', displayName: 'Engineering', mail: 'eng@example.com' },
+ ],
+ nextLink: undefined,
+ });
+ (mockClient.getGroupMembers as jest.Mock).mockReturnValue(asyncYield());
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ });
+
+ const result = await provider.next(makeContext(), groupsCursor);
+
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('failed to read organization root group'),
+ expect.anything(),
+ );
+ // The group itself is still emitted
+ expect(
+ result.entities!.some(
+ e =>
+ e.entity.metadata.annotations?.[
+ MICROSOFT_GRAPH_GROUP_ID_ANNOTATION
+ ] === 'grp-1',
+ ),
+ ).toBe(true);
+ });
+
+ it('skips groups where the transformer returns undefined', async () => {
+ (mockClient.getOrganization as jest.Mock).mockResolvedValue({
+ id: 'org-id',
+ displayName: 'My Org',
+ });
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [{ id: 'grp-1', displayName: 'Engineering' }],
+ nextLink: undefined,
+ });
+ (mockClient.getGroupMembers as jest.Mock).mockReturnValue(asyncYield());
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ groupTransformer: async () => undefined,
+ });
+
+ const result = await provider.next(makeContext(), groupsCursor);
+
+ // Only the root group entity remains
+ expect(result.entities!.every(e => e.entity.spec?.type === 'root')).toBe(
+ true,
+ );
+ });
+
+ it('logs a warning and skips a group member when the transformer throws', async () => {
+ (mockClient.getOrganization as jest.Mock).mockResolvedValue({
+ id: 'org-id',
+ displayName: 'My Org',
+ });
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [
+ { id: 'grp-1', displayName: 'Engineering', mail: 'eng@example.com' },
+ ],
+ nextLink: undefined,
+ });
+ (mockClient.getGroupMembers as jest.Mock).mockReturnValue(
+ asyncYield(
+ {
+ '@odata.type': '#microsoft.graph.user',
+ id: 'u-bad',
+ // sparse â no userPrincipalName, transformer will throw
+ },
+ {
+ '@odata.type': '#microsoft.graph.user',
+ id: 'u-good',
+ displayName: 'Alice',
+ userPrincipalName: 'alice@example.com',
+ },
+ ),
+ );
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ userTransformer: async user => {
+ if (!user.userPrincipalName) throw new Error('Missing UPN');
+ const { defaultUserTransformer } = await import(
+ '@backstage/plugin-catalog-backend-module-msgraph'
+ );
+ return defaultUserTransformer(user);
+ },
+ });
+
+ const result = await provider.next(makeContext(), groupsCursor);
+
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('group member user u-bad failed to transform'),
+ expect.anything(),
+ );
+ const groupEntity = result.entities!.find(
+ e =>
+ e.entity.metadata.annotations?.[
+ MICROSOFT_GRAPH_GROUP_ID_ANNOTATION
+ ] === 'grp-1',
+ );
+ // Only the good member should appear
+ expect(groupEntity!.entity.spec?.members).toHaveLength(1);
+ });
+
+ it('merges transformer-pre-populated members with fetched membership', async () => {
+ (mockClient.getOrganization as jest.Mock).mockResolvedValue({
+ id: 'org-id',
+ displayName: 'My Org',
+ });
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [
+ { id: 'grp-1', displayName: 'Engineering', mail: 'eng@example.com' },
+ ],
+ nextLink: undefined,
+ });
+ (mockClient.getGroupMembers as jest.Mock).mockReturnValue(
+ asyncYield({
+ '@odata.type': '#microsoft.graph.user',
+ id: 'u2',
+ displayName: 'Bob',
+ userPrincipalName: 'bob@example.com',
+ }),
+ );
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: baseProviderConfig as any,
+ logger,
+ groupTransformer: async group => {
+ const base = await import(
+ '@backstage/plugin-catalog-backend-module-msgraph'
+ ).then(m => m.defaultGroupTransformer(group));
+ if (!base) return undefined;
+ // Transformer pre-populates an extra member
+ base.spec = { ...base.spec, members: ['user:default/extra-user'] };
+ return base;
+ },
+ });
+
+ const result = await provider.next(makeContext(), groupsCursor);
+
+ const groupEntity = result.entities!.find(
+ e =>
+ e.entity.metadata.annotations?.[
+ MICROSOFT_GRAPH_GROUP_ID_ANNOTATION
+ ] === 'grp-1',
+ );
+ // Should contain both the transformer-set member and the fetched one
+ expect(groupEntity!.entity.spec?.members).toContain(
+ 'user:default/extra-user',
+ );
+ expect(groupEntity!.entity.spec?.members).toContain(
+ 'user:default/bob_example.com',
+ );
+ });
+
+ it('passes group filter and search from provider config', async () => {
+ const providerWithFilter = {
+ ...baseProviderConfig,
+ groupFilter: 'securityEnabled eq true',
+ groupSearch: '"displayName:Engineering"',
+ };
+ (mockClient.getOrganization as jest.Mock).mockResolvedValue({
+ id: 'org-id',
+ displayName: 'My Org',
+ });
+ mockRequestOnePage.mockResolvedValueOnce({
+ items: [],
+ nextLink: undefined,
+ });
+ (mockClient.getGroupMembers as jest.Mock).mockReturnValue(asyncYield());
+
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: 'default',
+ provider: providerWithFilter as any,
+ logger,
+ });
+
+ await provider.next(
+ { client: mockClient, provider: providerWithFilter as any },
+ groupsCursor,
+ );
+
+ expect(mockRequestOnePage).toHaveBeenCalledWith(
+ mockClient,
+ 'groups',
+ expect.objectContaining({
+ query: expect.objectContaining({
+ filter: 'securityEnabled eq true',
+ search: '"displayName:Engineering"',
+ }),
+ }),
+ );
+ });
+ });
+});
diff --git a/plugins/catalog-backend-module-msgraph-incremental/src/MicrosoftGraphIncrementalEntityProvider.ts b/plugins/catalog-backend-module-msgraph-incremental/src/MicrosoftGraphIncrementalEntityProvider.ts
new file mode 100644
index 0000000000..57cbc16c66
--- /dev/null
+++ b/plugins/catalog-backend-module-msgraph-incremental/src/MicrosoftGraphIncrementalEntityProvider.ts
@@ -0,0 +1,551 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import crypto from 'node:crypto';
+import {
+ ANNOTATION_LOCATION,
+ ANNOTATION_ORIGIN_LOCATION,
+ Entity,
+ stringifyEntityRef,
+} from '@backstage/catalog-model';
+import { Config } from '@backstage/config';
+import {
+ IncrementalEntityProvider,
+ EntityIteratorResult,
+} from '@backstage/plugin-catalog-backend-module-incremental-ingestion';
+import { DeferredEntity } from '@backstage/plugin-catalog-node';
+import limiterFactory from 'p-limit';
+import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
+import {
+ GroupTransformer,
+ MICROSOFT_GRAPH_GROUP_ID_ANNOTATION,
+ MICROSOFT_GRAPH_TENANT_ID_ANNOTATION,
+ MICROSOFT_GRAPH_USER_ID_ANNOTATION,
+ MicrosoftGraphClient,
+ MicrosoftGraphProviderConfig,
+ OrganizationTransformer,
+ ProviderConfigTransformer,
+ UserTransformer,
+ defaultGroupTransformer,
+ defaultOrganizationTransformer,
+ defaultUserTransformer,
+ readProviderConfigs,
+} from '@backstage/plugin-catalog-backend-module-msgraph';
+import { LoggerService } from '@backstage/backend-plugin-api';
+import { getUserPhotoGated, requestOnePage } from './clientHelpers';
+
+const USER_PAGE_SIZE = 999;
+// Groups phase fetches members for every group on the page, so a smaller page
+// size keeps each burst within its time budget.
+const GROUP_PAGE_SIZE = 100;
+
+/**
+ * Backstage entity names must be â¤63 chars ([a-zA-Z0-9] separated by [-_.]).
+ * When MS Graph UPNs exceed that (e.g. calendar/booking accounts), we truncate
+ * to 54 chars and append an 8-char SHA-1 hash to preserve uniqueness.
+ */
+function capEntityName(name: string): string {
+ if (name.length <= 63) return name;
+ const hash = crypto.createHash('sha1').update(name).digest('hex').slice(0, 8);
+ return `${name.slice(0, 54)}_${hash}`;
+}
+
+/** Stamps `annotations.backstage.io/location` on an entity using the MS Graph UID. */
+function withLocations(providerId: string, entity: Entity): Entity {
+ const uid =
+ entity.metadata.annotations?.[MICROSOFT_GRAPH_USER_ID_ANNOTATION] ||
+ entity.metadata.annotations?.[MICROSOFT_GRAPH_GROUP_ID_ANNOTATION] ||
+ entity.metadata.annotations?.[MICROSOFT_GRAPH_TENANT_ID_ANNOTATION] ||
+ entity.metadata.name;
+ const location = `msgraph:${providerId}/${encodeURIComponent(uid)}`;
+ return {
+ ...entity,
+ metadata: {
+ ...entity.metadata,
+ annotations: {
+ ...entity.metadata.annotations,
+ [ANNOTATION_LOCATION]: location,
+ [ANNOTATION_ORIGIN_LOCATION]: location,
+ },
+ },
+ };
+}
+
+/**
+ * Pagination cursor used by {@link MicrosoftGraphIncrementalEntityProvider}.
+ *
+ * The `nextLink` field holds the `@odata.nextLink` URL returned by the
+ * Microsoft Graph API, which encodes all state needed to resume a paged
+ * request. An absent value means the current phase is starting fresh.
+ *
+ * @public
+ */
+export type MSGraphCursor = {
+ phase: 'users' | 'groups';
+ nextLink?: string;
+};
+
+/**
+ * Context passed to each burst of {@link MicrosoftGraphIncrementalEntityProvider}.
+ *
+ * @public
+ */
+export type MSGraphContext = {
+ client: MicrosoftGraphClient;
+ provider: MicrosoftGraphProviderConfig;
+};
+
+/**
+ * Options for {@link MicrosoftGraphIncrementalEntityProvider}.
+ *
+ * @public
+ */
+export interface MicrosoftGraphIncrementalEntityProviderOptions {
+ /**
+ * The logger to use.
+ */
+ logger: LoggerService;
+
+ /**
+ * The function that transforms a user entry in msgraph to an entity.
+ * Optionally, you can pass separate transformers per provider ID.
+ */
+ userTransformer?: UserTransformer | Record;
+
+ /**
+ * The function that transforms a group entry in msgraph to an entity.
+ * Optionally, you can pass separate transformers per provider ID.
+ */
+ groupTransformer?: GroupTransformer | Record;
+
+ /**
+ * The function that transforms an organization entry in msgraph to an entity.
+ * Optionally, you can pass separate transformers per provider ID.
+ */
+ organizationTransformer?:
+ | OrganizationTransformer
+ | Record;
+
+ /**
+ * The function that transforms provider config dynamically before each sync.
+ * Optionally, you can pass separate transformers per provider ID.
+ */
+ providerConfigTransformer?:
+ | ProviderConfigTransformer
+ | Record;
+}
+
+/**
+ * Incrementally reads user and group entries out of Microsoft Graph, one page
+ * at a time, and provides them as User and Group entities for the catalog.
+ *
+ * Unlike `MicrosoftGraphOrgEntityProvider`, this provider never holds the full
+ * dataset in memory at once. Each burst processes a single page (up to 999
+ * users or 100 groups). This makes it suitable for very large tenants and
+ * avoids the memory pressure and long-running task issues of the full-scan
+ * provider.
+ *
+ * The Microsoft Graph `@odata.nextLink` URL is stored as the cursor, so a pod
+ * restart during ingestion resumes from the last completed page.
+ *
+ * Group membership (`spec.members`) is resolved inline during the groups phase
+ * by fetching the direct members of each group. The catalog's built-in relation
+ * stitching derives `spec.memberOf` on users from these group membership lists.
+ *
+ * @remarks
+ * `userGroupMemberFilter`, `userGroupMemberSearch`, `userGroupMemberPath`, and
+ * `groupIncludeSubGroups` are not supported. Use `userFilter` / `userPath` to
+ * restrict which users are ingested, and `groupFilter` / `groupSearch` to
+ * restrict which groups. Switch to `MicrosoftGraphOrgEntityProvider` if you
+ * require any of these options.
+ *
+ * @public
+ */
+export class MicrosoftGraphIncrementalEntityProvider
+ implements IncrementalEntityProvider
+{
+ /**
+ * Create one provider instance per provider entry in
+ * `catalog.providers.microsoftGraphOrg`.
+ */
+ static fromConfig(
+ configRoot: Config,
+ options: MicrosoftGraphIncrementalEntityProviderOptions,
+ ): MicrosoftGraphIncrementalEntityProvider[] {
+ function getTransformer(
+ id: string,
+ transformers?: T | Record,
+ ): T | undefined {
+ if (['undefined', 'function'].includes(typeof transformers)) {
+ return transformers as T;
+ }
+ return (transformers as Record)[id];
+ }
+
+ return readProviderConfigs(configRoot).map(
+ providerConfig =>
+ new MicrosoftGraphIncrementalEntityProvider({
+ id: providerConfig.id,
+ provider: providerConfig,
+ logger: options.logger,
+ userTransformer: getTransformer(
+ providerConfig.id,
+ options.userTransformer,
+ ),
+ groupTransformer: getTransformer(
+ providerConfig.id,
+ options.groupTransformer,
+ ),
+ organizationTransformer: getTransformer(
+ providerConfig.id,
+ options.organizationTransformer,
+ ),
+ providerConfigTransformer: getTransformer(
+ providerConfig.id,
+ options.providerConfigTransformer,
+ ),
+ }),
+ );
+ }
+
+ private readonly options: {
+ id: string;
+ provider: MicrosoftGraphProviderConfig;
+ logger: LoggerService;
+ userTransformer?: UserTransformer;
+ groupTransformer?: GroupTransformer;
+ organizationTransformer?: OrganizationTransformer;
+ providerConfigTransformer?: ProviderConfigTransformer;
+ };
+
+ constructor(options: {
+ id: string;
+ provider: MicrosoftGraphProviderConfig;
+ logger: LoggerService;
+ userTransformer?: UserTransformer;
+ groupTransformer?: GroupTransformer;
+ organizationTransformer?: OrganizationTransformer;
+ providerConfigTransformer?: ProviderConfigTransformer;
+ }) {
+ this.options = options;
+ }
+
+ /** {@inheritdoc @backstage/plugin-catalog-backend-module-incremental-ingestion#IncrementalEntityProvider.getProviderName} */
+ getProviderName(): string {
+ return `MicrosoftGraphIncrementalEntityProvider:${this.options.id}`;
+ }
+
+ /**
+ * Sets up the Microsoft Graph client for the duration of a full ingestion
+ * cycle. The optional `providerConfigTransformer` is applied here so that
+ * dynamic config changes (e.g., rotating credentials) take effect at the
+ * start of each cycle rather than mid-way through.
+ */
+ async around(
+ burst: (context: MSGraphContext) => Promise,
+ ): Promise {
+ const provider = this.options.providerConfigTransformer
+ ? await this.options.providerConfigTransformer(this.options.provider)
+ : this.options.provider;
+
+ if (
+ provider.userGroupMemberFilter ||
+ provider.userGroupMemberSearch ||
+ provider.userGroupMemberPath
+ ) {
+ this.options.logger.warn(
+ `${this.getProviderName()}: userGroupMemberFilter/Search/Path are not supported by ` +
+ `MicrosoftGraphIncrementalEntityProvider. Users will be fetched via the standard ` +
+ `userFilter/userPath options instead. Switch to MicrosoftGraphOrgEntityProvider if ` +
+ `you require userGroupMember-based ingestion.`,
+ );
+ }
+
+ if (provider.groupIncludeSubGroups) {
+ this.options.logger.warn(
+ `${this.getProviderName()}: groupIncludeSubGroups is not supported by ` +
+ `MicrosoftGraphIncrementalEntityProvider and will be ignored. ` +
+ `Switch to MicrosoftGraphOrgEntityProvider if you require this option.`,
+ );
+ }
+
+ const client = MicrosoftGraphClient.create(provider);
+ await burst({ client, provider });
+ }
+
+ /** {@inheritdoc @backstage/plugin-catalog-backend-module-incremental-ingestion#IncrementalEntityProvider.next} */
+ async next(
+ { client, provider }: MSGraphContext,
+ cursor?: MSGraphCursor,
+ ): Promise> {
+ const phase = cursor?.phase ?? 'users';
+ const nextLink = cursor?.nextLink;
+
+ if (phase === 'users') {
+ return this.readUsersPage(client, provider, nextLink);
+ }
+ return this.readGroupsPage(client, provider, nextLink);
+ }
+
+ private async readUsersPage(
+ client: MicrosoftGraphClient,
+ provider: MicrosoftGraphProviderConfig,
+ nextLink: string | undefined,
+ ): Promise> {
+ const { items: rawUsers, nextLink: newNextLink } =
+ await requestOnePage(
+ client,
+ provider.userPath ?? 'users',
+ {
+ query: {
+ filter: provider.userFilter,
+ expand: provider.userExpand,
+ select: provider.userSelect,
+ top: USER_PAGE_SIZE,
+ },
+ queryMode: provider.queryMode,
+ nextLink,
+ },
+ );
+
+ const transformer = this.options.userTransformer ?? defaultUserTransformer;
+ const limiter = limiterFactory(10);
+ const entities: DeferredEntity[] = [];
+
+ await Promise.all(
+ rawUsers.map(user =>
+ limiter(async () => {
+ let userPhoto: string | undefined;
+ if (user.id && provider.loadUserPhotos !== false) {
+ try {
+ userPhoto = await getUserPhotoGated(client, user.id, 120);
+ } catch (e) {
+ this.options.logger.debug(
+ `${this.getProviderName()}: failed to load photo for user ${
+ user.id
+ }`,
+ { error: e },
+ );
+ }
+ }
+
+ const entity = await transformer(user, userPhoto);
+ if (entity) {
+ entity.metadata.name = capEntityName(entity.metadata.name);
+ entities.push({
+ locationKey: `msgraph-org-provider:${this.options.id}`,
+ entity: withLocations(this.options.id, entity),
+ });
+ }
+ }),
+ ),
+ );
+
+ this.options.logger.debug(
+ `${this.getProviderName()}: read ${entities.length} users`,
+ { phase: 'users', hasNextPage: !!newNextLink },
+ );
+
+ if (newNextLink) {
+ return {
+ done: false,
+ entities,
+ cursor: { phase: 'users', nextLink: newNextLink },
+ };
+ }
+
+ return {
+ done: false,
+ entities,
+ cursor: { phase: 'groups' },
+ };
+ }
+
+ private async readGroupsPage(
+ client: MicrosoftGraphClient,
+ provider: MicrosoftGraphProviderConfig,
+ nextLink: string | undefined,
+ ): Promise> {
+ const { items: rawGroups, nextLink: newNextLink } =
+ await requestOnePage(
+ client,
+ provider.groupPath ?? 'groups',
+ {
+ query: {
+ filter: provider.groupFilter,
+ search: provider.groupSearch,
+ expand: provider.groupExpand,
+ select: provider.groupSelect,
+ top: GROUP_PAGE_SIZE,
+ },
+ queryMode: provider.queryMode,
+ nextLink,
+ },
+ );
+
+ const groupTransformer =
+ this.options.groupTransformer ?? defaultGroupTransformer;
+ const userTransformer =
+ this.options.userTransformer ?? defaultUserTransformer;
+ const limiter = limiterFactory(10);
+ const entities: DeferredEntity[] = [];
+
+ // Emit the tenant root group on the very first groups page
+ if (!nextLink) {
+ try {
+ const organization = await client.getOrganization(provider.tenantId);
+ const orgTransformer =
+ this.options.organizationTransformer ??
+ defaultOrganizationTransformer;
+ const rootGroup = await orgTransformer(organization);
+ if (rootGroup) {
+ entities.push({
+ locationKey: `msgraph-org-provider:${this.options.id}`,
+ entity: withLocations(this.options.id, rootGroup),
+ });
+ }
+ } catch (e) {
+ this.options.logger.warn(
+ `${this.getProviderName()}: failed to read organization root group`,
+ { error: e },
+ );
+ }
+ }
+
+ await Promise.all(
+ rawGroups.map(group =>
+ limiter(async () => {
+ const entity = await groupTransformer(group);
+ if (!entity) {
+ return;
+ }
+ entity.metadata.name = capEntityName(entity.metadata.name);
+
+ const userRefs: string[] = [];
+ const childRefs: string[] = [];
+
+ for await (const member of client.getGroupMembers(group.id!, {
+ top: GROUP_PAGE_SIZE,
+ // Request the minimum fields needed by defaultUserTransformer and
+ // defaultGroupTransformer so member objects are never sparse.
+ select: [
+ 'id',
+ 'displayName',
+ 'mail',
+ 'mailNickname',
+ 'userPrincipalName',
+ 'description',
+ 'securityEnabled',
+ ],
+ })) {
+ if (member['@odata.type'] === '#microsoft.graph.user') {
+ try {
+ const userEntity = await userTransformer(
+ member as MicrosoftGraph.User,
+ );
+ if (userEntity) {
+ userEntity.metadata.name = capEntityName(
+ userEntity.metadata.name,
+ );
+ userRefs.push(stringifyEntityRef(userEntity));
+ } else {
+ this.options.logger.debug(
+ `${this.getProviderName()}: group member user ${
+ member.id
+ } could not be transformed (sparse object?), skipping`,
+ );
+ }
+ } catch (e) {
+ this.options.logger.warn(
+ `${this.getProviderName()}: group member user ${
+ member.id
+ } failed to transform, skipping`,
+ { error: e },
+ );
+ }
+ } else if (member['@odata.type'] === '#microsoft.graph.group') {
+ // Only emit child refs when no group filter/search is active.
+ // With a filter, child groups may not be ingested themselves,
+ // which would produce dangling spec.children references.
+ if (!provider.groupFilter && !provider.groupSearch) {
+ try {
+ const childEntity = await groupTransformer(
+ member as MicrosoftGraph.Group,
+ );
+ if (childEntity) {
+ childEntity.metadata.name = capEntityName(
+ childEntity.metadata.name,
+ );
+ childRefs.push(stringifyEntityRef(childEntity));
+ } else {
+ this.options.logger.debug(
+ `${this.getProviderName()}: group member child group ${
+ member.id
+ } could not be transformed (sparse object?), skipping`,
+ );
+ }
+ } catch (e) {
+ this.options.logger.warn(
+ `${this.getProviderName()}: group member child group ${
+ member.id
+ } failed to transform, skipping`,
+ { error: e },
+ );
+ }
+ }
+ }
+ }
+
+ // Merge fetched membership with any members/children the transformer
+ // may have pre-populated, so custom transformers can augment the list.
+ const existingMembers = Array.isArray(entity.spec?.members)
+ ? (entity.spec.members as string[])
+ : [];
+ const existingChildren = Array.isArray(entity.spec?.children)
+ ? (entity.spec.children as string[])
+ : [];
+
+ entities.push({
+ locationKey: `msgraph-org-provider:${this.options.id}`,
+ entity: withLocations(this.options.id, {
+ ...entity,
+ spec: {
+ ...entity.spec,
+ members: [...new Set([...existingMembers, ...userRefs])],
+ children: [...new Set([...existingChildren, ...childRefs])],
+ },
+ }),
+ });
+ }),
+ ),
+ );
+
+ this.options.logger.debug(
+ `${this.getProviderName()}: read ${rawGroups.length} groups`,
+ { phase: 'groups', hasNextPage: !!newNextLink },
+ );
+
+ if (newNextLink) {
+ return {
+ done: false,
+ entities,
+ cursor: { phase: 'groups', nextLink: newNextLink },
+ };
+ }
+
+ return { done: true, entities };
+ }
+}
diff --git a/plugins/catalog-backend-module-msgraph-incremental/src/alpha.ts b/plugins/catalog-backend-module-msgraph-incremental/src/alpha.ts
new file mode 100644
index 0000000000..e512e4ba83
--- /dev/null
+++ b/plugins/catalog-backend-module-msgraph-incremental/src/alpha.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export {};
diff --git a/plugins/catalog-backend-module-msgraph-incremental/src/clientHelpers.test.ts b/plugins/catalog-backend-module-msgraph-incremental/src/clientHelpers.test.ts
new file mode 100644
index 0000000000..a103787f0d
--- /dev/null
+++ b/plugins/catalog-backend-module-msgraph-incremental/src/clientHelpers.test.ts
@@ -0,0 +1,298 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { MicrosoftGraphClient } from '@backstage/plugin-catalog-backend-module-msgraph';
+import { getUserPhotoGated, requestOnePage } from './clientHelpers';
+
+function makeResponse(status: number, body: unknown): Response {
+ return {
+ status,
+ json: () => Promise.resolve(body),
+ } as unknown as Response;
+}
+
+describe('requestOnePage', () => {
+ const client = {
+ requestApi: jest.fn(),
+ requestRaw: jest.fn(),
+ } as unknown as jest.Mocked;
+
+ afterEach(() => jest.resetAllMocks());
+
+ it('fetches a basic page using requestApi', async () => {
+ (client.requestApi as jest.Mock).mockResolvedValue(
+ makeResponse(200, {
+ value: [{ id: '1', displayName: 'Alice' }],
+ }),
+ );
+
+ const result = await requestOnePage<{ id: string }>(client, 'users', {
+ query: { top: 999 },
+ });
+
+ expect(result.items).toHaveLength(1);
+ expect(result.items[0].id).toBe('1');
+ expect(result.nextLink).toBeUndefined();
+ expect(client.requestApi).toHaveBeenCalledWith(
+ 'users',
+ { top: 999 },
+ {},
+ undefined,
+ );
+ });
+
+ it('adds ConsistencyLevel and $count for advanced mode with filter', async () => {
+ (client.requestApi as jest.Mock).mockResolvedValue(
+ makeResponse(200, { value: [] }),
+ );
+
+ await requestOnePage(client, 'users', {
+ query: {
+ filter: 'accountEnabled eq true and jobTitle ne null',
+ top: 999,
+ },
+ queryMode: 'advanced',
+ });
+
+ expect(client.requestApi).toHaveBeenCalledWith(
+ 'users',
+ expect.objectContaining({ count: true }),
+ { ConsistencyLevel: 'eventual' },
+ undefined,
+ );
+ });
+
+ it('adds ConsistencyLevel and $count for advanced mode without filter', async () => {
+ (client.requestApi as jest.Mock).mockResolvedValue(
+ makeResponse(200, { value: [] }),
+ );
+
+ await requestOnePage(client, 'groups', {
+ query: { top: 999 },
+ queryMode: 'advanced',
+ });
+
+ expect(client.requestApi).toHaveBeenCalledWith(
+ 'groups',
+ expect.objectContaining({ count: true }),
+ { ConsistencyLevel: 'eventual' },
+ undefined,
+ );
+ });
+
+ it('auto-promotes to advanced mode when $search is present', async () => {
+ (client.requestApi as jest.Mock).mockResolvedValue(
+ makeResponse(200, { value: [] }),
+ );
+
+ await requestOnePage(client, 'groups', {
+ query: { search: '"displayName:Sales"', top: 10 },
+ });
+
+ expect(client.requestApi).toHaveBeenCalledWith(
+ 'groups',
+ expect.objectContaining({ count: true }),
+ { ConsistencyLevel: 'eventual' },
+ undefined,
+ );
+ });
+
+ it('sends $count=true in advanced mode even when no query is provided', async () => {
+ (client.requestApi as jest.Mock).mockResolvedValue(
+ makeResponse(200, { value: [] }),
+ );
+
+ await requestOnePage(client, 'users', { queryMode: 'advanced' });
+
+ expect(client.requestApi).toHaveBeenCalledWith(
+ 'users',
+ expect.objectContaining({ count: true }),
+ { ConsistencyLevel: 'eventual' },
+ undefined,
+ );
+ });
+
+ it('does not set $count for basic mode', async () => {
+ (client.requestApi as jest.Mock).mockResolvedValue(
+ makeResponse(200, { value: [] }),
+ );
+
+ await requestOnePage(client, 'users', {
+ query: { filter: 'accountEnabled eq true', top: 999 },
+ queryMode: 'basic',
+ });
+
+ expect(client.requestApi).toHaveBeenCalledWith(
+ 'users',
+ expect.not.objectContaining({ count: true }),
+ {},
+ undefined,
+ );
+ });
+
+ it('follows nextLink using requestRaw', async () => {
+ const nextLink =
+ 'https://graph.microsoft.com/v1.0/users?$skiptoken=abc123&$count=true';
+ (client.requestRaw as jest.Mock).mockResolvedValue(
+ makeResponse(200, {
+ value: [{ id: '2' }],
+ '@odata.nextLink':
+ 'https://graph.microsoft.com/v1.0/users?$skiptoken=def456',
+ }),
+ );
+
+ const result = await requestOnePage(client, 'users', { nextLink });
+
+ expect(client.requestRaw).toHaveBeenCalledWith(nextLink, {}, 2, undefined);
+ expect(client.requestApi).not.toHaveBeenCalled();
+ expect(result.items).toHaveLength(1);
+ expect(result.nextLink).toBe(
+ 'https://graph.microsoft.com/v1.0/users?$skiptoken=def456',
+ );
+ });
+
+ it('passes ConsistencyLevel header when following nextLink in advanced mode', async () => {
+ const nextLink = 'https://graph.microsoft.com/v1.0/groups?$skiptoken=xyz';
+ (client.requestRaw as jest.Mock).mockResolvedValue(
+ makeResponse(200, { value: [] }),
+ );
+
+ await requestOnePage(client, 'groups', {
+ query: { filter: 'securityEnabled eq true', top: 999 },
+ queryMode: 'advanced',
+ nextLink,
+ });
+
+ expect(client.requestRaw).toHaveBeenCalledWith(
+ nextLink,
+ { ConsistencyLevel: 'eventual' },
+ 2,
+ undefined,
+ );
+ });
+
+ it('returns nextLink from response when present', async () => {
+ const expectedNextLink =
+ 'https://graph.microsoft.com/v1.0/users?$skiptoken=page2';
+ (client.requestApi as jest.Mock).mockResolvedValue(
+ makeResponse(200, {
+ value: Array(999).fill({ id: 'x' }),
+ '@odata.nextLink': expectedNextLink,
+ }),
+ );
+
+ const result = await requestOnePage(client, 'users', {});
+
+ expect(result.nextLink).toBe(expectedNextLink);
+ expect(result.items).toHaveLength(999);
+ });
+
+ it('throws a descriptive error on non-200 response', async () => {
+ (client.requestApi as jest.Mock).mockResolvedValue(
+ makeResponse(403, {
+ error: {
+ code: 'Authorization_RequestDenied',
+ message: 'Access denied',
+ },
+ }),
+ );
+
+ await expect(requestOnePage(client, 'users', {})).rejects.toThrow(
+ 'Authorization_RequestDenied - Access denied',
+ );
+ });
+
+ it('passes AbortSignal through to the client', async () => {
+ (client.requestApi as jest.Mock).mockResolvedValue(
+ makeResponse(200, { value: [] }),
+ );
+ const signal = new AbortController().signal;
+
+ await requestOnePage(client, 'users', { signal });
+
+ expect(client.requestApi).toHaveBeenCalledWith(
+ 'users',
+ undefined,
+ {},
+ signal,
+ );
+ });
+});
+
+describe('getUserPhotoGated', () => {
+ const client = {
+ requestApi: jest.fn(),
+ getUserPhotoWithSizeLimit: jest.fn(),
+ } as unknown as jest.Mocked;
+
+ afterEach(() => jest.resetAllMocks());
+
+ it('returns undefined when the photo check returns 404', async () => {
+ (client.requestApi as jest.Mock).mockResolvedValue({
+ status: 404,
+ } as Response);
+
+ const result = await getUserPhotoGated(client, 'user-id', 120);
+
+ expect(result).toBeUndefined();
+ expect(client.getUserPhotoWithSizeLimit).not.toHaveBeenCalled();
+ });
+
+ it('throws for non-404 error responses so callers can distinguish real errors', async () => {
+ (client.requestApi as jest.Mock).mockResolvedValue({
+ status: 403,
+ } as Response);
+
+ await expect(getUserPhotoGated(client, 'user-id', 120)).rejects.toThrow(
+ 'Unexpected status 403 when checking photo for user user-id',
+ );
+ expect(client.getUserPhotoWithSizeLimit).not.toHaveBeenCalled();
+ });
+
+ it('returns the photo data URI when the check passes', async () => {
+ (client.requestApi as jest.Mock).mockResolvedValue({
+ status: 200,
+ } as Response);
+ (client.getUserPhotoWithSizeLimit as jest.Mock).mockResolvedValue(
+ 'data:image/jpeg;base64,/9j/abc123',
+ );
+
+ const result = await getUserPhotoGated(client, 'user-id', 120);
+
+ expect(result).toBe('data:image/jpeg;base64,/9j/abc123');
+ expect(client.requestApi).toHaveBeenCalledWith('users/user-id/photo');
+ expect(client.getUserPhotoWithSizeLimit).toHaveBeenCalledWith(
+ 'user-id',
+ 120,
+ );
+ });
+
+ it('passes the maxSize limit to getUserPhotoWithSizeLimit', async () => {
+ (client.requestApi as jest.Mock).mockResolvedValue({
+ status: 200,
+ } as Response);
+ (client.getUserPhotoWithSizeLimit as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+
+ await getUserPhotoGated(client, 'user-abc', 48);
+
+ expect(client.getUserPhotoWithSizeLimit).toHaveBeenCalledWith(
+ 'user-abc',
+ 48,
+ );
+ });
+});
diff --git a/plugins/catalog-backend-module-msgraph-incremental/src/clientHelpers.ts b/plugins/catalog-backend-module-msgraph-incremental/src/clientHelpers.ts
new file mode 100644
index 0000000000..61b78e4c64
--- /dev/null
+++ b/plugins/catalog-backend-module-msgraph-incremental/src/clientHelpers.ts
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ MicrosoftGraphClient,
+ ODataQuery,
+} from '@backstage/plugin-catalog-backend-module-msgraph';
+
+/**
+ * Fetches a single page of Graph API results.
+ *
+ * When `options.nextLink` is provided it is followed directly (all query
+ * parameters are already encoded in it). Otherwise the request is built from
+ * `options.query`.
+ *
+ * MS Graph requires `ConsistencyLevel: eventual` + `$count=true` for advanced
+ * queries using `ne`/`not` operators in `$filter` or using `$search`.
+ */
+export async function requestOnePage(
+ client: MicrosoftGraphClient,
+ path: string,
+ options: {
+ query?: ODataQuery;
+ queryMode?: 'basic' | 'advanced';
+ nextLink?: string;
+ signal?: AbortSignal;
+ } = {},
+): Promise<{ items: T[]; nextLink?: string }> {
+ const { query, queryMode, nextLink, signal } = options;
+ const appliedQueryMode = query?.search ? 'advanced' : queryMode ?? 'basic';
+
+ // Microsoft Graph requires $count=true whenever ConsistencyLevel: eventual is set,
+ // including plain listing requests with no $filter or $search.
+ const finalQuery =
+ appliedQueryMode === 'advanced' ? { ...(query ?? {}), count: true } : query;
+
+ const headers: Record =
+ appliedQueryMode === 'advanced' ? { ConsistencyLevel: 'eventual' } : {};
+
+ const response = nextLink
+ ? await client.requestRaw(nextLink, headers, 2, signal)
+ : await client.requestApi(path, finalQuery, headers, signal);
+
+ if (response.status !== 200) {
+ let message = `HTTP ${response.status}`;
+ try {
+ const body = await response.json();
+ const err = body?.error;
+ if (err?.code || err?.message) {
+ message = `${err.code} - ${err.message}`;
+ }
+ } catch {
+ // Response body is not JSON; fall back to HTTP status above
+ }
+ throw new Error(
+ `Error while reading ${
+ nextLink ?? path
+ } from Microsoft Graph: ${message}`,
+ );
+ }
+
+ const result = await response.json();
+ return {
+ items: result.value as T[],
+ nextLink: result['@odata.nextLink'],
+ };
+}
+
+/**
+ * Like `getUserPhotoWithSizeLimit` but skips the size-listing call for users
+ * with no photo. For users without a photo: 1 fast check call. For users with
+ * a photo: 1 check + the normal size-limited fetch (2 more calls).
+ *
+ * Returns `undefined` only for 404 (no photo assigned). Throws for any other
+ * non-200 status so callers can distinguish "no photo" from real errors.
+ */
+export async function getUserPhotoGated(
+ client: MicrosoftGraphClient,
+ userId: string,
+ maxSize: number,
+): Promise {
+ const check = await client.requestApi(`users/${userId}/photo`);
+ if (check.status === 404) return undefined;
+ if (check.status !== 200) {
+ throw new Error(
+ `Unexpected status ${check.status} when checking photo for user ${userId}`,
+ );
+ }
+ return await client.getUserPhotoWithSizeLimit(userId, maxSize);
+}
diff --git a/plugins/catalog-backend-module-msgraph-incremental/src/index.ts b/plugins/catalog-backend-module-msgraph-incremental/src/index.ts
new file mode 100644
index 0000000000..37ad8b96c6
--- /dev/null
+++ b/plugins/catalog-backend-module-msgraph-incremental/src/index.ts
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * A Backstage catalog backend module that incrementally ingests users and
+ * groups from Microsoft Graph, one page at a time.
+ *
+ * @packageDocumentation
+ */
+
+export { catalogModuleMicrosoftGraphIncrementalEntityProvider as default } from './module';
+export * from './module';
+export { MicrosoftGraphIncrementalEntityProvider } from './MicrosoftGraphIncrementalEntityProvider';
+export type {
+ MSGraphCursor,
+ MSGraphContext,
+ MicrosoftGraphIncrementalEntityProviderOptions,
+} from './MicrosoftGraphIncrementalEntityProvider';
diff --git a/plugins/catalog-backend-module-msgraph-incremental/src/module/catalogModuleMicrosoftGraphIncrementalEntityProvider.test.ts b/plugins/catalog-backend-module-msgraph-incremental/src/module/catalogModuleMicrosoftGraphIncrementalEntityProvider.test.ts
new file mode 100644
index 0000000000..c7ca22fe48
--- /dev/null
+++ b/plugins/catalog-backend-module-msgraph-incremental/src/module/catalogModuleMicrosoftGraphIncrementalEntityProvider.test.ts
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { mockServices, startTestBackend } from '@backstage/backend-test-utils';
+import { incrementalIngestionProvidersExtensionPoint } from '@backstage/plugin-catalog-backend-module-incremental-ingestion';
+import { catalogModuleMicrosoftGraphIncrementalEntityProvider } from './catalogModuleMicrosoftGraphIncrementalEntityProvider';
+import { MicrosoftGraphIncrementalEntityProvider } from '../MicrosoftGraphIncrementalEntityProvider';
+
+describe('catalogModuleMicrosoftGraphIncrementalEntityProvider', () => {
+ it('registers the provider at the incremental ingestion extension point', async () => {
+ const addProvider = jest.fn();
+ const extensionPoint = { addProvider };
+
+ await startTestBackend({
+ extensionPoints: [
+ [incrementalIngestionProvidersExtensionPoint, extensionPoint],
+ ],
+ features: [
+ catalogModuleMicrosoftGraphIncrementalEntityProvider,
+ mockServices.rootConfig.factory({
+ data: {
+ catalog: {
+ providers: {
+ microsoftGraphOrg: {
+ default: {
+ tenantId: 'tenant-id',
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ schedule: {
+ frequency: 'PT12H',
+ timeout: 'PT4H',
+ },
+ },
+ },
+ },
+ },
+ },
+ }),
+ ],
+ });
+
+ expect(addProvider).toHaveBeenCalledTimes(1);
+ const { provider, options } = addProvider.mock.calls[0][0];
+ expect(provider).toBeInstanceOf(MicrosoftGraphIncrementalEntityProvider);
+ expect(provider.getProviderName()).toBe(
+ 'MicrosoftGraphIncrementalEntityProvider:default',
+ );
+ expect(options.burstInterval).toEqual({ seconds: 3 });
+ expect(options.burstLength).toEqual({ minutes: 5 });
+ // restLength derived from schedule.frequency (12h)
+ expect(options.restLength).toEqual({ hours: 12 });
+ });
+
+ it('creates one provider per config entry', async () => {
+ const addProvider = jest.fn();
+ const extensionPoint = { addProvider };
+
+ await startTestBackend({
+ extensionPoints: [
+ [incrementalIngestionProvidersExtensionPoint, extensionPoint],
+ ],
+ features: [
+ catalogModuleMicrosoftGraphIncrementalEntityProvider,
+ mockServices.rootConfig.factory({
+ data: {
+ catalog: {
+ providers: {
+ microsoftGraphOrg: {
+ tenantA: {
+ tenantId: 'a',
+ clientId: 'c',
+ clientSecret: 's',
+ },
+ tenantB: {
+ tenantId: 'b',
+ clientId: 'c',
+ clientSecret: 's',
+ },
+ },
+ },
+ },
+ },
+ }),
+ ],
+ });
+
+ expect(addProvider).toHaveBeenCalledTimes(2);
+ });
+
+ it('defaults restLength to 8 hours when no schedule frequency is configured', async () => {
+ const addProvider = jest.fn();
+ const extensionPoint = { addProvider };
+
+ await startTestBackend({
+ extensionPoints: [
+ [incrementalIngestionProvidersExtensionPoint, extensionPoint],
+ ],
+ features: [
+ catalogModuleMicrosoftGraphIncrementalEntityProvider,
+ mockServices.rootConfig.factory({
+ data: {
+ catalog: {
+ providers: {
+ microsoftGraphOrg: {
+ default: {
+ tenantId: 'tenant-id',
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ },
+ },
+ },
+ },
+ },
+ }),
+ ],
+ });
+
+ const { options } = addProvider.mock.calls[0][0];
+ expect(options.restLength).toEqual({ hours: 8 });
+ });
+});
diff --git a/plugins/catalog-backend-module-msgraph-incremental/src/module/catalogModuleMicrosoftGraphIncrementalEntityProvider.ts b/plugins/catalog-backend-module-msgraph-incremental/src/module/catalogModuleMicrosoftGraphIncrementalEntityProvider.ts
new file mode 100644
index 0000000000..3ab51aa9b0
--- /dev/null
+++ b/plugins/catalog-backend-module-msgraph-incremental/src/module/catalogModuleMicrosoftGraphIncrementalEntityProvider.ts
@@ -0,0 +1,252 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ coreServices,
+ createBackendModule,
+ createExtensionPoint,
+ LoggerService,
+} from '@backstage/backend-plugin-api';
+import { incrementalIngestionProvidersExtensionPoint } from '@backstage/plugin-catalog-backend-module-incremental-ingestion';
+import {
+ GroupTransformer,
+ OrganizationTransformer,
+ ProviderConfigTransformer,
+ UserTransformer,
+ readProviderConfigs,
+} from '@backstage/plugin-catalog-backend-module-msgraph';
+import { HumanDuration } from '@backstage/types';
+import {
+ MicrosoftGraphIncrementalEntityProvider,
+ MSGraphContext,
+ MSGraphCursor,
+} from '../MicrosoftGraphIncrementalEntityProvider';
+
+/**
+ * Interface for
+ * {@link microsoftGraphIncrementalEntityProviderTransformExtensionPoint}.
+ *
+ * @public
+ */
+export interface MicrosoftGraphIncrementalEntityProviderTransformsExtensionPoint {
+ /**
+ * Set the function that transforms a user entry in msgraph to an entity.
+ * Optionally, you can pass separate transformers per provider ID.
+ */
+ setUserTransformer(
+ transformer: UserTransformer | Record,
+ ): void;
+
+ /**
+ * Set the function that transforms a group entry in msgraph to an entity.
+ * Optionally, you can pass separate transformers per provider ID.
+ */
+ setGroupTransformer(
+ transformer: GroupTransformer | Record,
+ ): void;
+
+ /**
+ * Set the function that transforms an organization entry in msgraph to an
+ * entity. Optionally, you can pass separate transformers per provider ID.
+ */
+ setOrganizationTransformer(
+ transformer:
+ | OrganizationTransformer
+ | Record,
+ ): void;
+
+ /**
+ * Set the function that transforms provider config dynamically.
+ * Optionally, you can pass separate transformers per provider ID.
+ */
+ setProviderConfigTransformer(
+ transformer:
+ | ProviderConfigTransformer
+ | Record,
+ ): void;
+}
+
+/**
+ * Extension point used to customize the transforms applied by the incremental
+ * module.
+ *
+ * @public
+ */
+export const microsoftGraphIncrementalEntityProviderTransformExtensionPoint =
+ createExtensionPoint(
+ {
+ id: 'catalog.microsoftGraphIncrementalEntityProvider.transforms',
+ },
+ );
+
+/**
+ * Registers {@link MicrosoftGraphIncrementalEntityProvider} instances with the
+ * catalog's incremental ingestion extension point.
+ *
+ * This module requires `catalogModuleIncrementalIngestionEntityProvider` to
+ * also be installed in the backend.
+ *
+ * @example
+ * ```ts
+ * // packages/backend/src/index.ts
+ * backend.add(import('@backstage/plugin-catalog-backend-module-incremental-ingestion'));
+ * backend.add(import('@backstage/plugin-catalog-backend-module-msgraph-incremental'));
+ * ```
+ *
+ * @public
+ */
+export const catalogModuleMicrosoftGraphIncrementalEntityProvider =
+ createBackendModule({
+ pluginId: 'catalog',
+ moduleId: 'microsoftGraphIncrementalEntityProvider',
+ register(env) {
+ let userTransformer:
+ | UserTransformer
+ | Record
+ | undefined;
+ let groupTransformer:
+ | GroupTransformer
+ | Record
+ | undefined;
+ let organizationTransformer:
+ | OrganizationTransformer
+ | Record
+ | undefined;
+ let providerConfigTransformer:
+ | ProviderConfigTransformer
+ | Record
+ | undefined;
+
+ env.registerExtensionPoint(
+ microsoftGraphIncrementalEntityProviderTransformExtensionPoint,
+ {
+ setUserTransformer(transformer) {
+ if (userTransformer) {
+ throw new Error('User transformer may only be set once');
+ }
+ userTransformer = transformer;
+ },
+ setGroupTransformer(transformer) {
+ if (groupTransformer) {
+ throw new Error('Group transformer may only be set once');
+ }
+ groupTransformer = transformer;
+ },
+ setOrganizationTransformer(transformer) {
+ if (organizationTransformer) {
+ throw new Error('Organization transformer may only be set once');
+ }
+ organizationTransformer = transformer;
+ },
+ setProviderConfigTransformer(transformer) {
+ if (providerConfigTransformer) {
+ throw new Error(
+ 'Provider config transformer may only be set once',
+ );
+ }
+ providerConfigTransformer = transformer;
+ },
+ },
+ );
+
+ env.registerInit({
+ deps: {
+ config: coreServices.rootConfig,
+ logger: coreServices.logger,
+ incremental: incrementalIngestionProvidersExtensionPoint,
+ },
+ async init({ config, logger, incremental }) {
+ const providerConfigs = readProviderConfigs(config);
+
+ for (const providerConfig of providerConfigs) {
+ const provider = new MicrosoftGraphIncrementalEntityProvider({
+ id: providerConfig.id,
+ provider: providerConfig,
+ logger,
+ userTransformer: resolveTransformer(
+ providerConfig.id,
+ userTransformer,
+ ),
+ groupTransformer: resolveTransformer(
+ providerConfig.id,
+ groupTransformer,
+ ),
+ organizationTransformer: resolveTransformer(
+ providerConfig.id,
+ organizationTransformer,
+ ),
+ providerConfigTransformer: resolveTransformer(
+ providerConfig.id,
+ providerConfigTransformer,
+ ),
+ });
+
+ const restLength = deriveRestLength(providerConfig, logger);
+
+ incremental.addProvider({
+ provider,
+ options: {
+ burstInterval: { seconds: 3 },
+ burstLength: { minutes: 5 },
+ restLength,
+ backoff: [
+ { seconds: 30 },
+ { minutes: 3 },
+ { minutes: 30 },
+ { hours: 3 },
+ ],
+ },
+ });
+ }
+ },
+ });
+ },
+ });
+
+function resolveTransformer(
+ id: string,
+ transformer?: T | Record,
+): T | undefined {
+ if (['undefined', 'function'].includes(typeof transformer)) {
+ return transformer as T;
+ }
+ return (transformer as Record)[id];
+}
+
+function deriveRestLength(
+ providerConfig: ReturnType[number],
+ logger: LoggerService,
+): HumanDuration {
+ const freq = providerConfig.schedule?.frequency;
+ // Only treat plain duration objects as restLength â exclude cron expressions
+ // and any other non-duration schedule types (e.g. manual triggers).
+ if (
+ freq &&
+ typeof freq === 'object' &&
+ !('cron' in freq) &&
+ !('trigger' in freq)
+ ) {
+ return freq as HumanDuration;
+ }
+ if (freq) {
+ logger.warn(
+ `MicrosoftGraphIncrementalEntityProvider:${providerConfig.id}: ` +
+ `schedule.frequency is not a duration-based schedule; cannot derive restLength from it. ` +
+ `Defaulting restLength to 8 hours.`,
+ );
+ }
+ return { hours: 8 };
+}
diff --git a/plugins/catalog-backend-module-msgraph-incremental/src/module/index.ts b/plugins/catalog-backend-module-msgraph-incremental/src/module/index.ts
new file mode 100644
index 0000000000..988418dd9b
--- /dev/null
+++ b/plugins/catalog-backend-module-msgraph-incremental/src/module/index.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export {
+ catalogModuleMicrosoftGraphIncrementalEntityProvider,
+ microsoftGraphIncrementalEntityProviderTransformExtensionPoint,
+ type MicrosoftGraphIncrementalEntityProviderTransformsExtensionPoint,
+} from './catalogModuleMicrosoftGraphIncrementalEntityProvider';
diff --git a/plugins/catalog-backend-module-msgraph/CHANGELOG.md b/plugins/catalog-backend-module-msgraph/CHANGELOG.md
index b4c06ef83c..e1ab7dc63e 100644
--- a/plugins/catalog-backend-module-msgraph/CHANGELOG.md
+++ b/plugins/catalog-backend-module-msgraph/CHANGELOG.md
@@ -1,5 +1,30 @@
# @backstage/plugin-catalog-backend-module-msgraph
+## 0.10.0
+
+### Minor Changes
+
+- 2bd0450: **BREAKING**: Disabled user accounts are now filtered out by default. The provider automatically applies an `accountEnabled eq true` filter, combining it with any custom `user.filter` you provide. If you previously included `accountEnabled eq true` in your user filter, it is safe to remove it, but leaving it in will not cause any issues.
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/config@1.3.8
+ - @backstage/plugin-catalog-common@1.1.10
+
+## 0.9.3-next.1
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.8.1-next.1
+ - @backstage/plugin-catalog-node@2.2.1-next.1
+
## 0.9.3-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-msgraph/README.md b/plugins/catalog-backend-module-msgraph/README.md
index 8e8c84de37..da466f7553 100644
--- a/plugins/catalog-backend-module-msgraph/README.md
+++ b/plugins/catalog-backend-module-msgraph/README.md
@@ -52,8 +52,9 @@ catalog:
# Optional filter for user, see Microsoft Graph API for the syntax
# See https://docs.microsoft.com/en-us/graph/api/resources/user?view=graph-rest-1.0#properties
# and for the syntax https://docs.microsoft.com/en-us/graph/query-parameters#filter-parameter
- # This and userGroupMemberFilter are mutually exclusive, only one can be specified
- filter: accountEnabled eq true and userType eq 'member'
+ # This is combined with the base `accountEnabled eq true` filter
+ # that is always applied automatically.
+ filter: userType eq 'member'
# Set to false to not load user photos.
loadPhotos: true
# See https://docs.microsoft.com/en-us/graph/api/resources/schemaextension?view=graph-rest-1.0
@@ -64,12 +65,10 @@ catalog:
userGroupMember:
# Optional filter for users, use group membership to get users.
# (Filtered groups and fetch their members.)
- # This and userFilter are mutually exclusive, only one can be specified
# See https://docs.microsoft.com/en-us/graph/search-query-parameter
filter: "displayName eq 'Backstage Users'"
# Optional search for users, use group membership to get users.
# (Search for groups and fetch their members.)
- # This and userFilter are mutually exclusive, only one can be specified
search: '"description:One" AND ("displayName:Video" OR "displayName:Drive")'
# Optional /groups by default but allow to query groups from different msgraph endpoints
path: /groups
@@ -102,9 +101,7 @@ catalog:
initialDelay: { seconds: 15},
```
-`user.filter` and `userGroupMember.filter` are mutually exclusive, only one can be provided. If both are provided, an error will be thrown.
-
-By default, all users are loaded. If you want to filter users based on their attributes, use `user.filter`. `userGroupMember.filter` can be used if you want to load users based on their group membership.
+By default, all enabled users are loaded (disabled accounts are automatically filtered out). If you want to further filter users based on their attributes, use `user.filter`. `userGroupMember.filter` can be used if you want to load users based on their group membership.
3. The package is not installed by default, therefore you have to add a
dependency to `@backstage/plugin-catalog-backend-module-msgraph` to your
diff --git a/plugins/catalog-backend-module-msgraph/config.d.ts b/plugins/catalog-backend-module-msgraph/config.d.ts
index dfcad06593..dec4532599 100644
--- a/plugins/catalog-backend-module-msgraph/config.d.ts
+++ b/plugins/catalog-backend-module-msgraph/config.d.ts
@@ -60,8 +60,9 @@ export interface Config {
/**
* The filter to apply to extract users.
+ * Combined with the base `accountEnabled eq true` filter.
*
- * E.g. "accountEnabled eq true and userType eq 'member'"
+ * E.g. "userType eq 'member'"
*/
userFilter?: string;
/**
@@ -162,8 +163,9 @@ export interface Config {
expand?: string;
/**
* The filter to apply to extract users.
+ * Combined with the base `accountEnabled eq true` filter
*
- * E.g. "accountEnabled eq true and userType eq 'member'"
+ * E.g. "userType eq 'member'"
*/
filter?: string;
/**
@@ -295,8 +297,9 @@ export interface Config {
expand?: string;
/**
* The filter to apply to extract users.
+ * Combined with the base `accountEnabled eq true` filter
*
- * E.g. "accountEnabled eq true and userType eq 'member'"
+ * E.g. "userType eq 'member'"
*/
filter?: string;
/**
diff --git a/plugins/catalog-backend-module-msgraph/package.json b/plugins/catalog-backend-module-msgraph/package.json
index b016f3ce7b..5efe9b875c 100644
--- a/plugins/catalog-backend-module-msgraph/package.json
+++ b/plugins/catalog-backend-module-msgraph/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-msgraph",
- "version": "0.9.3-next.0",
+ "version": "0.10.0",
"description": "A Backstage catalog backend module that helps integrate towards Microsoft Graph",
"backstage": {
"role": "backend-plugin-module",
@@ -60,8 +60,7 @@
"@microsoft/microsoft-graph-types": "^2.6.0",
"lodash": "^4.17.21",
"p-limit": "^3.0.2",
- "qs": "^6.9.4",
- "uuid": "^11.0.0"
+ "qs": "^6.9.4"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts
index 182c4842dd..6d9a6cc332 100644
--- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts
+++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts
@@ -34,6 +34,7 @@ describe('readMicrosoftGraphConfig', () => {
id: 'target',
target: 'target',
tenantId: 'tenantId',
+ userFilter: 'accountEnabled eq true',
},
];
expect(actual).toEqual(expected);
@@ -50,7 +51,7 @@ describe('readMicrosoftGraphConfig', () => {
clientSecret: 'clientSecret',
authority: 'https://login.example.com/',
userExpand: 'manager',
- userFilter: 'accountEnabled eq true',
+ userFilter: "userType eq 'member'",
userSelect: ['id', 'displayName', 'department'],
groupExpand: 'member',
groupSelect: ['id', 'displayName', 'description'],
@@ -68,7 +69,7 @@ describe('readMicrosoftGraphConfig', () => {
clientSecret: 'clientSecret',
authority: 'https://login.example.com/',
userExpand: 'manager',
- userFilter: 'accountEnabled eq true',
+ userFilter: "accountEnabled eq true and (userType eq 'member')",
userSelect: ['id', 'displayName', 'department'],
groupExpand: 'member',
groupSelect: ['id', 'displayName', 'description'],
@@ -78,32 +79,6 @@ describe('readMicrosoftGraphConfig', () => {
expect(actual).toEqual(expected);
});
- it('should fail if both userFilter and userGroupMemberFilter are set', () => {
- const config = {
- providers: [
- {
- tenantId: 'tenantId',
- userFilter: 'accountEnabled eq true',
- userGroupMemberFilter: 'any',
- },
- ],
- };
- expect(() => readMicrosoftGraphConfig(new ConfigReader(config))).toThrow();
- });
-
- it('should fail if both userFilter and userGroupMemberSearch are set', () => {
- const config = {
- providers: [
- {
- tenantId: 'tenantId',
- userFilter: 'accountEnabled eq true',
- userGroupMemberSearch: 'any',
- },
- ],
- };
- expect(() => readMicrosoftGraphConfig(new ConfigReader(config))).toThrow();
- });
-
it('should fail if clientId is set without clientSecret', () => {
const config = {
providers: [
@@ -148,6 +123,7 @@ describe('readProviderConfigs', () => {
id: 'customProviderId',
target: 'https://graph.microsoft.com/v1.0',
tenantId: 'tenantId',
+ userFilter: 'accountEnabled eq true',
userPath: 'users',
groupPath: 'groups',
},
@@ -169,7 +145,7 @@ describe('readProviderConfigs', () => {
queryMode: 'advanced',
user: {
expand: 'manager',
- filter: 'accountEnabled eq true',
+ filter: "userType eq 'member'",
select: ['id', 'displayName', 'department'],
path: '/groups/{groupId}/members',
},
@@ -202,7 +178,7 @@ describe('readProviderConfigs', () => {
authority: 'https://login.example.com/',
queryMode: 'advanced',
userExpand: 'manager',
- userFilter: 'accountEnabled eq true',
+ userFilter: "accountEnabled eq true and (userType eq 'member')",
userSelect: ['id', 'displayName', 'department'],
userPath: '/groups/{groupId}/members',
groupExpand: 'member',
@@ -221,7 +197,7 @@ describe('readProviderConfigs', () => {
expect(actual).toEqual(expected);
});
- it('should fail if both userFilter and userGroupMemberFilter are set', () => {
+ it('should combine custom filter with accountEnabled filter by default', () => {
const config = {
catalog: {
providers: {
@@ -229,38 +205,17 @@ describe('readProviderConfigs', () => {
customProviderId: {
tenantId: 'tenantId',
user: {
- filter: 'accountEnabled eq true',
- },
- userGroupMember: {
- filter: 'any',
+ filter: "userType eq 'member'",
},
},
},
},
},
};
- expect(() => readProviderConfigs(new ConfigReader(config))).toThrow();
- });
-
- it('should fail if both userFilter and userGroupMemberSearch are set', () => {
- const config = {
- catalog: {
- providers: {
- microsoftGraphOrg: {
- customProviderId: {
- tenantId: 'tenantId',
- user: {
- filter: 'accountEnabled eq true',
- },
- userGroupMember: {
- search: 'any',
- },
- },
- },
- },
- },
- };
- expect(() => readProviderConfigs(new ConfigReader(config))).toThrow();
+ const [result] = readProviderConfigs(new ConfigReader(config));
+ expect(result.userFilter).toBe(
+ "accountEnabled eq true and (userType eq 'member')",
+ );
});
it('should fail if clientId is set without clientSecret', () => {
diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts
index 5feabcb9b2..0eb939022e 100644
--- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts
+++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts
@@ -61,9 +61,10 @@ export type MicrosoftGraphProviderConfig = {
*/
clientSecret?: string;
/**
- * The filter to apply to extract users.
+ * The filter to apply to extract users. This is combined with the base
+ * `accountEnabled eq true` filter that is always applied automatically.
*
- * E.g. "accountEnabled eq true and userType eq 'member'"
+ * E.g. "userType eq 'member'"
*/
userFilter?: string;
/**
@@ -192,7 +193,9 @@ export function readMicrosoftGraphConfig(
const clientSecret = providerConfig.getOptionalString('clientSecret');
const userExpand = providerConfig.getOptionalString('userExpand');
- const userFilter = providerConfig.getOptionalString('userFilter');
+ const userFilter = buildUserFilter(
+ providerConfig.getOptionalString('userFilter'),
+ );
const userSelect = providerConfig.getOptionalStringArray('userSelect');
const userGroupMemberFilter = providerConfig.getOptionalString(
'userGroupMemberFilter',
@@ -204,17 +207,6 @@ export function readMicrosoftGraphConfig(
const groupFilter = providerConfig.getOptionalString('groupFilter');
const groupSearch = providerConfig.getOptionalString('groupSearch');
- if (userFilter && userGroupMemberFilter) {
- throw new Error(
- `userFilter and userGroupMemberFilter are mutually exclusive, only one can be specified.`,
- );
- }
- if (userFilter && userGroupMemberSearch) {
- throw new Error(
- `userGroupMemberSearch cannot be specified when userFilter is defined.`,
- );
- }
-
const groupSelect = providerConfig.getOptionalStringArray('groupSelect');
const queryMode = providerConfig.getOptionalString('queryMode');
if (
@@ -312,7 +304,7 @@ export function readProviderConfig(
const clientSecret = config.getOptionalString('clientSecret');
const userExpand = config.getOptionalString('user.expand');
- const userFilter = config.getOptionalString('user.filter');
+ const userFilter = buildUserFilter(config.getOptionalString('user.filter'));
const userSelect = config.getOptionalStringArray('user.select');
const userPath = config.getOptionalString('user.path') ?? 'users';
const loadUserPhotos = config.getOptionalBoolean('user.loadPhotos');
@@ -343,17 +335,6 @@ export function readProviderConfig(
);
const userGroupMemberPath = config.getOptionalString('userGroupMember.path');
- if (userFilter && userGroupMemberFilter) {
- throw new Error(
- `userFilter and userGroupMemberFilter are mutually exclusive, only one can be specified.`,
- );
- }
- if (userFilter && userGroupMemberSearch) {
- throw new Error(
- `userGroupMemberSearch cannot be specified when userFilter is defined.`,
- );
- }
-
if (clientId && !clientSecret) {
throw new Error(`clientSecret must be provided when clientId is defined.`);
}
@@ -393,3 +374,11 @@ export function readProviderConfig(
schedule,
};
}
+
+function buildUserFilter(rawFilter: string | undefined): string {
+ const base = 'accountEnabled eq true';
+ if (rawFilter) {
+ return `${base} and (${rawFilter})`;
+ }
+ return base;
+}
diff --git a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts
index a6f2ee78d7..572931537d 100644
--- a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts
+++ b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts
@@ -25,7 +25,7 @@ import {
EntityProviderConnection,
} from '@backstage/plugin-catalog-node';
import { merge } from 'lodash';
-import * as uuid from 'uuid';
+import { randomUUID } from 'node:crypto';
import {
GroupTransformer,
MICROSOFT_GRAPH_GROUP_ID_ANNOTATION,
@@ -391,7 +391,7 @@ export class MicrosoftGraphOrgEntityProvider implements EntityProvider {
const logger = this.options.logger.child({
class: MicrosoftGraphOrgEntityProvider.prototype.constructor.name,
taskId: id,
- taskInstanceId: uuid.v4(),
+ taskInstanceId: randomUUID(),
});
try {
diff --git a/plugins/catalog-backend-module-openapi/CHANGELOG.md b/plugins/catalog-backend-module-openapi/CHANGELOG.md
index 99edffa616..fbf4c3f3ca 100644
--- a/plugins/catalog-backend-module-openapi/CHANGELOG.md
+++ b/plugins/catalog-backend-module-openapi/CHANGELOG.md
@@ -1,5 +1,16 @@
# @backstage/plugin-catalog-backend-module-openapi
+## 0.2.22
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/integration@2.0.2
+ - @backstage/plugin-catalog-common@1.1.10
+
## 0.2.22-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-openapi/package.json b/plugins/catalog-backend-module-openapi/package.json
index ebc51640aa..1755c1c4de 100644
--- a/plugins/catalog-backend-module-openapi/package.json
+++ b/plugins/catalog-backend-module-openapi/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-openapi",
- "version": "0.2.22-next.0",
+ "version": "0.2.22",
"description": "A Backstage catalog backend module that helps with OpenAPI specifications",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/catalog-backend-module-puppetdb/CHANGELOG.md b/plugins/catalog-backend-module-puppetdb/CHANGELOG.md
index 3de8962a8f..c29b342026 100644
--- a/plugins/catalog-backend-module-puppetdb/CHANGELOG.md
+++ b/plugins/catalog-backend-module-puppetdb/CHANGELOG.md
@@ -1,5 +1,26 @@
# @backstage/plugin-catalog-backend-module-puppetdb
+## 0.2.22
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/config@1.3.8
+
+## 0.2.22-next.1
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.8.1-next.1
+ - @backstage/plugin-catalog-node@2.2.1-next.1
+
## 0.2.22-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-puppetdb/package.json b/plugins/catalog-backend-module-puppetdb/package.json
index 8adf244bff..6be8c4c811 100644
--- a/plugins/catalog-backend-module-puppetdb/package.json
+++ b/plugins/catalog-backend-module-puppetdb/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-puppetdb",
- "version": "0.2.22-next.0",
+ "version": "0.2.22",
"description": "A Backstage catalog backend module that helps integrate towards PuppetDB",
"backstage": {
"role": "backend-plugin-module",
@@ -55,8 +55,7 @@
"@backstage/errors": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"@backstage/types": "workspace:^",
- "lodash": "^4.17.21",
- "uuid": "^11.0.0"
+ "lodash": "^4.17.21"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
diff --git a/plugins/catalog-backend-module-puppetdb/src/providers/PuppetDbEntityProvider.ts b/plugins/catalog-backend-module-puppetdb/src/providers/PuppetDbEntityProvider.ts
index 8849e32c16..d44c447832 100644
--- a/plugins/catalog-backend-module-puppetdb/src/providers/PuppetDbEntityProvider.ts
+++ b/plugins/catalog-backend-module-puppetdb/src/providers/PuppetDbEntityProvider.ts
@@ -23,7 +23,7 @@ import {
readProviderConfigs,
} from './PuppetDbEntityProviderConfig';
import { Config } from '@backstage/config';
-import * as uuid from 'uuid';
+import { randomUUID } from 'node:crypto';
import { defaultResourceTransformer, ResourceTransformer } from '../puppet';
import {
ANNOTATION_LOCATION,
@@ -145,7 +145,7 @@ export class PuppetDbEntityProvider implements EntityProvider {
const logger = this.logger.child({
class: PuppetDbEntityProvider.prototype.constructor.name,
taskId,
- taskInstanceId: uuid.v4(),
+ taskInstanceId: randomUUID(),
});
try {
await this.refresh(logger);
diff --git a/plugins/catalog-backend-module-scaffolder-entity-model/CHANGELOG.md b/plugins/catalog-backend-module-scaffolder-entity-model/CHANGELOG.md
index 674c6d1097..e67b0c5dfe 100644
--- a/plugins/catalog-backend-module-scaffolder-entity-model/CHANGELOG.md
+++ b/plugins/catalog-backend-module-scaffolder-entity-model/CHANGELOG.md
@@ -1,5 +1,24 @@
# @backstage/plugin-catalog-backend-module-scaffolder-entity-model
+## 0.2.20
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/plugin-scaffolder-common@2.2.0
+ - @backstage/plugin-catalog-common@1.1.10
+
+## 0.2.20-next.1
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1-next.1
+ - @backstage/plugin-scaffolder-common@2.2.0-next.1
+
## 0.2.20-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-scaffolder-entity-model/package.json b/plugins/catalog-backend-module-scaffolder-entity-model/package.json
index 102a846b70..8cf68ea392 100644
--- a/plugins/catalog-backend-module-scaffolder-entity-model/package.json
+++ b/plugins/catalog-backend-module-scaffolder-entity-model/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-scaffolder-entity-model",
- "version": "0.2.20-next.0",
+ "version": "0.2.20",
"description": "Adds support for the scaffolder specific entity model (e.g. the Template kind) to the catalog backend plugin.",
"backstage": {
"role": "backend-plugin-module",
diff --git a/plugins/catalog-backend-module-unprocessed/CHANGELOG.md b/plugins/catalog-backend-module-unprocessed/CHANGELOG.md
index 224a43e8dc..c0022b5ab4 100644
--- a/plugins/catalog-backend-module-unprocessed/CHANGELOG.md
+++ b/plugins/catalog-backend-module-unprocessed/CHANGELOG.md
@@ -1,5 +1,27 @@
# @backstage/plugin-catalog-backend-module-unprocessed
+## 0.6.12
+
+### Patch Changes
+
+- fa06df6: Added permission authorization checks to the unprocessed entities read endpoints for pending and failed entities.
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/plugin-auth-node@0.7.1
+ - @backstage/plugin-permission-common@0.9.9
+ - @backstage/plugin-catalog-unprocessed-entities-common@0.0.16
+
+## 0.6.12-next.1
+
+### Patch Changes
+
+- fa06df6: Added permission authorization checks to the unprocessed entities read endpoints for pending and failed entities.
+- Updated dependencies
+ - @backstage/plugin-catalog-unprocessed-entities-common@0.0.16-next.1
+
## 0.6.11-next.0
### Patch Changes
diff --git a/plugins/catalog-backend-module-unprocessed/package.json b/plugins/catalog-backend-module-unprocessed/package.json
index 9aca307ffb..805ce7999a 100644
--- a/plugins/catalog-backend-module-unprocessed/package.json
+++ b/plugins/catalog-backend-module-unprocessed/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend-module-unprocessed",
- "version": "0.6.11-next.0",
+ "version": "0.6.12",
"description": "Backstage Catalog module to view unprocessed entities",
"backstage": {
"role": "backend-plugin-module",
@@ -44,7 +44,12 @@
"knex": "^3.0.0"
},
"devDependencies": {
+ "@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
- "@types/express": "^4.17.6"
+ "@types/express": "^4.17.6",
+ "@types/supertest": "^2.0.8",
+ "better-sqlite3": "^12.0.0",
+ "express": "^4.22.0",
+ "supertest": "^7.0.0"
}
}
diff --git a/plugins/catalog-backend-module-unprocessed/src/UnprocessedEntitiesModule.test.ts b/plugins/catalog-backend-module-unprocessed/src/UnprocessedEntitiesModule.test.ts
new file mode 100644
index 0000000000..5cfd2a8761
--- /dev/null
+++ b/plugins/catalog-backend-module-unprocessed/src/UnprocessedEntitiesModule.test.ts
@@ -0,0 +1,225 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import express from 'express';
+import request from 'supertest';
+import knex, { Knex } from 'knex';
+import { UnprocessedEntitiesModule } from './UnprocessedEntitiesModule';
+import {
+ AuthorizeResult,
+ type PermissionEvaluator,
+} from '@backstage/plugin-permission-common';
+import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
+
+describe('UnprocessedEntitiesModule', () => {
+ let db: Knex;
+ let app: express.Express;
+ let mockPermissions: jest.Mocked;
+
+ beforeEach(async () => {
+ db = knex({
+ client: 'better-sqlite3',
+ connection: { filename: ':memory:' },
+ useNullAsDefault: true,
+ });
+
+ await db.schema.createTable('refresh_state', table => {
+ table.string('entity_id').primary();
+ table.string('entity_ref');
+ table.text('unprocessed_entity');
+ table.text('unprocessed_hash').nullable();
+ table.text('processed_entity').nullable();
+ table.text('result_hash').nullable();
+ table.text('cache').nullable();
+ table.text('errors').nullable();
+ table.text('location_key').nullable();
+ table.string('next_update_at');
+ table.string('last_discovery_at');
+ });
+
+ await db.schema.createTable('final_entities', table => {
+ table.string('entity_id').primary();
+ table.text('final_entity').nullable();
+ });
+
+ const now = new Date().toISOString();
+
+ await db('refresh_state').insert({
+ entity_id: 'pending-entity',
+ entity_ref: 'component:default/pending',
+ unprocessed_entity: JSON.stringify({
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'Component',
+ metadata: { name: 'pending' },
+ spec: { owner: 'group:default/team-a', type: 'service' },
+ }),
+ next_update_at: now,
+ last_discovery_at: now,
+ });
+
+ await db('refresh_state').insert({
+ entity_id: 'failed-entity',
+ entity_ref: 'component:default/failed',
+ unprocessed_entity: JSON.stringify({
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'Component',
+ metadata: { name: 'failed' },
+ spec: { owner: 'group:default/team-a', type: 'service' },
+ }),
+ errors: JSON.stringify([{ message: 'something broke' }]),
+ next_update_at: now,
+ last_discovery_at: now,
+ });
+
+ await db('final_entities').insert({
+ entity_id: 'failed-entity',
+ final_entity: null,
+ });
+
+ mockPermissions = {
+ authorize: jest.fn(),
+ authorizeConditional: jest.fn(),
+ };
+
+ app = express();
+ const router = express.Router();
+ app.use(router);
+
+ const module = UnprocessedEntitiesModule.create({
+ database: db,
+ router: { use: handler => router.use(handler) },
+ permissions: mockPermissions,
+ httpAuth: mockServices.httpAuth(),
+ });
+ module.registerRoutes();
+
+ app.use(
+ (
+ err: Error & { statusCode?: number },
+ _req: express.Request,
+ res: express.Response,
+ _next: express.NextFunction,
+ ) => {
+ const status =
+ err.statusCode ?? (err.name === 'NotAllowedError' ? 403 : 500);
+ res.status(status).json({ error: { name: err.name } });
+ },
+ );
+ });
+
+ afterEach(async () => {
+ await db.destroy();
+ });
+
+ describe('GET /entities/unprocessed/pending', () => {
+ it('returns pending entities when authorized', async () => {
+ mockPermissions.authorize.mockResolvedValue([
+ { result: AuthorizeResult.ALLOW },
+ ]);
+
+ const response = await request(app)
+ .get('/entities/unprocessed/pending')
+ .auth(mockCredentials.user.token(), { type: 'bearer' });
+
+ expect(response.status).toBe(200);
+ expect(response.body.type).toBe('pending');
+ expect(response.body.entities).toHaveLength(1);
+ expect(response.body.entities[0].entity_ref).toBe(
+ 'component:default/pending',
+ );
+ expect(mockPermissions.authorize).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns 403 when not authorized', async () => {
+ mockPermissions.authorize.mockResolvedValue([
+ { result: AuthorizeResult.DENY },
+ ]);
+
+ const response = await request(app)
+ .get('/entities/unprocessed/pending')
+ .auth(mockCredentials.user.token(), { type: 'bearer' });
+
+ expect(response.status).toBe(403);
+ expect(mockPermissions.authorize).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('GET /entities/unprocessed/failed', () => {
+ it('returns failed entities when authorized', async () => {
+ mockPermissions.authorize.mockResolvedValue([
+ { result: AuthorizeResult.ALLOW },
+ ]);
+
+ const response = await request(app)
+ .get('/entities/unprocessed/failed')
+ .auth(mockCredentials.user.token(), { type: 'bearer' });
+
+ expect(response.status).toBe(200);
+ expect(response.body.type).toBe('failed');
+ expect(response.body.entities).toHaveLength(1);
+ expect(response.body.entities[0].entity_ref).toBe(
+ 'component:default/failed',
+ );
+ expect(mockPermissions.authorize).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns 403 when not authorized', async () => {
+ mockPermissions.authorize.mockResolvedValue([
+ { result: AuthorizeResult.DENY },
+ ]);
+
+ const response = await request(app)
+ .get('/entities/unprocessed/failed')
+ .auth(mockCredentials.user.token(), { type: 'bearer' });
+
+ expect(response.status).toBe(403);
+ expect(mockPermissions.authorize).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('DELETE /entities/unprocessed/delete/:entity_id', () => {
+ it('deletes entity when authorized', async () => {
+ mockPermissions.authorize.mockResolvedValue([
+ { result: AuthorizeResult.ALLOW },
+ ]);
+
+ const response = await request(app)
+ .delete('/entities/unprocessed/delete/failed-entity')
+ .auth(mockCredentials.user.token(), { type: 'bearer' });
+
+ expect(response.status).toBe(204);
+ expect(mockPermissions.authorize).toHaveBeenCalledTimes(1);
+
+ const remaining = await db('refresh_state')
+ .where({ entity_id: 'failed-entity' })
+ .select();
+ expect(remaining).toHaveLength(0);
+ });
+
+ it('returns 403 when not authorized', async () => {
+ mockPermissions.authorize.mockResolvedValue([
+ { result: AuthorizeResult.DENY },
+ ]);
+
+ const response = await request(app)
+ .delete('/entities/unprocessed/delete/failed-entity')
+ .auth(mockCredentials.user.token(), { type: 'bearer' });
+
+ expect(response.status).toBe(403);
+ expect(mockPermissions.authorize).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/plugins/catalog-backend-module-unprocessed/src/UnprocessedEntitiesModule.ts b/plugins/catalog-backend-module-unprocessed/src/UnprocessedEntitiesModule.ts
index 91ecd45bf5..62310aa14e 100644
--- a/plugins/catalog-backend-module-unprocessed/src/UnprocessedEntitiesModule.ts
+++ b/plugins/catalog-backend-module-unprocessed/src/UnprocessedEntitiesModule.ts
@@ -33,7 +33,10 @@ import {
AuthorizeResult,
BasicPermission,
} from '@backstage/plugin-permission-common';
-import { unprocessedEntitiesDeletePermission } from '@backstage/plugin-catalog-unprocessed-entities-common';
+import {
+ unprocessedEntitiesDeletePermission,
+ unprocessedEntitiesReadPermission,
+} from '@backstage/plugin-catalog-unprocessed-entities-common';
import { NotAllowedError } from '@backstage/errors';
/**
@@ -158,6 +161,14 @@ export class UnprocessedEntitiesModule {
this.moduleRouter
.get('/entities/unprocessed/failed', async (req, res) => {
+ const authorized = await isRequestAuthorized(
+ req,
+ unprocessedEntitiesReadPermission,
+ );
+ if (!authorized) {
+ throw new NotAllowedError('Unauthorized');
+ }
+
return res.json(
await this.unprocessed({
reason: 'failed',
@@ -167,6 +178,14 @@ export class UnprocessedEntitiesModule {
);
})
.get('/entities/unprocessed/pending', async (req, res) => {
+ const authorized = await isRequestAuthorized(
+ req,
+ unprocessedEntitiesReadPermission,
+ );
+ if (!authorized) {
+ throw new NotAllowedError('Unauthorized');
+ }
+
return res.json(
await this.unprocessed({
reason: 'pending',
diff --git a/plugins/catalog-backend-module-unprocessed/src/module.ts b/plugins/catalog-backend-module-unprocessed/src/module.ts
index 1b46cb4448..1d5697f529 100644
--- a/plugins/catalog-backend-module-unprocessed/src/module.ts
+++ b/plugins/catalog-backend-module-unprocessed/src/module.ts
@@ -19,7 +19,10 @@ import {
createBackendModule,
} from '@backstage/backend-plugin-api';
import { UnprocessedEntitiesModule } from './UnprocessedEntitiesModule';
-import { unprocessedEntitiesDeletePermission } from '@backstage/plugin-catalog-unprocessed-entities-common';
+import {
+ unprocessedEntitiesDeletePermission,
+ unprocessedEntitiesReadPermission,
+} from '@backstage/plugin-catalog-unprocessed-entities-common';
/**
* Catalog Module for Unprocessed Entities
@@ -55,6 +58,7 @@ export const catalogModuleUnprocessedEntities = createBackendModule({
});
permissionsRegistry.addPermissions([
+ unprocessedEntitiesReadPermission,
unprocessedEntitiesDeletePermission,
]);
diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md
index 127db42518..023fb1d113 100644
--- a/plugins/catalog-backend/CHANGELOG.md
+++ b/plugins/catalog-backend/CHANGELOG.md
@@ -1,5 +1,148 @@
# @backstage/plugin-catalog-backend
+## 3.7.0
+
+### Minor Changes
+
+- c2de113: **BREAKING**: When paginating entities with an order field via `/entities/by-query`, entities that lack the order field are now excluded from both the result set and the `totalItems` count. Previously these entities appeared at the end of the sorted result via `NULLS LAST`, but cursor-based pagination could not actually reach them past the first page â the count over-reported the number of navigable entities. The new behavior aligns the count with what is actually returned.
+
+ This also removes the `DISTINCT` deduplication from the sort-field CTE, which is a prerequisite for the planner to use the `(key, value, entity_id)` index in sort order and short-circuit on `LIMIT`. Installations with duplicate search rows should land the search-table deduplication migration before adopting this change.
+
+### Patch Changes
+
+- 3f5e7ec: Added `catalog.actions.experimentalCatalogLayersDescriptions.enabled` config option. When enabled, the `query-catalog-entities` action description references `get-catalog-model-description` for field information instead of embedding a static model description.
+- ccbad9d: Improved the performance of the `catalog_entities_count` metric.
+
+ The legacy Prometheus and OpenTelemetry observable gauges previously each ran their own copy of the per-kind count query against the `search` table on every metrics scrape. On large catalogs this could pile up faster than the queries completed, contending for buffers and stalling the database.
+
+ The two callbacks now share a single query result with a short in-process TTL cache, and the underlying query reads from `final_entities` instead of `search`, avoiding the bitmap heap scans that dominated the previous form. The emitted labels and values are unchanged.
+
+- 17a9550: Deprecated immediate mode stitching (`catalog.stitchingStrategy.mode: 'immediate'`). A warning is now logged on startup when immediate mode is configured. Immediate mode will be removed in the next Backstage release. Migrate to deferred mode (the default) by removing the `stitchingStrategy` configuration or setting `mode: 'deferred'`.
+- add5d1a: Restructured the entity listing endpoint so that, when a sort field is specified, the search-by-key index drives the query rather than being side-joined onto `final_entities`. This lets PostgreSQL walk the `(key, value, entity_id)` index in already-sorted order and short-circuit on `LIMIT`, reducing typical broad-filter paginated list times from seconds to milliseconds. Entities that lack the sort field still appear at the end of sorted results (NULLS LAST semantics preserved), ordered by `entity_id`.
+- 387ea7d: Simplified the entity facets aggregation from `COUNT(DISTINCT entity_id)` to `COUNT(*)`. The unique constraint on `(entity_id, key, value)` guarantees each entity appears at most once per search row group, making the `DISTINCT` unnecessary. This allows the database to use a simpler aggregation plan.
+- 3f55b73: Improved the performance of the entity facets endpoint when filters are applied. The filtered entity set is now combined with the search table through an inner join rather than a `WHERE entity_id IN (subquery)`. Results are unchanged; on large catalogs the query planner is able to choose dramatically cheaper plans, with measured improvements ranging from roughly 1.2Ã on already-fast cases to 7Ã or more on high-cardinality facets.
+- b33f845: Fixed several database migration `down` functions that were not properly reversible, causing the SQL report to show warnings:
+
+ - `20241003170511_alter_target_in_locations.js`: both `up` and `down` now include `.notNullable()` when altering the `locations.target` column, preventing the `NOT NULL` constraint from being accidentally dropped when widening the column type from `varchar(255)` to `text`.
+ - `20220116144621_remove_legacy.js`: the `down` function now properly recreates the three dropped legacy tables (`entities`, `entities_search`, `entities_relations`) with correct columns and indices.
+ - `20210302150147_refresh_state.js`: the `down` function now drops dependent tables in the correct order (avoiding a FK constraint violation) and fixes a typo where the table was referred to as `references` instead of `refresh_state_references`.
+ - `20201005122705_add_entity_full_name.js`: the `down` function now drops the `full_name` column from `entities` (not `entities_search`), and restores the `entities_unique_name` index with the correct column order `(kind, name, namespace)`.
+ - `20200702153613_entities.js`: the `down` function now uses `table.integer('generation')` instead of `table.string('generation')`, restoring the correct column type.
+
+- cde3643: Added missing description to the `type` parameter on the `unregister-entity` MCP action.
+- cf195de: Fixed a performance regression in the `/entity-facets` endpoint when filters or permission conditions are applied, by routing the EXISTS-based filter through `final_entities` instead of correlating against the much larger `search` table.
+- 07ec25d: Moved `generateStableHash` out of shared utility file to avoid pulling `node:crypto` into browser bundles
+- bc32c13: Added a missing index on `relations.target_entity_ref`. Several query paths (orphan deletion, entity ancestry, eager pruning) join or filter on this column, but no index existed â causing full sequential scans of the relations table on every invocation. On a production catalog with ~3.5M relation rows, individual lookups were taking ~122ms (full table scan) instead of <1ms (index scan).
+- 744fa1f: Removed duplicated entries that appeared in both `dependencies` and `devDependencies`.
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- 7445f0f: Added a migration that removes duplicate rows from the `search` table, creates covering indices for improved query performance, and adds a `UNIQUE` constraint on `(entity_id, key, value)`.
+
+ This is a long-running migration on large catalogs. On PostgreSQL with millions of search rows, the index creation may take 5-15 minutes per index. During this time, other pods running the previous version will continue to serve traffic normally â the index creation does not block reads or writes. However, if a Kubernetes liveness probe kills the pod before the index build completes, the build is lost and the next startup will start over. On large tables this can repeat indefinitely.
+
+ **For large installations**, it is recommended to run the following SQL commands against your PostgreSQL database **before deploying** this version. Each index build takes a few minutes but does not block reads or writes. If these have already completed, the migration will detect the existing indices and skip all work â startup will be instant.
+
+ ```sql
+ -- Step 1: Remove duplicate search rows
+ WITH cte AS (
+ SELECT ctid, row_number() OVER (PARTITION BY entity_id, key, value) AS rn
+ FROM search
+ )
+ DELETE FROM search USING cte WHERE search.ctid = cte.ctid AND cte.rn > 1;
+
+ -- Step 2: Create new indices (run each separately)
+ CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS
+ search_entity_key_value_idx ON search (entity_id, key, value);
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
+ search_key_value_entity_idx ON search (key, value, entity_id);
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
+ search_facets_covering_idx ON search (key, original_value, entity_id)
+ WHERE original_value IS NOT NULL;
+
+ -- Step 3: Drop old indices that are no longer needed
+ DROP INDEX CONCURRENTLY IF EXISTS search_key_value_idx;
+ DROP INDEX CONCURRENTLY IF EXISTS search_key_original_value_idx;
+ ```
+
+ Also fixed `buildEntitySearch` to remove duplicate output for entities with duplicate array values, and added `ON CONFLICT DO UPDATE` to `syncSearchRows` so that concurrent stitching races are handled gracefully.
+
+- Updated dependencies
+ - @backstage/catalog-model@1.9.0
+ - @backstage/errors@1.3.1
+ - @backstage/backend-plugin-api@1.9.1
+ - @backstage/plugin-catalog-node@2.2.1
+ - @backstage/filter-predicates@0.1.3
+ - @backstage/integration@2.0.2
+ - @backstage/plugin-permission-node@0.11.0
+ - @backstage/plugin-permission-common@0.9.9
+ - @backstage/backend-openapi-utils@0.6.9
+ - @backstage/catalog-client@1.15.1
+ - @backstage/config@1.3.8
+ - @backstage/plugin-catalog-common@1.1.10
+ - @backstage/plugin-events-node@0.4.22
+
+## 3.7.0-next.2
+
+### Minor Changes
+
+- c2de113: **BREAKING**: When paginating entities with an order field via `/entities/by-query`, entities that lack the order field are now excluded from both the result set and the `totalItems` count. Previously these entities appeared at the end of the sorted result via `NULLS LAST`, but cursor-based pagination could not actually reach them past the first page â the count over-reported the number of navigable entities. The new behavior aligns the count with what is actually returned.
+
+ This also removes the `DISTINCT` deduplication from the sort-field CTE, which is a prerequisite for the planner to use the `(key, value, entity_id)` index in sort order and short-circuit on `LIMIT`. Installations with duplicate search rows should land the search-table deduplication migration before adopting this change.
+
+### Patch Changes
+
+- ccbad9d: Improved the performance of the `catalog_entities_count` metric.
+
+ The legacy Prometheus and OpenTelemetry observable gauges previously each ran their own copy of the per-kind count query against the `search` table on every metrics scrape. On large catalogs this could pile up faster than the queries completed, contending for buffers and stalling the database.
+
+ The two callbacks now share a single query result with a short in-process TTL cache, and the underlying query reads from `final_entities` instead of `search`, avoiding the bitmap heap scans that dominated the previous form. The emitted labels and values are unchanged.
+
+- add5d1a: Restructured the entity listing endpoint so that, when a sort field is specified, the search-by-key index drives the query rather than being side-joined onto `final_entities`. This lets PostgreSQL walk the `(key, value, entity_id)` index in already-sorted order and short-circuit on `LIMIT`, reducing typical broad-filter paginated list times from seconds to milliseconds. Entities that lack the sort field still appear at the end of sorted results (NULLS LAST semantics preserved), ordered by `entity_id`.
+- 387ea7d: Simplified the entity facets aggregation from `COUNT(DISTINCT entity_id)` to `COUNT(*)`. The unique constraint on `(entity_id, key, value)` guarantees each entity appears at most once per search row group, making the `DISTINCT` unnecessary. This allows the database to use a simpler aggregation plan.
+- 3f55b73: Improved the performance of the entity facets endpoint when filters are applied. The filtered entity set is now combined with the search table through an inner join rather than a `WHERE entity_id IN (subquery)`. Results are unchanged; on large catalogs the query planner is able to choose dramatically cheaper plans, with measured improvements ranging from roughly 1.2Ã on already-fast cases to 7Ã or more on high-cardinality facets.
+- cde3643: Added missing description to the `type` parameter on the `unregister-entity` MCP action.
+- 7445f0f: Added a migration that removes duplicate rows from the `search` table, creates covering indices for improved query performance, and adds a `UNIQUE` constraint on `(entity_id, key, value)`.
+
+ This is a long-running migration on large catalogs. On PostgreSQL with millions of search rows, the index creation may take 5-15 minutes per index. During this time, other pods running the previous version will continue to serve traffic normally â the index creation does not block reads or writes. However, if a Kubernetes liveness probe kills the pod before the index build completes, the build is lost and the next startup will start over. On large tables this can repeat indefinitely.
+
+ **For large installations**, it is recommended to run the following SQL commands against your PostgreSQL database **before deploying** this version. Each index build takes a few minutes but does not block reads or writes. If these have already completed, the migration will detect the existing indices and skip all work â startup will be instant.
+
+ ```sql
+ -- Step 1: Remove duplicate search rows
+ WITH cte AS (
+ SELECT ctid, row_number() OVER (PARTITION BY entity_id, key, value) AS rn
+ FROM search
+ )
+ DELETE FROM search USING cte WHERE search.ctid = cte.ctid AND cte.rn > 1;
+
+ -- Step 2: Create new indices (run each separately)
+ CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS
+ search_entity_key_value_idx ON search (entity_id, key, value);
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
+ search_key_value_entity_idx ON search (key, value, entity_id);
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
+ search_facets_covering_idx ON search (key, original_value, entity_id)
+ WHERE original_value IS NOT NULL;
+
+ -- Step 3: Drop old indices that are no longer needed
+ DROP INDEX CONCURRENTLY IF EXISTS search_key_value_idx;
+ DROP INDEX CONCURRENTLY IF EXISTS search_key_original_value_idx;
+ ```
+
+ Also fixed `buildEntitySearch` to remove duplicate output for entities with duplicate array values, and added `ON CONFLICT DO UPDATE` to `syncSearchRows` so that concurrent stitching races are handled gracefully.
+
+- Updated dependencies
+ - @backstage/backend-plugin-api@1.9.1-next.1
+
+## 3.6.2-next.1
+
+### Patch Changes
+
+- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
+- Updated dependencies
+ - @backstage/catalog-model@1.8.1-next.1
+ - @backstage/plugin-catalog-node@2.2.1-next.1
+ - @backstage/plugin-permission-common@0.9.9-next.1
+
## 3.6.1-next.0
### Patch Changes
diff --git a/plugins/catalog-backend/config.d.ts b/plugins/catalog-backend/config.d.ts
index 433d8c494c..41be62feb4 100644
--- a/plugins/catalog-backend/config.d.ts
+++ b/plugins/catalog-backend/config.d.ts
@@ -179,7 +179,11 @@ export interface Config {
*/
stitchingStrategy?:
| {
- /** Perform stitching in-band immediately when needed */
+ /**
+ * Perform stitching in-band immediately when needed.
+ *
+ * @deprecated Immediate mode stitching has been deprecated and will be removed in a future release. Migrate to deferred mode (the default).
+ */
mode: 'immediate';
}
| {
diff --git a/plugins/catalog-backend/migrations/20200809202832_add_bootstrap_location.js b/plugins/catalog-backend/migrations/20200809202832_add_bootstrap_location.js
index 5afb069d49..6adf798b07 100644
--- a/plugins/catalog-backend/migrations/20200809202832_add_bootstrap_location.js
+++ b/plugins/catalog-backend/migrations/20200809202832_add_bootstrap_location.js
@@ -23,7 +23,7 @@ exports.up = async function up(knex) {
// Adds a single 'bootstrap' location that can be used to trigger work in processors.
// This is primarily here to fulfill foreign key constraints.
await knex('locations').insert({
- id: require('uuid').v4(),
+ id: require('node:crypto').randomUUID(),
type: 'bootstrap',
target: 'bootstrap',
});
diff --git a/plugins/catalog-backend/migrations/20260510000000_search_indices_and_dedup.js b/plugins/catalog-backend/migrations/20260510000000_search_indices_and_dedup.js
new file mode 100644
index 0000000000..1a35b0f080
--- /dev/null
+++ b/plugins/catalog-backend/migrations/20260510000000_search_indices_and_dedup.js
@@ -0,0 +1,439 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// @ts-check
+
+/**
+ * Deduplicates the search table, adds covering indices (including a UNIQUE
+ * constraint on (entity_id, key, value)), and drops superseded indices.
+ *
+ * On PostgreSQL this uses CREATE INDEX CONCURRENTLY which avoids blocking
+ * reads/writes but can take several minutes on large tables (13M+ rows).
+ * Each step is idempotent: it checks the current state and skips work
+ * already done, or cleans up INVALID indices left by an interrupted
+ * attempt before retrying. However, an interrupted index build does NOT
+ * leave partial progress â each retry starts from scratch. If the
+ * Kubernetes liveness probe kills the pod before an index build completes,
+ * the next startup will drop the INVALID index and restart the build. On
+ * large tables this can repeat indefinitely. To prevent this, either
+ * increase the liveness probe timeout for this one-time upgrade, or run
+ * the SQL commands below manually before deploying.
+ *
+ * ## Deduplication strategy
+ *
+ * The dedup step is skipped entirely if the UNIQUE index already exists and
+ * is valid â a valid unique index guarantees no duplicates can be present.
+ *
+ * When dedup is needed the migration uses a two-phase approach that avoids
+ * a full heap scan by leveraging the pre-existing
+ * search_key_value_entity_idx (key, value, entity_id) covering index:
+ *
+ * Phase 1 (index-only scan):
+ * GROUP BY entity_id, key, value on the covering index â zero heap
+ * fetches â to build a small temp table of only the duplicate groups.
+ *
+ * Phase 2 (index scan, dup rows only):
+ * For each duplicate group in the temp table, LATERAL index-scan back
+ * into search to find the per-group ctids, then DELETE them in one
+ * statement. Only the ~2Ã duplicate rows are touched; clean rows are
+ * never read from the heap.
+ *
+ * ## Recommended: run manually before deploying (large installations)
+ *
+ * For PostgreSQL installations with millions of search rows, run these
+ * commands against your database BEFORE deploying this version. Each
+ * index build takes a few minutes but does not block reads or writes.
+ * The migration detects that the indices already exist and skips all work,
+ * so startup is instant.
+ *
+ * -- 1. Remove duplicate search rows using the same index-friendly
+ * -- two-phase strategy used by the migration itself.
+ * CREATE TEMP TABLE _search_dedup_groups AS
+ * SELECT entity_id, key, value
+ * FROM search
+ * GROUP BY entity_id, key, value
+ * HAVING COUNT(*) > 1;
+ * CREATE INDEX ON _search_dedup_groups (key, value, entity_id);
+ *
+ * DELETE FROM search WHERE ctid IN (
+ * SELECT s.ctid FROM _search_dedup_groups g
+ * CROSS JOIN LATERAL (
+ * SELECT ctid FROM (
+ * SELECT ctid,
+ * row_number() OVER (ORDER BY ctid) AS rn
+ * FROM search
+ * WHERE key = g.key AND entity_id = g.entity_id
+ * AND value = g.value
+ * ) sub WHERE rn > 1
+ * ) s WHERE g.value IS NOT NULL
+ * UNION ALL
+ * SELECT s.ctid FROM _search_dedup_groups g
+ * CROSS JOIN LATERAL (
+ * SELECT ctid FROM (
+ * SELECT ctid,
+ * row_number() OVER (ORDER BY ctid) AS rn
+ * FROM search
+ * WHERE key = g.key AND entity_id = g.entity_id
+ * AND value IS NULL
+ * ) sub WHERE rn > 1
+ * ) s WHERE g.value IS NULL
+ * );
+ *
+ * DROP TABLE _search_dedup_groups;
+ *
+ * -- 2. Create indices (run each separately)
+ * CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS
+ * search_entity_key_value_idx ON search (entity_id, key, value);
+ * CREATE INDEX CONCURRENTLY IF NOT EXISTS
+ * search_key_value_entity_idx ON search (key, value, entity_id);
+ * CREATE INDEX CONCURRENTLY IF NOT EXISTS
+ * search_facets_covering_idx ON search (key, original_value, entity_id)
+ * WHERE original_value IS NOT NULL;
+ *
+ * -- 3. Drop old indices
+ * DROP INDEX CONCURRENTLY IF EXISTS search_key_value_idx;
+ * DROP INDEX CONCURRENTLY IF EXISTS search_key_original_value_idx;
+ *
+ * If these commands have already completed, the migration will detect the
+ * existing indices and skip all work â startup will be instant.
+ */
+
+/**
+ * @param {import('knex').Knex} knex
+ */
+exports.up = async function up(knex) {
+ const client = knex.client.config.client;
+
+ if (client.includes('pg')) {
+ await upPostgres(knex);
+ } else if (client.includes('mysql')) {
+ await upMysql(knex);
+ } else {
+ await upSqlite(knex);
+ }
+};
+
+/**
+ * @param {import('knex').Knex} knex
+ */
+exports.down = async function down(knex) {
+ const client = knex.client.config.client;
+
+ if (client.includes('pg')) {
+ // Restore the old indices first so there is no window without coverage,
+ // then drop the new ones.
+ await knex.raw(
+ 'CREATE INDEX CONCURRENTLY IF NOT EXISTS search_key_value_idx ON search (key, value)',
+ );
+ await knex.raw(
+ 'CREATE INDEX CONCURRENTLY IF NOT EXISTS search_key_original_value_idx ON search (key, original_value)',
+ );
+ await knex.raw(
+ 'DROP INDEX CONCURRENTLY IF EXISTS search_entity_key_value_idx',
+ );
+ await knex.raw(
+ 'DROP INDEX CONCURRENTLY IF EXISTS search_key_value_entity_idx',
+ );
+ await knex.raw(
+ 'DROP INDEX CONCURRENTLY IF EXISTS search_facets_covering_idx',
+ );
+ } else if (client.includes('mysql')) {
+ await knex.schema.alterTable('search', table => {
+ table.index(['key', 'value'], 'search_key_value_idx');
+ table.index(['key', 'original_value'], 'search_key_original_value_idx');
+ });
+ await mysqlDropIndexIfExists(knex, 'search_entity_key_value_idx');
+ await mysqlDropIndexIfExists(knex, 'search_key_value_entity_idx');
+ await mysqlDropIndexIfExists(knex, 'search_facets_covering_idx');
+ } else {
+ await knex.raw(
+ 'CREATE INDEX IF NOT EXISTS search_key_value_idx ON search (key, value)',
+ );
+ await knex.raw(
+ 'CREATE INDEX IF NOT EXISTS search_key_original_value_idx ON search (key, original_value)',
+ );
+ await knex.raw('DROP INDEX IF EXISTS search_entity_key_value_idx');
+ await knex.raw('DROP INDEX IF EXISTS search_key_value_entity_idx');
+ await knex.raw('DROP INDEX IF EXISTS search_facets_covering_idx');
+ }
+};
+
+exports.config = { transaction: false };
+
+// ---------------------------------------------------------------------------
+// PostgreSQL
+// ---------------------------------------------------------------------------
+
+/** @param {import('knex').Knex} knex */
+async function upPostgres(knex) {
+ // Step 1: Ensure the covering index exists before deduplication.
+ // This non-unique index on (key, value, entity_id) covers all three dedup
+ // columns, enabling an index-only GROUP BY scan with zero heap fetches in
+ // Phase 1 of the dedup. Creating it here guarantees this is true even on
+ // vanilla installations that have never run any preparatory SQL manually.
+ await ensurePgIndex(knex, {
+ name: 'search_key_value_entity_idx',
+ columns: '(key, value, entity_id)',
+ unique: false,
+ });
+
+ // Step 2: Remove duplicate search rows.
+ //
+ // Fast path: if the UNIQUE index already exists and is valid, Postgres has
+ // been enforcing uniqueness since the index was created, so there are no
+ // duplicates. Skip dedup entirely â this makes restarts essentially free
+ // for installations that created the index manually beforehand.
+ //
+ // Slow path (index absent or invalid): two-phase approach.
+ // Phase 1: index-only GROUP BY scan over search_key_value_entity_idx
+ // (key, value, entity_id) â zero heap fetches â to build a
+ // temp table of only the duplicate groups.
+ // Phase 2: LATERAL + index scan to find ctids within each group, then
+ // a single DELETE. Only the duplicate rows (~2Ã their count)
+ // are ever read from the heap; the full table is never scanned.
+ const uniqueCheck = await knex.raw(
+ `SELECT indisvalid
+ FROM pg_index
+ WHERE indexrelid = (
+ SELECT oid FROM pg_class WHERE relname = ? AND relkind = 'i'
+ ) AND indisunique = true`,
+ ['search_entity_key_value_idx'],
+ );
+ const needsDedup = !uniqueCheck.rows[0]?.indisvalid;
+
+ if (needsDedup) {
+ // Phase 1: index-only GROUP BY scan â no heap fetches.
+ // search_key_value_entity_idx (key, value, entity_id) covers all three
+ // dedup columns, so PostgreSQL resolves COUNT(*) without touching the
+ // heap at all (Heap Fetches: 0 in EXPLAIN). The result is a small temp
+ // table of only the duplicate (entity_id, key, value) groups.
+ await knex.raw(`
+ CREATE TEMP TABLE _search_dedup_groups AS
+ SELECT entity_id, key, value
+ FROM search
+ GROUP BY entity_id, key, value
+ HAVING COUNT(*) > 1
+ `);
+ await knex.raw(
+ `CREATE INDEX ON _search_dedup_groups (key, value, entity_id)`,
+ );
+
+ // Phase 2: for each duplicate group, LATERAL-join back into search via
+ // the covering index (Nested Loop + Index Scan), row_number within that
+ // tiny per-group result, then DELETE rows where rn > 1. Only the ~2Ã
+ // duplicate rows are ever read from the heap; all clean rows are skipped.
+ //
+ // NULL values need a separate arm because `value = NULL` is always false
+ // in SQL â `value IS NULL` is required for the index condition.
+ await knex.raw(`
+ DELETE FROM search WHERE ctid IN (
+ SELECT s.ctid FROM _search_dedup_groups g
+ CROSS JOIN LATERAL (
+ SELECT ctid FROM (
+ SELECT ctid,
+ row_number() OVER (ORDER BY ctid) AS rn
+ FROM search
+ WHERE key = g.key AND entity_id = g.entity_id
+ AND value = g.value
+ ) sub WHERE rn > 1
+ ) s WHERE g.value IS NOT NULL
+
+ UNION ALL
+
+ SELECT s.ctid FROM _search_dedup_groups g
+ CROSS JOIN LATERAL (
+ SELECT ctid FROM (
+ SELECT ctid,
+ row_number() OVER (ORDER BY ctid) AS rn
+ FROM search
+ WHERE key = g.key AND entity_id = g.entity_id
+ AND value IS NULL
+ ) sub WHERE rn > 1
+ ) s WHERE g.value IS NULL
+ )
+ `);
+
+ await knex.raw('DROP TABLE IF EXISTS _search_dedup_groups');
+ }
+
+ // Step 3: Create remaining covering indices. Each call is idempotent â
+ // it checks the index state and only does work if needed.
+ // search_key_value_entity_idx was already created in Step 1.
+ await ensurePgIndex(knex, {
+ name: 'search_entity_key_value_idx',
+ columns: '(entity_id, key, value)',
+ unique: true,
+ });
+ await ensurePgIndex(knex, {
+ name: 'search_facets_covering_idx',
+ columns: '(key, original_value, entity_id)',
+ where: 'WHERE original_value IS NOT NULL',
+ unique: false,
+ });
+
+ // Step 4: Drop superseded indices.
+ await dropPgIndexIfExists(knex, 'search_key_value_idx');
+ await dropPgIndexIfExists(knex, 'search_key_original_value_idx');
+}
+
+/**
+ * Creates or replaces an index on the search table, handling all edge cases:
+ * - Already valid with correct uniqueness: skip
+ * - Exists but INVALID (interrupted CREATE): drop and recreate
+ * - Exists but wrong uniqueness (e.g. non-unique but we need unique): drop and recreate
+ * - Missing: create from scratch
+ *
+ * @param {import('knex').Knex} knex
+ * @param {{ name: string; columns: string; unique: boolean; where?: string }} opts
+ */
+async function ensurePgIndex(knex, opts) {
+ const { name, columns, unique, where } = opts;
+
+ const result = await knex.raw(
+ `
+ SELECT indisunique, indisvalid
+ FROM pg_index
+ WHERE indexrelid = (
+ SELECT oid FROM pg_class WHERE relname = ? AND relkind = 'i'
+ )
+ `,
+ [name],
+ );
+
+ if (result.rows.length > 0) {
+ const { indisunique, indisvalid } = result.rows[0];
+ if (indisvalid && indisunique === unique) {
+ return; // Already correct
+ }
+ // Wrong state â drop and recreate
+ await knex.raw(`DROP INDEX CONCURRENTLY IF EXISTS "${name}"`);
+ }
+
+ const uniqueKw = unique ? 'UNIQUE' : '';
+ const whereClause = where || '';
+ await knex.raw(
+ `CREATE ${uniqueKw} INDEX CONCURRENTLY "${name}" ON search ${columns} ${whereClause}`,
+ );
+}
+
+/**
+ * @param {import('knex').Knex} knex
+ * @param {string} name
+ */
+async function dropPgIndexIfExists(knex, name) {
+ const result = await knex.raw(
+ `
+ SELECT 1 FROM pg_class WHERE relname = ? AND relkind = 'i'
+ `,
+ [name],
+ );
+ if (result.rows.length > 0) {
+ await knex.raw(`DROP INDEX CONCURRENTLY IF EXISTS "${name}"`);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// MySQL
+// ---------------------------------------------------------------------------
+
+/** @param {import('knex').Knex} knex */
+async function upMysql(knex) {
+ // Dedup via temp table
+ await knex.transaction(async trx => {
+ await trx.raw(
+ 'CREATE TEMPORARY TABLE IF NOT EXISTS `_search_keep` (' +
+ '`entity_id` VARCHAR(255), `key` VARCHAR(255), ' +
+ '`value` VARCHAR(255), `original_value` VARCHAR(255))',
+ );
+ await trx.raw('DELETE FROM `_search_keep`');
+ await trx.raw(
+ 'INSERT INTO `_search_keep` ' +
+ 'SELECT `entity_id`, `key`, `value`, MAX(`original_value`) ' +
+ 'FROM `search` GROUP BY `entity_id`, `key`, `value`',
+ );
+ await trx.raw('DELETE FROM `search`');
+ await trx.raw(
+ 'INSERT INTO `search` (`entity_id`, `key`, `value`, `original_value`) ' +
+ 'SELECT * FROM `_search_keep`',
+ );
+ await trx.raw('DROP TEMPORARY TABLE `_search_keep`');
+ });
+
+ // Drop old indices if present, then create new ones
+ await mysqlDropIndexIfExists(knex, 'search_key_value_idx');
+ await mysqlDropIndexIfExists(knex, 'search_key_original_value_idx');
+ await mysqlDropIndexIfExists(knex, 'search_entity_key_value_idx');
+ await mysqlDropIndexIfExists(knex, 'search_key_value_entity_idx');
+ await mysqlDropIndexIfExists(knex, 'search_facets_covering_idx');
+
+ await knex.schema.alterTable('search', table => {
+ table.unique(['entity_id', 'key', 'value'], 'search_entity_key_value_idx');
+ table.index(['key', 'value', 'entity_id'], 'search_key_value_entity_idx');
+ });
+ // MySQL doesn't support partial indices â create a full one
+ await knex.schema.alterTable('search', table => {
+ table.index(
+ ['key', 'original_value', 'entity_id'],
+ 'search_facets_covering_idx',
+ );
+ });
+}
+
+/** @param {import('knex').Knex} knex @param {string} name */
+async function mysqlDropIndexIfExists(knex, name) {
+ const [rows] = await knex.raw(
+ `SHOW INDEX FROM \`search\` WHERE Key_name = ?`,
+ [name],
+ );
+ if (rows.length > 0) {
+ await knex.schema.alterTable('search', t => {
+ t.dropIndex([], name);
+ });
+ }
+}
+
+// ---------------------------------------------------------------------------
+// SQLite
+// ---------------------------------------------------------------------------
+
+/** @param {import('knex').Knex} knex */
+async function upSqlite(knex) {
+ await knex.transaction(async trx => {
+ await trx.raw(`
+ DELETE FROM search
+ WHERE rowid NOT IN (
+ SELECT MIN(rowid) FROM search GROUP BY entity_id, key, value
+ )
+ `);
+ });
+
+ // Drop old, create new â SQLite is fast on small tables
+ await knex.raw('DROP INDEX IF EXISTS search_key_value_idx');
+ await knex.raw('DROP INDEX IF EXISTS search_key_original_value_idx');
+ await knex.raw('DROP INDEX IF EXISTS search_entity_key_value_idx');
+ await knex.raw('DROP INDEX IF EXISTS search_key_value_entity_idx');
+ await knex.raw('DROP INDEX IF EXISTS search_facets_covering_idx');
+
+ await knex.raw(
+ 'CREATE UNIQUE INDEX search_entity_key_value_idx ON search (entity_id, key, value)',
+ );
+ await knex.raw(
+ 'CREATE INDEX search_key_value_entity_idx ON search (key, value, entity_id)',
+ );
+ await knex.raw(
+ 'CREATE INDEX search_facets_covering_idx ON search (key, original_value, entity_id)',
+ );
+}
diff --git a/plugins/catalog-backend/migrations/20260516000000_relations_target_index.js b/plugins/catalog-backend/migrations/20260516000000_relations_target_index.js
new file mode 100644
index 0000000000..268e92f47f
--- /dev/null
+++ b/plugins/catalog-backend/migrations/20260516000000_relations_target_index.js
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// @ts-check
+
+/**
+ * Adds an index on `relations.target_entity_ref`.
+ *
+ * The `relations` table had indexes on `originating_entity_id` and
+ * `source_entity_ref` but none on `target_entity_ref`. Several query
+ * paths (orphan deletion, entity ancestry, eager pruning) join or
+ * filter on `target_entity_ref`, causing full sequential scans of the
+ * table (~3.5M rows, ~714 MB heap).
+ *
+ * On PostgreSQL this uses CREATE INDEX CONCURRENTLY to avoid blocking
+ * reads and writes. The index is ~141 MB based on the column's average
+ * width of ~35 bytes across ~3.5M rows.
+ */
+
+/**
+ * @param {import('knex').Knex} knex
+ */
+exports.up = async function up(knex) {
+ const client = knex.client.config.client;
+
+ if (client.includes('pg')) {
+ // Check if index already exists in the current schema (idempotent).
+ // The pg_class lookup is scoped via pg_namespace to handle
+ // schema-division mode where each plugin has its own schema.
+ const result = await knex.raw(
+ `SELECT c.oid, i.indisvalid
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ LEFT JOIN pg_index i ON i.indexrelid = c.oid
+ WHERE c.relname = ?
+ AND c.relkind = 'i'
+ AND n.nspname = current_schema()`,
+ ['relations_target_entity_ref_idx'],
+ );
+
+ if (result.rows.length > 0) {
+ if (result.rows[0].indisvalid) {
+ return; // Already exists and valid
+ }
+ // Invalid â drop and recreate
+ await knex.raw(
+ 'DROP INDEX CONCURRENTLY IF EXISTS relations_target_entity_ref_idx',
+ );
+ }
+
+ await knex.raw(
+ 'CREATE INDEX CONCURRENTLY relations_target_entity_ref_idx ON relations (target_entity_ref)',
+ );
+ } else {
+ // SQLite / MySQL â simple CREATE INDEX
+ await knex.schema.alterTable('relations', table => {
+ table.index(['target_entity_ref'], 'relations_target_entity_ref_idx');
+ });
+ }
+};
+
+/**
+ * @param {import('knex').Knex} knex
+ */
+exports.down = async function down(knex) {
+ const client = knex.client.config.client;
+
+ if (client.includes('pg')) {
+ await knex.raw(
+ 'DROP INDEX CONCURRENTLY IF EXISTS relations_target_entity_ref_idx',
+ );
+ } else {
+ await knex.schema.alterTable('relations', table => {
+ table.dropIndex([], 'relations_target_entity_ref_idx');
+ });
+ }
+};
+
+exports.config = { transaction: false };
diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json
index d28fb2befe..56cc3839ea 100644
--- a/plugins/catalog-backend/package.json
+++ b/plugins/catalog-backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend",
- "version": "3.6.1-next.0",
+ "version": "3.7.0",
"description": "The Backstage backend plugin that provides the Backstage catalog",
"backstage": {
"role": "backend-plugin",
@@ -94,7 +94,6 @@
"minimatch": "^10.2.1",
"p-limit": "^3.0.2",
"prom-client": "^15.0.0",
- "uuid": "^11.0.0",
"yaml": "^2.0.0",
"yn": "^4.0.0",
"zod": "^3.25.76 || ^4.0.0",
diff --git a/plugins/catalog-backend/report.sql.md b/plugins/catalog-backend/report.sql.md
index e23b250be8..1d172db3ba 100644
--- a/plugins/catalog-backend/report.sql.md
+++ b/plugins/catalog-backend/report.sql.md
@@ -114,6 +114,7 @@
- `relations_source_entity_id_idx` (`originating_entity_id`)
- `relations_source_entity_ref_idx` (`source_entity_ref`)
+- `relations_target_entity_ref_idx` (`target_entity_ref`)
## Table `search`
@@ -127,8 +128,9 @@
### Indices
- `search_entity_id_idx` (`entity_id`)
-- `search_key_original_value_idx` (`key`, `original_value`)
-- `search_key_value_idx` (`key`, `value`)
+- `search_entity_key_value_idx` (`entity_id`, `key`, `value`) unique
+- `search_facets_covering_idx` (`key`, `original_value`, `entity_id`)
+- `search_key_value_entity_idx` (`key`, `value`, `entity_id`)
## Table `stitch_queue`
diff --git a/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.ts b/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.ts
index 6bf4171163..addd02320e 100644
--- a/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.ts
+++ b/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.ts
@@ -17,22 +17,45 @@ import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
import { CatalogService } from '@backstage/plugin-catalog-node';
import { createZodV3FilterPredicateSchema } from '@backstage/filter-predicates';
-export const createQueryCatalogEntitiesAction = ({
- catalog,
- actionsRegistry,
-}: {
- catalog: CatalogService;
- actionsRegistry: ActionsRegistryService;
-}) => {
- actionsRegistry.register({
- name: 'query-catalog-entities',
- title: 'Query Catalog Entities',
- attributes: {
- destructive: false,
- readOnly: true,
- idempotent: true,
- },
- description: `
+const QUERY_SYNTAX = `
+## Query Syntax
+
+The query uses predicate expressions with dot-notation field paths.
+
+Simple matching:
+ { query: { kind: "Component" } }
+ { query: { kind: "Component", "spec.type": "service" } }
+
+Value operators:
+ { query: { kind: { "$in": ["API", "Component"] } } }
+ { query: { "metadata.annotations.backstage.io/techdocs-ref": { "$exists": true } } }
+ { query: { "metadata.tags": { "$contains": "java" } } }
+ { query: { "metadata.name": { "$hasPrefix": "team-" } } }
+
+Logical operators:
+ { query: { "$all": [{ kind: "Component" }, { "spec.lifecycle": "production" }] } }
+ { query: { "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] } }
+ { query: { "$not": { kind: "Group" } } }
+
+Querying relations - find all entities owned by a specific group:
+ { query: { "relations.ownedby": "group:default/team-alpha" } }
+
+Combined example - find production services or websites with TechDocs:
+ { query: { "$all": [
+ { kind: "Component", "spec.lifecycle": "production" },
+ { "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] },
+ { "metadata.annotations.backstage.io/techdocs-ref": { "$exists": true } }
+ ] } }
+
+## Other Options
+
+Limit returned fields: { fields: ["kind", "metadata.name", "metadata.namespace"] }
+Sort results: { orderFields: { field: "metadata.name", order: "asc" } }
+Full text search: { fullTextFilter: { term: "auth", fields: ["metadata.name", "metadata.title"] } }
+Pagination: Use limit (e.g. 20) and the returned nextPageCursor for subsequent requests via cursor.
+`;
+
+const INLINE_MODEL_DESCRIPTION = `
Query entities from the Backstage Software Catalog using predicate filters.
## Catalog Model
@@ -76,43 +99,34 @@ Entities have bidirectional relations stored in the "relations" array. Common re
Relations can be queried via "relations." e.g. "relations.ownedby: user:default/jane-doe". The value there must always be a valid entity reference.
When querying for entity relationships, prefer using relations over spec fields. For example, use "relations.ownedby" instead of "spec.owner" to find entities owned by a particular group or user.
+${QUERY_SYNTAX}`;
-## Query Syntax
+const MODEL_REFERENCE_DESCRIPTION = `
+Query entities from the Backstage Software Catalog using predicate filters.
-The query uses predicate expressions with dot-notation field paths.
+For a complete list of entity kinds, fields, relations, and other queryable attributes available in the catalog, use \`get-catalog-model-description\`.
+${QUERY_SYNTAX}`;
-Simple matching:
- { query: { kind: "Component" } }
- { query: { kind: "Component", "spec.type": "service" } }
-
-Value operators:
- { query: { kind: { "$in": ["API", "Component"] } } }
- { query: { "metadata.annotations.backstage.io/techdocs-ref": { "$exists": true } } }
- { query: { "metadata.tags": { "$contains": "java" } } }
- { query: { "metadata.name": { "$hasPrefix": "team-" } } }
-
-Logical operators:
- { query: { "$all": [{ kind: "Component" }, { "spec.lifecycle": "production" }] } }
- { query: { "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] } }
- { query: { "$not": { kind: "Group" } } }
-
-Querying relations - find all entities owned by a specific group:
- { query: { "relations.ownedby": "group:default/team-alpha" } }
-
-Combined example - find production services or websites with TechDocs:
- { query: { "$all": [
- { kind: "Component", "spec.lifecycle": "production" },
- { "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] },
- { "metadata.annotations.backstage.io/techdocs-ref": { "$exists": true } }
- ] } }
-
-## Other Options
-
-Limit returned fields: { fields: ["kind", "metadata.name", "metadata.namespace"] }
-Sort results: { orderFields: { field: "metadata.name", order: "asc" } }
-Full text search: { fullTextFilter: { term: "auth", fields: ["metadata.name", "metadata.title"] } }
-Pagination: Use limit (e.g. 20) and the returned nextPageCursor for subsequent requests via cursor.
- `,
+export const createQueryCatalogEntitiesAction = ({
+ catalog,
+ actionsRegistry,
+ useExperimentalCatalogLayersDescriptions,
+}: {
+ catalog: CatalogService;
+ actionsRegistry: ActionsRegistryService;
+ useExperimentalCatalogLayersDescriptions?: boolean;
+}) => {
+ actionsRegistry.register({
+ name: 'query-catalog-entities',
+ title: 'Query Catalog Entities',
+ attributes: {
+ destructive: false,
+ readOnly: true,
+ idempotent: true,
+ },
+ description: useExperimentalCatalogLayersDescriptions
+ ? MODEL_REFERENCE_DESCRIPTION
+ : INLINE_MODEL_DESCRIPTION,
schema: {
input: z =>
z.object({
diff --git a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts
index 542fc79ac5..22dbb15eb6 100644
--- a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts
+++ b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts
@@ -40,9 +40,9 @@ Once completed, all entities associated with the Location will be deleted from t
`,
schema: {
input: z =>
- z
- .object({
- type: z.union([
+ z.object({
+ type: z
+ .union([
z.object({
locationId: z
.string()
@@ -55,11 +55,11 @@ Once completed, all entities associated with the Location will be deleted from t
`URL of the catalog-info.yaml file to unregister for example: https://github.com/backstage/demo/blob/master/catalog-info.yaml`,
),
}),
- ]),
- })
- .describe(
- 'The type to the unregister-entity action. Either locationId or locationUrl must be provided.',
- ),
+ ])
+ .describe(
+ 'Identifies the entity to unregister. Provide either locationId or locationUrl.',
+ ),
+ }),
output: z => z.object({}),
},
action: async ({ input: { type }, credentials }) => {
diff --git a/plugins/catalog-backend/src/actions/index.ts b/plugins/catalog-backend/src/actions/index.ts
index 2d6fb58f87..5b5bb056ce 100644
--- a/plugins/catalog-backend/src/actions/index.ts
+++ b/plugins/catalog-backend/src/actions/index.ts
@@ -28,6 +28,7 @@ export const createCatalogActions = (options: {
actionsRegistry: ActionsRegistryService;
catalog: CatalogService;
modelHolder: ModelHolder | undefined;
+ useExperimentalCatalogLayersDescriptions?: boolean;
}) => {
createGetCatalogModelDescriptionAction(options);
createGetCatalogEntityAction(options);
diff --git a/plugins/catalog-backend/src/database/DefaultCatalogDatabase.test.ts b/plugins/catalog-backend/src/database/DefaultCatalogDatabase.test.ts
index a537168bcf..2f25336775 100644
--- a/plugins/catalog-backend/src/database/DefaultCatalogDatabase.test.ts
+++ b/plugins/catalog-backend/src/database/DefaultCatalogDatabase.test.ts
@@ -14,11 +14,7 @@
* limitations under the License.
*/
-import {
- mockServices,
- TestDatabaseId,
- TestDatabases,
-} from '@backstage/backend-test-utils';
+import { mockServices, TestDatabases } from '@backstage/backend-test-utils';
import { DefaultCatalogDatabase } from './DefaultCatalogDatabase';
import { applyDatabaseMigrations } from './migrations';
import { DbRefreshStateReferencesRow, DbRefreshStateRow } from './tables';
@@ -26,48 +22,46 @@ import { LoggerService } from '@backstage/backend-plugin-api';
jest.setTimeout(60_000);
-describe('DefaultCatalogDatabase', () => {
- const defaultLogger = mockServices.logger.mock();
- const databases = TestDatabases.create();
+const databases = TestDatabases.create();
- async function createDatabase(
- databaseId: TestDatabaseId,
- logger: LoggerService = defaultLogger,
- ) {
- const knex = await databases.init(databaseId);
- await applyDatabaseMigrations(knex);
- return {
- knex,
- db: new DefaultCatalogDatabase({
- database: knex,
- logger,
- }),
- };
- }
+describe.each(databases.eachSupportedId())(
+ 'DefaultCatalogDatabase, %p',
+ databaseId => {
+ const defaultLogger = mockServices.logger.mock();
- describe('listAncestors', () => {
- let nextId = 1;
- function makeEntity(ref: string) {
+ async function createDatabase(logger: LoggerService = defaultLogger) {
+ const knex = await databases.init(databaseId);
+ await applyDatabaseMigrations(knex);
return {
- entity_id: String(nextId++),
- entity_ref: ref,
- unprocessed_entity: JSON.stringify({
- kind: 'Location',
- apiVersion: '1.0.0',
- metadata: {
- name: 'xyz',
- },
+ knex,
+ db: new DefaultCatalogDatabase({
+ database: knex,
+ logger,
}),
- errors: '[]',
- next_update_at: '2019-01-01 23:00:00',
- last_discovery_at: '2021-04-01 13:37:00',
};
}
- it.each(databases.eachSupportedId())(
- 'should return ancestors, %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ describe('listAncestors', () => {
+ let nextId = 1;
+ function makeEntity(ref: string) {
+ return {
+ entity_id: String(nextId++),
+ entity_ref: ref,
+ unprocessed_entity: JSON.stringify({
+ kind: 'Location',
+ apiVersion: '1.0.0',
+ metadata: {
+ name: 'xyz',
+ },
+ }),
+ errors: '[]',
+ next_update_at: '2019-01-01 23:00:00',
+ last_discovery_at: '2021-04-01 13:37:00',
+ };
+ }
+
+ it('should return ancestors', async () => {
+ const { knex, db } = await createDatabase();
await knex('refresh_state').insert(
makeEntity('location:default/root-1'),
@@ -107,7 +101,7 @@ describe('DefaultCatalogDatabase', () => {
'location:default/root-1',
'location:default/root-2',
]);
- },
- );
- });
-});
+ });
+ });
+ },
+);
diff --git a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.test.ts
index b9f3599b19..4b6811f183 100644
--- a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.test.ts
+++ b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.test.ts
@@ -14,14 +14,10 @@
* limitations under the License.
*/
-import {
- mockServices,
- TestDatabaseId,
- TestDatabases,
-} from '@backstage/backend-test-utils';
+import { mockServices, TestDatabases } from '@backstage/backend-test-utils';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { Knex } from 'knex';
-import * as uuid from 'uuid';
+import { randomUUID as uuid } from 'node:crypto';
import { Logger } from 'winston';
import { DateTime } from 'luxon';
import { applyDatabaseMigrations } from './migrations';
@@ -40,64 +36,62 @@ import { metricsServiceMock } from '@backstage/backend-test-utils/alpha';
jest.setTimeout(60_000);
-describe('DefaultProcessingDatabase', () => {
- const defaultLogger = mockServices.logger.mock();
- const databases = TestDatabases.create();
+const databases = TestDatabases.create();
- async function createDatabase(
- databaseId: TestDatabaseId,
- logger: LoggerService = defaultLogger,
- ) {
- const knex = await databases.init(databaseId);
- await applyDatabaseMigrations(knex);
- return {
- knex,
- db: new DefaultProcessingDatabase({
- database: knex,
- logger,
- refreshInterval: createRandomProcessingInterval({
- minSeconds: 100,
- maxSeconds: 150,
+describe.each(databases.eachSupportedId())(
+ 'DefaultProcessingDatabase, %p',
+ databaseId => {
+ const defaultLogger = mockServices.logger.mock();
+
+ async function createDatabase(logger: LoggerService = defaultLogger) {
+ const knex = await databases.init(databaseId);
+ await applyDatabaseMigrations(knex);
+ return {
+ knex,
+ db: new DefaultProcessingDatabase({
+ database: knex,
+ logger,
+ refreshInterval: createRandomProcessingInterval({
+ minSeconds: 100,
+ maxSeconds: 150,
+ }),
+ events: mockServices.events.mock(),
+ metrics: metricsServiceMock.mock(),
}),
- events: mockServices.events.mock(),
- metrics: metricsServiceMock.mock(),
- }),
- };
- }
-
- const insertRefRow = async (db: Knex, ref: DbRefreshStateReferencesRow) => {
- return db('refresh_state_references').insert(
- ref,
- );
- };
-
- const insertRefreshStateRow = async (db: Knex, ref: DbRefreshStateRow) => {
- await db('refresh_state').insert(ref);
- };
-
- describe('updateProcessedEntity', () => {
- let id: string;
- let processedEntity: Entity;
-
- beforeEach(() => {
- id = uuid.v4();
- processedEntity = {
- apiVersion: '1',
- kind: 'Location',
- metadata: {
- name: 'fakelocation',
- },
- spec: {
- type: 'url',
- target: 'somethingelse',
- },
};
- });
+ }
- it.each(databases.eachSupportedId())(
- 'fails when an entity is processed with a different locationKey, %p',
- async databaseId => {
- const { db } = await createDatabase(databaseId);
+ const insertRefRow = async (db: Knex, ref: DbRefreshStateReferencesRow) => {
+ return db('refresh_state_references').insert(
+ ref,
+ );
+ };
+
+ const insertRefreshStateRow = async (db: Knex, ref: DbRefreshStateRow) => {
+ await db('refresh_state').insert(ref);
+ };
+
+ describe('updateProcessedEntity', () => {
+ let id: string;
+ let processedEntity: Entity;
+
+ beforeEach(() => {
+ id = uuid();
+ processedEntity = {
+ apiVersion: '1',
+ kind: 'Location',
+ metadata: {
+ name: 'fakelocation',
+ },
+ spec: {
+ type: 'url',
+ target: 'somethingelse',
+ },
+ };
+ });
+
+ it('fails when an entity is processed with a different locationKey', async () => {
+ const { db } = await createDatabase();
await db.transaction(async tx => {
await expect(() =>
db.updateProcessedEntity(tx, {
@@ -112,12 +106,9 @@ describe('DefaultProcessingDatabase', () => {
`Conflicting write of processing result for ${id} with location key 'undefined'`,
);
});
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'fails when the locationKey is different, %p',
- async databaseId => {
+ it('fails when the locationKey is different', async () => {
const options = {
id,
processedEntity,
@@ -128,7 +119,7 @@ describe('DefaultProcessingDatabase', () => {
refreshKeys: [],
errors: "['something broke']",
};
- const { knex, db } = await createDatabase(databaseId);
+ const { knex, db } = await createDatabase();
await insertRefreshStateRow(knex, {
entity_id: id,
entity_ref: 'location:default/fakelocation',
@@ -158,13 +149,10 @@ describe('DefaultProcessingDatabase', () => {
`Conflicting write of processing result for ${id} with location key 'fail'`,
),
);
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'updates the refresh state entry with the cache, processed entity and errors, %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ it('updates the refresh state entry with the cache, processed entity and errors', async () => {
+ const { knex, db } = await createDatabase();
await insertRefreshStateRow(knex, {
entity_id: id,
entity_ref: 'location:default/fakelocation',
@@ -197,13 +185,10 @@ describe('DefaultProcessingDatabase', () => {
);
expect(entities[0].errors).toEqual("['something broke']");
expect(entities[0].location_key).toEqual('key');
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'removes old relations and stores the new relationships, %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ it('removes old relations and stores the new relationships', async () => {
+ const { knex, db } = await createDatabase();
await insertRefreshStateRow(knex, {
entity_id: id,
entity_ref: 'location:default/fakelocation',
@@ -282,13 +267,10 @@ describe('DefaultProcessingDatabase', () => {
target_entity_ref: 'component:default/foo',
},
]);
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'adds deferred entities to the refresh_state table to be picked up later, %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ it('adds deferred entities to the refresh_state table to be picked up later', async () => {
+ const { knex, db } = await createDatabase();
await insertRefreshStateRow(knex, {
entity_id: id,
entity_ref: 'location:default/fakelocation',
@@ -330,19 +312,15 @@ describe('DefaultProcessingDatabase', () => {
.select();
expect(refreshStateEntries).toHaveLength(1);
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'updates unprocessed entities with varying location keys, %p',
- async databaseId => {
+ it('updates unprocessed entities with varying location keys', async () => {
const mockLogger = {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
};
const { knex, db } = await createDatabase(
- databaseId,
mockLogger as unknown as Logger,
);
@@ -479,19 +457,15 @@ describe('DefaultProcessingDatabase', () => {
expect(mockLogger.error).not.toHaveBeenCalled();
}
});
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'stores the refresh keys for the entity where key length is 255 chars or less',
- async databaseId => {
+ it('stores the refresh keys for the entity where key length is 255 chars or less', async () => {
const mockLogger = {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
};
const { knex, db } = await createDatabase(
- databaseId,
mockLogger as unknown as Logger,
);
await insertRefreshStateRow(knex, {
@@ -536,19 +510,15 @@ describe('DefaultProcessingDatabase', () => {
entity_id: id,
key: 'protocol:foo-bar.com',
});
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'stores the refresh keys for the entity where key length is greater than 255 chars',
- async databaseId => {
+ it('stores the refresh keys for the entity where key length is greater than 255 chars', async () => {
const mockLogger = {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
};
const { knex, db } = await createDatabase(
- databaseId,
mockLogger as unknown as Logger,
);
await insertRefreshStateRow(knex, {
@@ -597,15 +567,12 @@ describe('DefaultProcessingDatabase', () => {
entity_id: id,
key: `url:https://example.com/foo-bar-test-group/very-long-group-name-that-exceeds-255-characters-just-to-test-the-limits-of-url-length-in-the-catalog-info-yaml-file-and-see-how-the-back#sha256:edfb606500d184900e63891e5279d35bf0069ea251e90d15c0a430de6023d905`,
});
- },
- );
- });
+ });
+ });
- describe('updateEntityCache', () => {
- it.each(databases.eachSupportedId())(
- 'updates the entityCache, %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ describe('updateEntityCache', () => {
+ it('updates the entityCache', async () => {
+ const { knex, db } = await createDatabase();
const id = '123';
await insertRefreshStateRow(knex, {
entity_id: id,
@@ -644,15 +611,12 @@ describe('DefaultProcessingDatabase', () => {
).select();
expect(entities2.length).toBe(1);
expect(entities2[0].cache).toEqual('{}');
- },
- );
- });
+ });
+ });
- describe('getProcessableEntities', () => {
- it.each(databases.eachSupportedId())(
- 'should return entities to process, %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ describe('getProcessableEntities', () => {
+ it('should return entities to process', async () => {
+ const { knex, db } = await createDatabase();
const entity = JSON.stringify({
kind: 'Location',
apiVersion: '1.0.0',
@@ -696,13 +660,10 @@ describe('DefaultProcessingDatabase', () => {
}),
).resolves.toEqual({ items: [] });
});
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'should update the next_refresh interval with a timestamp that includes refresh spread, %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ it('should update the next_refresh interval with a timestamp that includes refresh spread', async () => {
+ const { knex, db } = await createDatabase();
const entity = JSON.stringify({
kind: 'Location',
apiVersion: '1.0.0',
@@ -731,33 +692,30 @@ describe('DefaultProcessingDatabase', () => {
const nextUpdate = timestampToDateTime(result[0].next_update_at);
const nextUpdateDiff = nextUpdate.diff(now, 'seconds');
expect(nextUpdateDiff.seconds).toBeGreaterThanOrEqual(90);
- },
- );
- });
+ });
+ });
- describe('listParents', () => {
- let nextId = 1;
- function makeEntity(ref: string) {
- return {
- entity_id: String(nextId++),
- entity_ref: ref,
- unprocessed_entity: JSON.stringify({
- kind: 'Location',
- apiVersion: '1.0.0',
- metadata: {
- name: 'xyz',
- },
- }),
- errors: '[]',
- next_update_at: '2019-01-01 23:00:00',
- last_discovery_at: '2021-04-01 13:37:00',
- };
- }
+ describe('listParents', () => {
+ let nextId = 1;
+ function makeEntity(ref: string) {
+ return {
+ entity_id: String(nextId++),
+ entity_ref: ref,
+ unprocessed_entity: JSON.stringify({
+ kind: 'Location',
+ apiVersion: '1.0.0',
+ metadata: {
+ name: 'xyz',
+ },
+ }),
+ errors: '[]',
+ next_update_at: '2019-01-01 23:00:00',
+ last_discovery_at: '2021-04-01 13:37:00',
+ };
+ }
- it.each(databases.eachSupportedId())(
- 'should return parents, %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ it('should return parents', async () => {
+ const { knex, db } = await createDatabase();
await knex('refresh_state').insert(
makeEntity('location:default/root-1'),
@@ -803,7 +761,7 @@ describe('DefaultProcessingDatabase', () => {
db.listParents(tx, { entityRefs: ['location:default/root-2'] }),
);
expect(result3.entityRefs).toEqual([]);
- },
- );
- });
-});
+ });
+ });
+ },
+);
diff --git a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts
index 264c11055d..bd32582b12 100644
--- a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts
+++ b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts
@@ -207,49 +207,66 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
request: { processBatchSize: number },
): Promise {
const knex = maybeTx as Knex.Transaction | Knex;
-
- let itemsQuery = knex('refresh_state').select([
- 'entity_id',
- 'entity_ref',
- 'unprocessed_entity',
- 'result_hash',
- 'cache',
- 'errors',
- 'location_key',
- 'next_update_at',
- ]);
-
- // This avoids duplication of work because of race conditions and is
- // also fast because locked rows are ignored rather than blocking.
- // It's only available in MySQL and PostgreSQL
- if (['mysql', 'mysql2', 'pg'].includes(knex.client.config.client)) {
- itemsQuery = itemsQuery.forUpdate().skipLocked();
- }
-
- const items = await itemsQuery
- .where('next_update_at', '<=', knex.fn.now())
- .limit(request.processBatchSize)
- .orderBy('next_update_at', 'asc');
+ const useLocking = ['mysql', 'mysql2', 'pg'].includes(
+ knex.client.config.client,
+ );
const interval = this.options.refreshInterval();
- const nextUpdateAt = (refreshInterval: number) => {
- if (knex.client.config.client.includes('sqlite3')) {
- return knex.raw(`datetime('now', ?)`, [`${refreshInterval} seconds`]);
- } else if (knex.client.config.client.includes('mysql')) {
- return knex.raw(`now() + interval ${refreshInterval} second`);
+ const nextUpdateAt = (
+ tx: Knex | Knex.Transaction,
+ refreshInterval: number,
+ ) => {
+ if (tx.client.config.client.includes('sqlite3')) {
+ return tx.raw(`datetime('now', ?)`, [`${refreshInterval} seconds`]);
+ } else if (tx.client.config.client.includes('mysql')) {
+ return tx.raw(`now() + interval ${refreshInterval} second`);
}
- return knex.raw(`now() + interval '${refreshInterval} seconds'`);
+ return tx.raw(`now() + interval '${refreshInterval} seconds'`);
};
- await knex('refresh_state')
- .whereIn(
- 'entity_ref',
- items.map(i => i.entity_ref),
- )
- .update({
- next_update_at: nextUpdateAt(interval),
- });
+ // The SELECT FOR UPDATE SKIP LOCKED + UPDATE must run inside a
+ // single transaction so that the row locks persist until
+ // next_update_at has been bumped.
+ const run = async (tx: Knex | Knex.Transaction) => {
+ const items: DbRefreshStateRow[] = await tx('refresh_state')
+ .select([
+ 'entity_id',
+ 'entity_ref',
+ 'unprocessed_entity',
+ 'result_hash',
+ 'cache',
+ 'errors',
+ 'location_key',
+ 'next_update_at',
+ ])
+ .where('next_update_at', '<=', tx.fn.now())
+ .limit(request.processBatchSize)
+ .orderBy('next_update_at', 'asc')
+ .modify(qb => {
+ if (useLocking) {
+ qb.forUpdate().skipLocked();
+ }
+ });
+
+ if (items.length > 0) {
+ await tx('refresh_state')
+ .whereIn(
+ 'entity_ref',
+ items.map(i => i.entity_ref),
+ )
+ .update({
+ next_update_at: nextUpdateAt(tx, interval),
+ });
+ }
+
+ return items;
+ };
+
+ const items =
+ knex.isTransaction || !useLocking
+ ? await run(knex)
+ : await knex.transaction(run);
return {
items: items.map(
diff --git a/plugins/catalog-backend/src/database/DefaultProviderDatabase.test.ts b/plugins/catalog-backend/src/database/DefaultProviderDatabase.test.ts
index d9465b27a6..2e2f3657cc 100644
--- a/plugins/catalog-backend/src/database/DefaultProviderDatabase.test.ts
+++ b/plugins/catalog-backend/src/database/DefaultProviderDatabase.test.ts
@@ -14,14 +14,10 @@
* limitations under the License.
*/
-import {
- mockServices,
- TestDatabaseId,
- TestDatabases,
-} from '@backstage/backend-test-utils';
+import { mockServices, TestDatabases } from '@backstage/backend-test-utils';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { Knex } from 'knex';
-import * as uuid from 'uuid';
+import { randomUUID as uuid } from 'node:crypto';
import { DefaultProviderDatabase } from './DefaultProviderDatabase';
import { applyDatabaseMigrations } from './migrations';
import { DbRefreshStateReferencesRow, DbRefreshStateRow } from './tables';
@@ -30,54 +26,52 @@ import { generateStableHash } from './util';
jest.setTimeout(60_000);
-describe('DefaultProviderDatabase', () => {
- const defaultLogger = mockServices.logger.mock();
- const databases = TestDatabases.create();
+const databases = TestDatabases.create();
- async function createDatabase(
- databaseId: TestDatabaseId,
- logger: LoggerService = defaultLogger,
- ) {
- const knex = await databases.init(databaseId);
- await applyDatabaseMigrations(knex);
- return {
- knex,
- db: new DefaultProviderDatabase({
- database: knex,
- logger,
- }),
- };
- }
+describe.each(databases.eachSupportedId())(
+ 'DefaultProviderDatabase, %p',
+ databaseId => {
+ const defaultLogger = mockServices.logger.mock();
- const insertRefRow = async (db: Knex, ref: DbRefreshStateReferencesRow) => {
- return db('refresh_state_references').insert(
- ref,
- );
- };
-
- const insertRefreshStateRow = async (db: Knex, ref: DbRefreshStateRow) => {
- await db('refresh_state').insert(ref);
- };
-
- const createLocations = async (db: Knex, entityRefs: string[]) => {
- for (const ref of entityRefs) {
- await insertRefreshStateRow(db, {
- entity_id: uuid.v4(),
- entity_ref: ref,
- unprocessed_entity: '{}',
- processed_entity: '{}',
- errors: '[]',
- next_update_at: '2021-04-01 13:37:00',
- last_discovery_at: '2021-04-01 13:37:00',
- });
+ async function createDatabase(logger: LoggerService = defaultLogger) {
+ const knex = await databases.init(databaseId);
+ await applyDatabaseMigrations(knex);
+ return {
+ knex,
+ db: new DefaultProviderDatabase({
+ database: knex,
+ logger,
+ }),
+ };
}
- };
- describe('replaceUnprocessedEntities', () => {
- it.each(databases.eachSupportedId())(
- 'replaces all existing state correctly for simple dependency chains, %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ const insertRefRow = async (db: Knex, ref: DbRefreshStateReferencesRow) => {
+ return db('refresh_state_references').insert(
+ ref,
+ );
+ };
+
+ const insertRefreshStateRow = async (db: Knex, ref: DbRefreshStateRow) => {
+ await db('refresh_state').insert(ref);
+ };
+
+ const createLocations = async (db: Knex, entityRefs: string[]) => {
+ for (const ref of entityRefs) {
+ await insertRefreshStateRow(db, {
+ entity_id: uuid(),
+ entity_ref: ref,
+ unprocessed_entity: '{}',
+ processed_entity: '{}',
+ errors: '[]',
+ next_update_at: '2021-04-01 13:37:00',
+ last_discovery_at: '2021-04-01 13:37:00',
+ });
+ }
+ };
+
+ describe('replaceUnprocessedEntities', () => {
+ it('replaces all existing state correctly for simple dependency chains', async () => {
+ const { knex, db } = await createDatabase();
/*
config -> location:default/root -> location:default/root-1 -> location:default/root-2
database -> location:default/second -> location:default/root-2
@@ -187,13 +181,10 @@ describe('DefaultProviderDatabase', () => {
t.source_key === 'config',
),
).toBeTruthy();
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'should work for more complex chains, %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ it('should work for more complex chains', async () => {
+ const { knex, db } = await createDatabase();
/*
config -> location:default/root -> location:default/root-1 -> location:default/root-2
config -> location:default/root -> location:default/root-1a -> location:default/root-2
@@ -323,13 +314,10 @@ describe('DefaultProviderDatabase', () => {
t.target_entity_ref === 'location:default/root-2',
),
).toBeFalsy();
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'should add new locations using the delta options, %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ it('should add new locations using the delta options', async () => {
+ const { knex, db } = await createDatabase();
// Existing state and references should stay
await createLocations(knex, ['location:default/existing']);
@@ -393,13 +381,10 @@ describe('DefaultProviderDatabase', () => {
t.target_entity_ref === 'location:default/existing',
),
).toBeTruthy();
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'should not remove locations that are referenced elsewhere, %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ it('should not remove locations that are referenced elsewhere', async () => {
+ const { knex, db } = await createDatabase();
/*
config-1 -> location:default/root
config-2 -> location:default/root
@@ -443,13 +428,10 @@ describe('DefaultProviderDatabase', () => {
entity_ref: 'location:default/root',
}),
]);
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'should remove old locations using the delta options, %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ it('should remove old locations using the delta options', async () => {
+ const { knex, db } = await createDatabase();
await createLocations(knex, ['location:default/new-root']);
await insertRefRow(knex, {
@@ -492,16 +474,13 @@ describe('DefaultProviderDatabase', () => {
t.target_entity_ref === 'location:default/new-root',
),
).toBeFalsy();
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'should update the location key during full replace, %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ it('should update the location key during full replace', async () => {
+ const { knex, db } = await createDatabase();
await createLocations(knex, ['location:default/removed']);
await insertRefreshStateRow(knex, {
- entity_id: uuid.v4(),
+ entity_id: uuid(),
entity_ref: 'location:default/replaced',
unprocessed_entity: '{}',
processed_entity: '{}',
@@ -558,13 +537,10 @@ describe('DefaultProviderDatabase', () => {
target_entity_ref: 'location:default/replaced',
}),
]);
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'should support replacing modified entities during a full update, %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ it('should support replacing modified entities during a full update', async () => {
+ const { knex, db } = await createDatabase();
await db.transaction(async tx => {
await db.replaceUnprocessedEntities(tx, {
@@ -689,14 +665,11 @@ describe('DefaultProviderDatabase', () => {
target_entity_ref: 'component:default/a',
},
]);
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'should successfully fall back from batch to individual mode on conflicts, %p',
- async databaseId => {
+ it('should successfully fall back from batch to individual mode on conflicts', async () => {
const fakeLogger = mockServices.logger.mock();
- const { knex, db } = await createDatabase(databaseId, fakeLogger);
+ const { knex, db } = await createDatabase(fakeLogger);
await createLocations(knex, ['component:default/a']);
@@ -738,14 +711,11 @@ describe('DefaultProviderDatabase', () => {
}),
]),
);
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'should gracefully handle accidental duplicate refresh state references when deletion happens during a full sync, %p',
- async databaseId => {
+ it('should gracefully handle accidental duplicate refresh state references when deletion happens during a full sync', async () => {
const fakeLogger = mockServices.logger.mock();
- const { knex, db } = await createDatabase(databaseId, fakeLogger);
+ const { knex, db } = await createDatabase(fakeLogger);
await createLocations(knex, ['component:default/a']);
@@ -768,14 +738,11 @@ describe('DefaultProviderDatabase', () => {
const state = await knex('refresh_state').select();
expect(state).toEqual([]);
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'should properly translate deltas into add/update/remove, %p',
- async databaseId => {
+ it('should properly translate deltas into add/update/remove', async () => {
const fakeLogger = mockServices.logger.mock();
- const { knex, db } = await createDatabase(databaseId, fakeLogger);
+ const { knex, db } = await createDatabase(fakeLogger);
const entity1Before: Entity = {
apiVersion: '1',
@@ -950,14 +917,11 @@ describe('DefaultProviderDatabase', () => {
location_key: 'new', // managed to update only the location key
},
]);
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'can handle large deltas without exploding, %p',
- async databaseId => {
+ it('can handle large deltas without exploding', async () => {
const fakeLogger = mockServices.logger.mock();
- const { knex, db } = await createDatabase(databaseId, fakeLogger);
+ const { knex, db } = await createDatabase(fakeLogger);
const count = 10000;
const padded = (n: number) => String(n).padStart(8, '0');
@@ -989,15 +953,12 @@ describe('DefaultProviderDatabase', () => {
unprocessed_entity: JSON.stringify(entities[0].entity),
unprocessed_hash: generateStableHash(entities[0].entity),
});
- },
- );
- });
+ });
+ });
- describe('listReferenceSourceKeys', () => {
- it.each(databases.eachSupportedId())(
- 'returns the source_keys from "refresh_state_references", %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ describe('listReferenceSourceKeys', () => {
+ it('returns the source_keys from "refresh_state_references"', async () => {
+ const { knex, db } = await createDatabase();
await createLocations(knex, [
'location:default/root',
@@ -1018,13 +979,10 @@ describe('DefaultProviderDatabase', () => {
);
expect(res).toEqual(['bar', 'foo']);
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'returns only unique source_keys", %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ it('returns only unique source_keys"', async () => {
+ const { knex, db } = await createDatabase();
await createLocations(knex, [
'location:default/root',
@@ -1045,13 +1003,10 @@ describe('DefaultProviderDatabase', () => {
);
expect(res).toEqual(['foo']);
- },
- );
+ });
- it.each(databases.eachSupportedId())(
- 'does not return null source_keys", %p',
- async databaseId => {
- const { knex, db } = await createDatabase(databaseId);
+ it('does not return null source_keys"', async () => {
+ const { knex, db } = await createDatabase();
await createLocations(knex, [
'location:default/root',
@@ -1071,7 +1026,7 @@ describe('DefaultProviderDatabase', () => {
);
expect(res).toEqual(['foo']);
- },
- );
- });
-});
+ });
+ });
+ },
+);
diff --git a/plugins/catalog-backend/src/database/DefaultProviderDatabase.ts b/plugins/catalog-backend/src/database/DefaultProviderDatabase.ts
index f6f89d066c..fd844ca5e7 100644
--- a/plugins/catalog-backend/src/database/DefaultProviderDatabase.ts
+++ b/plugins/catalog-backend/src/database/DefaultProviderDatabase.ts
@@ -18,7 +18,7 @@ import { stringifyEntityRef } from '@backstage/catalog-model';
import { DeferredEntity } from '@backstage/plugin-catalog-node';
import { Knex } from 'knex';
import lodash from 'lodash';
-import { v4 as uuid } from 'uuid';
+import { randomUUID as uuid } from 'node:crypto';
import { rethrowError } from './conversion';
import { deleteWithEagerPruningOfChildren } from './operations/provider/deleteWithEagerPruningOfChildren';
import { refreshByRefreshKeys } from './operations/provider/refreshByRefreshKeys';
diff --git a/plugins/catalog-backend/src/database/metrics.test.ts b/plugins/catalog-backend/src/database/metrics.test.ts
new file mode 100644
index 0000000000..a4f6a9bd6f
--- /dev/null
+++ b/plugins/catalog-backend/src/database/metrics.test.ts
@@ -0,0 +1,153 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestDatabases } from '@backstage/backend-test-utils';
+import { Knex } from 'knex';
+import { randomUUID as uuid } from 'node:crypto';
+import { applyDatabaseMigrations } from './migrations';
+import { DbFinalEntitiesRow, DbRefreshStateRow } from './tables';
+import { createEntitiesCountByKind, queryEntitiesCountByKind } from './metrics';
+
+jest.setTimeout(60_000);
+
+const databases = TestDatabases.create();
+
+describe.each(databases.eachSupportedId())('metrics, %p', databaseId => {
+ async function createDatabase() {
+ const knex = await databases.init(databaseId);
+ await applyDatabaseMigrations(knex);
+ return knex;
+ }
+
+ async function insertEntity(
+ knex: Knex,
+ options: { entityRef: string; finalEntity: string | null },
+ ): Promise {
+ const entityId = uuid();
+ await knex('refresh_state').insert({
+ entity_id: entityId,
+ entity_ref: options.entityRef,
+ unprocessed_entity: '{}',
+ errors: '[]',
+ next_update_at: '2021-04-01 13:37:00',
+ last_discovery_at: '2021-04-01 13:37:00',
+ });
+ await knex('final_entities').insert({
+ entity_id: entityId,
+ entity_ref: options.entityRef,
+ hash: 'h',
+ final_entity: options.finalEntity ?? undefined,
+ last_updated_at: '2021-04-01 13:37:00',
+ });
+ }
+
+ describe('queryEntitiesCountByKind', () => {
+ it('counts entities grouped by the kind in entity_ref', async () => {
+ const knex = await createDatabase();
+
+ await insertEntity(knex, {
+ entityRef: 'component:default/svc-a',
+ finalEntity: '{"kind":"Component"}',
+ });
+ await insertEntity(knex, {
+ entityRef: 'component:default/svc-b',
+ finalEntity: '{"kind":"Component"}',
+ });
+ await insertEntity(knex, {
+ entityRef: 'api:default/api-a',
+ finalEntity: '{"kind":"API"}',
+ });
+ await insertEntity(knex, {
+ entityRef: 'system:other/sys-a',
+ finalEntity: '{"kind":"System"}',
+ });
+ // Not yet stitched -- must be excluded from the count
+ await insertEntity(knex, {
+ entityRef: 'component:default/pending',
+ finalEntity: null,
+ });
+
+ const result = await queryEntitiesCountByKind(knex);
+
+ expect(Object.fromEntries(result)).toEqual({
+ component: 2,
+ api: 1,
+ system: 1,
+ });
+ });
+ });
+
+ describe('createEntitiesCountByKind', () => {
+ it('serves cached results within the TTL and refreshes after', async () => {
+ const knex = await createDatabase();
+ const getCount = createEntitiesCountByKind(knex, { ttlMs: 50 });
+
+ await insertEntity(knex, {
+ entityRef: 'component:default/one',
+ finalEntity: '{}',
+ });
+
+ const first = await getCount();
+ expect(Object.fromEntries(first)).toEqual({ component: 1 });
+
+ // A change made within the TTL window must not be visible yet.
+ await insertEntity(knex, {
+ entityRef: 'component:default/two',
+ finalEntity: '{}',
+ });
+ const cached = await getCount();
+ expect(Object.fromEntries(cached)).toEqual({ component: 1 });
+
+ // After the TTL elapses the next call hits the database again.
+ await new Promise(resolve => setTimeout(resolve, 80));
+ const refreshed = await getCount();
+ expect(Object.fromEntries(refreshed)).toEqual({ component: 2 });
+ });
+
+ it('coalesces overlapping callers into a single underlying query', async () => {
+ const knex = await createDatabase();
+ const getCount = createEntitiesCountByKind(knex, { ttlMs: 50 });
+
+ await insertEntity(knex, {
+ entityRef: 'component:default/one',
+ finalEntity: '{}',
+ });
+
+ const finalEntitiesQueries: string[] = [];
+ knex.on('query', (q: { sql: string }) => {
+ if (
+ /from\s+["`]?final_entities["`]?/i.test(q.sql) &&
+ /^\s*select/i.test(q.sql)
+ ) {
+ finalEntitiesQueries.push(q.sql);
+ }
+ });
+
+ // Five concurrent callers should result in one query, not five.
+ const results = await Promise.all([
+ getCount(),
+ getCount(),
+ getCount(),
+ getCount(),
+ getCount(),
+ ]);
+ expect(finalEntitiesQueries).toHaveLength(1);
+ for (const r of results) {
+ expect(Object.fromEntries(r)).toEqual({ component: 1 });
+ }
+ });
+ });
+});
diff --git a/plugins/catalog-backend/src/database/metrics.ts b/plugins/catalog-backend/src/database/metrics.ts
index f6a1464b3a..40a3c62b7a 100644
--- a/plugins/catalog-backend/src/database/metrics.ts
+++ b/plugins/catalog-backend/src/database/metrics.ts
@@ -16,12 +16,102 @@
import { Knex } from 'knex';
import { createGaugeMetric } from '../util/metrics';
-import { DbRelationsRow, DbLocationsRow, DbSearchRow } from './tables';
+import { DbRelationsRow, DbLocationsRow } from './tables';
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
+const ENTITIES_COUNT_TTL_MS = 30_000;
+
+/**
+ * Returns a function that produces a Map of entity kind -> count, with
+ * a single-flight cache that coalesces overlapping calls.
+ *
+ * The OpenTelemetry observable gauge and the legacy Prometheus gauge are
+ * both registered to emit the same `catalog_entities_count` series, and
+ * both fire on every metrics scrape. Without coalescing, that means two
+ * identical heavy queries per scrape per pod, which can pile up against
+ * the database faster than they complete.
+ *
+ * Concurrent callers share the same in-flight promise, so a query is
+ * never overlapped by another instance of itself. The TTL is the
+ * minimum age at which the next caller is allowed to start a fresh
+ * query; if a query is still running when the TTL would have elapsed,
+ * waiting callers continue to share that one rather than starting a
+ * duplicate.
+ *
+ * @internal exported for testing
+ */
+export function createEntitiesCountByKind(
+ knex: Knex,
+ options?: { ttlMs?: number },
+): () => Promise