From 5147ac16c9db0d6f5aab101895c96ec1d9dde0dc Mon Sep 17 00:00:00 2001 From: Karthik Date: Tue, 23 Jun 2026 15:05:51 +0530 Subject: [PATCH] feat(homepage): add unless exclusion and tags for RBAC conditional filtering Signed-off-by: Karthik --- .../homepage/.changeset/unless-and-tags.md | 11 + workspaces/homepage/app-config.yaml | 41 +++ workspaces/homepage/conditional-policies.yaml | 18 +- .../plugins/homepage-backend/README.md | 105 +++++-- .../plugins/homepage-backend/config.d.ts | 4 + .../src/defaultWidgets/buildUserContext.ts | 39 +-- .../defaultWidgets/evaluateVisibility.test.ts | 258 ++++++++++++++++++ .../src/defaultWidgets/evaluateVisibility.ts | 93 +++---- .../defaultWidgets/loadDefaultWidgets.test.ts | 100 +++++++ .../src/defaultWidgets/loadDefaultWidgets.ts | 3 + .../src/permissions/permissionUtils.test.ts | 210 ++++++++++++++ .../src/permissions/permissionUtils.ts | 64 +++++ .../homepage-backend/src/permissions/rules.ts | 21 +- .../homepage-backend/src/plugin.test.ts | 6 +- .../src/services/DefaultWidgetsService.ts | 53 +++- .../plugins/homepage-common/report.api.md | 6 + .../plugins/homepage-common/src/types.ts | 3 + .../components/DefaultWidgetsReadOnlyGrid.tsx | 2 +- 18 files changed, 910 insertions(+), 127 deletions(-) create mode 100644 workspaces/homepage/.changeset/unless-and-tags.md create mode 100644 workspaces/homepage/plugins/homepage-backend/src/permissions/permissionUtils.test.ts create mode 100644 workspaces/homepage/plugins/homepage-backend/src/permissions/permissionUtils.ts diff --git a/workspaces/homepage/.changeset/unless-and-tags.md b/workspaces/homepage/.changeset/unless-and-tags.md new file mode 100644 index 0000000000..953378a862 --- /dev/null +++ b/workspaces/homepage/.changeset/unless-and-tags.md @@ -0,0 +1,11 @@ +--- +'@red-hat-developer-hub/backstage-plugin-homepage': patch +'@red-hat-developer-hub/backstage-plugin-homepage-backend': minor +'@red-hat-developer-hub/backstage-plugin-homepage-common': minor +--- + +Add `unless` exclusion block and `tags` for RBAC conditional policy filtering to homepage default widgets. + +`unless` is the denylist counterpart to `if` — it uses the same shape (`users`, `groups`, `permissions`) and hides a widget when any condition matches. Deny wins over `if`, and on group nodes it prunes the entire subtree. + +`tags` is an optional string array on leaf nodes (e.g. `['admin', 'developer']`) used with the new `HAS_TAG` permission rule for RBAC conditional filtering. Widgets without tags bypass tag-based filtering. diff --git a/workspaces/homepage/app-config.yaml b/workspaces/homepage/app-config.yaml index 72ecc17f7f..b5b2195e5b 100644 --- a/workspaces/homepage/app-config.yaml +++ b/workspaces/homepage/app-config.yaml @@ -168,6 +168,7 @@ homepage: - id: onboarding ref: 'rhdh-onboarding-section' + tags: [public] layout: xl: { w: 12, h: 6 } lg: { w: 12, h: 6 } @@ -177,6 +178,7 @@ homepage: xxs: { w: 12, h: 14 } - id: entity-list ref: 'rhdh-entity-section' + tags: [general] layout: xl: { w: 12, h: 7 } lg: { w: 12, h: 7 } @@ -186,6 +188,7 @@ homepage: xxs: { w: 12, h: 15 } - id: template-list ref: 'rhdh-template-section' + tags: [developer] layout: xl: { w: 12, h: 5 } lg: { w: 12, h: 5 } @@ -195,6 +198,7 @@ homepage: xxs: { w: 12, h: 13.5 } - id: quickaccess-card ref: quickaccess-card + tags: [general] layout: xl: { w: 6, h: 8, x: 6 } lg: { w: 6, h: 8, x: 6 } @@ -207,6 +211,7 @@ homepage: children: - id: featured-docs-card ref: featured-docs-card + tags: [general] layout: xl: { w: 6, h: 4 } lg: { w: 6, h: 4 } @@ -216,6 +221,7 @@ homepage: xxs: { w: 12, h: 4 } - id: catalog-starred-entities-card ref: catalog-starred-entities-card + tags: [general] layout: xl: { w: 6, h: 4 } lg: { w: 6, h: 4 } @@ -228,6 +234,7 @@ homepage: children: - id: recently-visited-card ref: recently-visited-card + tags: [developer] layout: xl: { w: 6, h: 4 } lg: { w: 6, h: 4 } @@ -237,6 +244,7 @@ homepage: xxs: { w: 12, h: 4 } - id: top-visited-card ref: top-visited-card + tags: [developer] layout: xl: { w: 6, h: 4 } lg: { w: 6, h: 4 } @@ -245,6 +253,39 @@ homepage: xs: { w: 12, h: 4 } xxs: { w: 12, h: 4 } + # --- tags + unless examples --- + + # Visible to developers group but hidden from developer-user specifically + - id: test-unless-user + ref: headline + if: + groups: [group:default/developers] + unless: + users: [user:default/developer-user] + props: + title: 'Visible to developers group, but hidden from developer-user via "unless"' + layout: + xl: { w: 12, h: 1 } + + # Visible to everyone except the admins group + - id: test-unless-group + ref: headline + unless: + groups: [group:default/admins] + props: + title: 'Hidden from the admins group via "unless"' + layout: + xl: { w: 12, h: 1 } + + # Tagged 'admin' — not in the conditional policy tags, so RBAC will filter it out + - id: test-tagged-admin + ref: headline + tags: [admin] + props: + title: 'Tagged admin — filtered by RBAC since the policy only allows public/general/developer' + layout: + xl: { w: 12, h: 1 } + organization: name: My Company diff --git a/workspaces/homepage/conditional-policies.yaml b/workspaces/homepage/conditional-policies.yaml index afd61c30df..33ae84bdcb 100644 --- a/workspaces/homepage/conditional-policies.yaml +++ b/workspaces/homepage/conditional-policies.yaml @@ -6,8 +6,16 @@ resourceType: homepage-default-widget permissionMapping: - read conditions: - rule: HAS_WIDGET_ID - resourceType: homepage-default-widget - params: - widgetIds: - - test-if-user-can-read-this-widget + anyOf: + - rule: HAS_WIDGET_ID + resourceType: homepage-default-widget + params: + widgetIds: + - test-if-user-can-read-this-widget + - rule: HAS_TAG + resourceType: homepage-default-widget + params: + tags: + - public + - general + - developer diff --git a/workspaces/homepage/plugins/homepage-backend/README.md b/workspaces/homepage/plugins/homepage-backend/README.md index af0524b46b..f85931870d 100644 --- a/workspaces/homepage/plugins/homepage-backend/README.md +++ b/workspaces/homepage/plugins/homepage-backend/README.md @@ -43,7 +43,20 @@ For each request to `GET /api/homepage/default-widgets`, the backend looks at th Rules inside `if` use **OR** logic. If the parent fails its `if`, nothing under it is shown. -At startup the backend finds every permission name used in the tree. Each request checks only those names in one batch. +**Who cannot see a card (`unless`).** A node can have an optional `unless` block. It uses the same shape as `if` (`users`, `groups`, `permissions`) but acts as a **denylist**—if any condition matches, the widget is hidden. + +- `unless` is checked **before** `if`. Deny wins: if both match, the widget is hidden. +- Rules inside `unless` also use **OR** logic—matching any user, group, or permission triggers the exclusion. +- On a group node, `unless` prunes the entire subtree without evaluating children. +- If `unless` is missing or empty, it never excludes. + +**Tags (`tags`).** A leaf node can have an optional `tags` array of strings (for example `['admin', 'developer']`). Tags are used for RBAC conditional policy filtering with the `HAS_TAG` permission rule. + +- Tags are passed through to the API response so the RBAC layer can filter on them. +- Widgets **without** tags bypass tag-based RBAC filtering entirely—they are always included when the RBAC decision is `CONDITIONAL`. +- Tags have no effect on config-time `if`/`unless` checks. They only matter at the RBAC layer. + +At startup the backend finds every permission name used in `if` and `unless` blocks across the tree. Each request checks only those names in one batch. **Leaves vs groups.** A **leaf** needs `id` and `ref`. A **group** needs `children` and must not use `id` or `ref`. The full rules are in `src/defaultWidgets/loadDefaultWidgets.ts`. @@ -52,51 +65,85 @@ At startup the backend finds every permission name used in the tree. Each reques ```yaml homepage: defaultWidgets: - # --- Simple cards (leaves) --- - # id = mountpoint id for the card; ref = which widget to render. + # --- Simple card with tags --- - id: onboarding ref: 'rhdh-onboarding-section' + tags: [public] layout: xl: { w: 12, h: 6 } lg: { w: 12, h: 6 } + + # --- Card visible to developers, tagged for RBAC filtering --- + - id: template-list + ref: 'rhdh-template-section' + tags: [developer] + if: + groups: [group:default/developers] + layout: + xl: { w: 12, h: 5 } + + # --- Card visible to developers but hidden from interns (unless) --- - id: quickaccess-card ref: quickaccess-card + tags: [developer] + if: + groups: [group:default/developers] + unless: + groups: [group:default/interns] layout: xl: { w: 6, h: 8, x: 6 } # --- Group with children (shared visibility) --- - # The group row has `if` and `children` only. All listed cards are hidden - # unless the user passes the group's `if` (here: member of admins). - if: groups: [group:default/admins] children: - id: rbac ref: RBAC + tags: [admin] + layout: + xl: { w: 12, h: 6 } + + # --- Group hidden from a specific user via unless --- + - if: + groups: [group:default/admins] + unless: + users: [user:default/alice] + children: + - id: audit-log + ref: platform-audit + tags: [admin] layout: xl: { w: 12, h: 6 } - # --- group with several children --- - # Use one parent to apply the same visibility to multiple cards. - # - if: - # groups: [group:default/platform-team] - # children: - # - id: metrics-card - # ref: platform-metrics - # layout: - # xl: { w: 6, h: 8 } - # - id: logs-card - # ref: platform-logs - # layout: - # xl: { w: 6, h: 8 } - # # Each child can still have its own `if` for finer rules. - # - id: audit-card - # ref: platform-audit - # if: - # users: [user:default/auditor] - - # --- Commented: single card gated by permission --- - # - id: admin-insights - # ref: admin-insights-card - # if: - # permissions: ['homepage.default-widgets.read'] + # --- Entire subtree hidden from viewers --- + - unless: + groups: [group:default/viewers] + children: + - id: dev-tools + ref: dev-tools-card + tags: [developer] + layout: + xl: { w: 12, h: 4 } +``` + +### Three filtering layers + ``` +Config (if/unless) --> Permission check (ALLOW/DENY/CONDITIONAL) --> Conditional rules (HAS_TAG/HAS_WIDGET_ID) + Layer 1 Layer 2 Layer 3 +``` + +- **Layer 1** always runs—identity and group based (`if`/`unless`). +- **Layer 2**—RBAC returns ALLOW (pass all), DENY (block all), or CONDITIONAL (apply Layer 3). +- **Layer 3**—rule-based filtering on survivors from Layer 1 using `HAS_TAG` and/or `HAS_WIDGET_ID`. + +### Permission rules + +The plugin registers two permission rules for the `homepage-default-widget` resource type: + +| Rule | Params | Description | +| --------------- | --------------------- | ------------------------------------------------------ | +| `HAS_WIDGET_ID` | `widgetIds: string[]` | Matches widgets whose `id` is in the list | +| `HAS_TAG` | `tags: string[]` | Matches widgets that have at least one overlapping tag | + +These rules can be used in RBAC conditional policies (via file or the RBAC UI) to control which widgets a role can see. Widgets without tags bypass `HAS_TAG` filtering entirely. diff --git a/workspaces/homepage/plugins/homepage-backend/config.d.ts b/workspaces/homepage/plugins/homepage-backend/config.d.ts index a4e04ac72c..25314e1c16 100644 --- a/workspaces/homepage/plugins/homepage-backend/config.d.ts +++ b/workspaces/homepage/plugins/homepage-backend/config.d.ts @@ -37,10 +37,14 @@ interface HomepageDefaultWidgetNodeConfig { props?: Record; /** Responsive layout per breakpoint (xl, lg, md, sm, xs, xxs). */ layouts?: Record; + /** Tags for RBAC conditional policy filtering (e.g. ['admin', 'developer']). */ + tags?: string[]; /** Child nodes. Presence makes this a group; must be omitted when `id` is set. */ children?: HomepageDefaultWidgetNodeConfig[]; /** Optional visibility constraints; omitted or empty means visible to all. */ if?: HomepageDefaultWidgetVisibilityConfig; + /** Optional exclusion constraints; if any condition matches, the widget is hidden (deny wins over if). */ + unless?: HomepageDefaultWidgetVisibilityConfig; } export interface Config { diff --git a/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/buildUserContext.ts b/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/buildUserContext.ts index ae70c7033f..fe2796655e 100644 --- a/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/buildUserContext.ts +++ b/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/buildUserContext.ts @@ -18,26 +18,16 @@ import { BackstageCredentials, BackstageUserPrincipal, LoggerService, - PermissionsService, } from '@backstage/backend-plugin-api'; import { RELATION_MEMBER_OF } from '@backstage/catalog-model'; import { CatalogService } from '@backstage/plugin-catalog-node'; -import { - createPermission, - PolicyDecision, - QueryPermissionRequest, -} from '@backstage/plugin-permission-common'; -import { UserContext } from './types'; export async function buildUserContext(opts: { credentials: BackstageCredentials; catalog: CatalogService; - permissions: PermissionsService; - referencedPermissions: Set; logger: LoggerService; -}): Promise { - const { credentials, catalog, permissions, referencedPermissions, logger } = - opts; +}): Promise<{ userEntityRef: string; groupEntityRefs: Set }> { + const { credentials, catalog, logger } = opts; const userEntityRef = credentials.principal.userEntityRef; const userEntity = await catalog.getEntityByRef(userEntityRef, { @@ -55,28 +45,5 @@ export async function buildUserContext(opts: { .map(relation => relation.targetRef), ); - const policyDecisions = new Map(); - if (referencedPermissions.size > 0) { - const names = [...referencedPermissions]; - - const conditionalPermissionRequests = names.map( - name => ({ - permission: createPermission({ - name, - attributes: { action: 'read' }, - resourceType: 'homepage-default-widget', - }), - }), - ); - - const conditionalDecisions = await permissions.authorizeConditional( - conditionalPermissionRequests, - { credentials }, - ); - conditionalDecisions.forEach((decision, index) => { - policyDecisions.set(names[index], decision); - }); - } - - return { userEntityRef, groupEntityRefs, policyDecisions }; + return { userEntityRef, groupEntityRefs }; } diff --git a/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/evaluateVisibility.test.ts b/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/evaluateVisibility.test.ts index 2d8090105f..465a36ac9d 100644 --- a/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/evaluateVisibility.test.ts +++ b/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/evaluateVisibility.test.ts @@ -21,6 +21,7 @@ import { import { filterToVisibleLeafIds, filterToVisibleLeaves, + isExcluded, isVisible, } from './evaluateVisibility'; import { DefaultWidgetNode, UserContext } from './types'; @@ -307,6 +308,132 @@ describe('isVisible', () => { }); }); +describe('isExcluded', () => { + const ctx = makeCtx({ + groupEntityRefs: new Set(['group:default/developers']), + policyDecisions: new Map([ + ['perm.allowed', { result: AuthorizeResult.ALLOW }], + ['perm.denied', { result: AuthorizeResult.DENY }], + ]), + }); + + it('not excluded when no unless block is provided', () => { + expect(isExcluded({}, ctx)).toBe(false); + }); + + it('not excluded when unless block is empty', () => { + expect(isExcluded({ unless: {} }, ctx)).toBe(false); + }); + + it('not excluded when unless has only empty arrays', () => { + expect( + isExcluded({ unless: { users: [], groups: [], permissions: [] } }, ctx), + ).toBe(false); + }); + + it('excluded when user ref matches', () => { + expect(isExcluded({ unless: { users: ['user:default/alice'] } }, ctx)).toBe( + true, + ); + }); + + it('not excluded when user ref does not match', () => { + expect(isExcluded({ unless: { users: ['user:default/bob'] } }, ctx)).toBe( + false, + ); + }); + + it('excluded when group ref matches', () => { + expect( + isExcluded({ unless: { groups: ['group:default/developers'] } }, ctx), + ).toBe(true); + }); + + it('excluded when permission is ALLOW', () => { + expect(isExcluded({ unless: { permissions: ['perm.allowed'] } }, ctx)).toBe( + true, + ); + }); + + it('not excluded when permission is DENY', () => { + expect(isExcluded({ unless: { permissions: ['perm.denied'] } }, ctx)).toBe( + false, + ); + }); + + it('not excluded when permission is missing from decisions (fails closed)', () => { + expect(isExcluded({ unless: { permissions: ['perm.unknown'] } }, ctx)).toBe( + false, + ); + }); + + describe('conditional permissions', () => { + const conditionFor = (widgetIds: string[]) => ({ + rule: 'HAS_WIDGET_ID', + resourceType: 'homepage-default-widget', + params: { widgetIds }, + }); + + const conditionalDecision = ( + conditions: PolicyDecision extends infer T + ? T extends { conditions: infer C } + ? C + : never + : never, + ): PolicyDecision => ({ + result: AuthorizeResult.CONDITIONAL, + pluginId: 'homepage', + resourceType: 'homepage-default-widget', + conditions, + }); + + it('excluded when CONDITIONAL decision matches widget id', () => { + const ctxCond = makeCtx({ + policyDecisions: new Map([ + ['perm.cond', conditionalDecision(conditionFor(['my-widget']))], + ]), + }); + expect( + isExcluded( + { id: 'my-widget', unless: { permissions: ['perm.cond'] } }, + ctxCond, + ), + ).toBe(true); + }); + + it('not excluded when CONDITIONAL decision does not match widget id', () => { + const ctxCond = makeCtx({ + policyDecisions: new Map([ + ['perm.cond', conditionalDecision(conditionFor(['other-widget']))], + ]), + }); + expect( + isExcluded( + { id: 'my-widget', unless: { permissions: ['perm.cond'] } }, + ctxCond, + ), + ).toBe(false); + }); + + it('not excluded when CONDITIONAL uses not and the inner condition matches', () => { + const ctxCond = makeCtx({ + policyDecisions: new Map([ + [ + 'perm.cond', + conditionalDecision({ not: conditionFor(['my-widget']) }), + ], + ]), + }); + expect( + isExcluded( + { id: 'my-widget', unless: { permissions: ['perm.cond'] } }, + ctxCond, + ), + ).toBe(false); + }); + }); +}); + describe('filterToVisibleLeafIds', () => { const ctx = makeCtx({ groupEntityRefs: new Set(['group:default/developers']), @@ -415,6 +542,78 @@ describe('filterToVisibleLeafIds', () => { ]; expect(filterToVisibleLeafIds(tree, ctx)).toEqual(['a', 'b', 'c']); }); + + it('excludes a leaf when unless matches the user', () => { + const tree: DefaultWidgetNode[] = [ + { id: 'a', ref: 'a' }, + { + id: 'b', + ref: 'b', + unless: { users: ['user:default/alice'] }, + }, + ]; + expect(filterToVisibleLeafIds(tree, ctx)).toEqual(['a']); + }); + + it('unless takes precedence over if (deny wins)', () => { + const tree: DefaultWidgetNode[] = [ + { + id: 'a', + ref: 'a', + if: { groups: ['group:default/developers'] }, + unless: { users: ['user:default/alice'] }, + }, + ]; + expect(filterToVisibleLeafIds(tree, ctx)).toEqual([]); + }); + + it('prunes entire subtree when unless on group node matches', () => { + const tree: DefaultWidgetNode[] = [ + { + unless: { groups: ['group:default/developers'] }, + children: [ + { id: 'a', ref: 'a' }, + { id: 'b', ref: 'b' }, + ], + }, + { id: 'c', ref: 'c' }, + ]; + expect(filterToVisibleLeafIds(tree, ctx)).toEqual(['c']); + }); + + it('excludes a leaf when unless matches a permission', () => { + const ctxWithPerm = makeCtx({ + policyDecisions: new Map([ + ['perm.exclude', { result: AuthorizeResult.ALLOW }], + ]), + }); + const tree: DefaultWidgetNode[] = [ + { id: 'a', ref: 'a' }, + { + id: 'b', + ref: 'b', + unless: { permissions: ['perm.exclude'] }, + }, + ]; + expect(filterToVisibleLeafIds(tree, ctxWithPerm)).toEqual(['a']); + }); + + it('keeps a leaf when unless permission is DENY', () => { + const ctxWithPerm = makeCtx({ + policyDecisions: new Map([ + ['perm.exclude', { result: AuthorizeResult.DENY }], + ]), + }); + const tree: DefaultWidgetNode[] = [ + { id: 'a', ref: 'a' }, + { + id: 'b', + ref: 'b', + unless: { permissions: ['perm.exclude'] }, + }, + ]; + expect(filterToVisibleLeafIds(tree, ctxWithPerm)).toEqual(['a', 'b']); + }); }); describe('filterToVisibleLeaves', () => { @@ -504,4 +703,63 @@ describe('filterToVisibleLeaves', () => { { id: 'visible', ref: 'visible' }, ]); }); + + it('includes tags in output when present', () => { + const tree: DefaultWidgetNode[] = [ + { id: 'tagged', ref: 'tagged', tags: ['admin', 'management'] }, + ]; + expect(filterToVisibleLeaves(tree, ctx)).toEqual([ + { id: 'tagged', ref: 'tagged', tags: ['admin', 'management'] }, + ]); + }); + + it('omits tags from output when empty array', () => { + const tree: DefaultWidgetNode[] = [{ id: 'x', ref: 'x', tags: [] }]; + const result = filterToVisibleLeaves(tree, ctx); + expect(result).toEqual([{ id: 'x', ref: 'x' }]); + expect(Object.keys(result[0])).toEqual(['id', 'ref']); + }); + + it('excludes widget when unless matches', () => { + const tree: DefaultWidgetNode[] = [ + { id: 'a', ref: 'a' }, + { + id: 'b', + ref: 'b', + unless: { groups: ['group:default/developers'] }, + }, + ]; + expect(filterToVisibleLeaves(tree, ctx)).toEqual([{ id: 'a', ref: 'a' }]); + }); + + it('unless takes precedence over if in leaf output', () => { + const tree: DefaultWidgetNode[] = [ + { + id: 'x', + ref: 'x', + if: { groups: ['group:default/developers'] }, + unless: { users: ['user:default/alice'] }, + }, + ]; + expect(filterToVisibleLeaves(tree, ctx)).toEqual([]); + }); + + it('excludes widget when unless matches a permission', () => { + const ctxWithPerm = makeCtx({ + policyDecisions: new Map([ + ['perm.exclude', { result: AuthorizeResult.ALLOW }], + ]), + }); + const tree: DefaultWidgetNode[] = [ + { id: 'a', ref: 'a' }, + { + id: 'b', + ref: 'b', + unless: { permissions: ['perm.exclude'] }, + }, + ]; + expect(filterToVisibleLeaves(tree, ctxWithPerm)).toEqual([ + { id: 'a', ref: 'a' }, + ]); + }); }); diff --git a/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/evaluateVisibility.ts b/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/evaluateVisibility.ts index 518218306f..a9f84153cc 100644 --- a/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/evaluateVisibility.ts +++ b/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/evaluateVisibility.ts @@ -15,26 +15,18 @@ */ import { - PermissionCondition, - PermissionCriteria, - PermissionRuleParams, -} from '@backstage/plugin-permission-common'; -import { DefaultWidgetNode, UserContext, VisibleDefaultWidget } from './types'; -import { rules } from '../permissions/rules'; + DefaultWidgetNode, + DefaultWidgetVisibility, + UserContext, + VisibleDefaultWidget, +} from './types'; +import { matches } from '../permissions/permissionUtils'; -export function isVisible( +function matchesVisibility( defaultWidget: DefaultWidgetNode, + visibility: DefaultWidgetVisibility, ctx: UserContext, ): boolean { - const visibility = defaultWidget?.if; - if (!visibility) return true; - - const hasAnyCondition = - (visibility.users?.length ?? 0) > 0 || - (visibility.groups?.length ?? 0) > 0 || - (visibility.permissions?.length ?? 0) > 0; - if (!hasAnyCondition) return true; - const matchUser = visibility.users?.some(ref => ref === ctx.userEntityRef) ?? false; const matchGroup = @@ -46,13 +38,43 @@ export function isVisible( return ( decision.result === 'ALLOW' || (decision.result === 'CONDITIONAL' && - matches(defaultWidget, decision.conditions)) + matches(defaultWidget as VisibleDefaultWidget, decision.conditions)) ); }) ?? false; - const matchAny = matchUser || matchGroup || matchPolicy; + return matchUser || matchGroup || matchPolicy; +} + +export function isVisible( + defaultWidget: DefaultWidgetNode, + ctx: UserContext, +): boolean { + const visibility = defaultWidget?.if; + if (!visibility) return true; + + const hasAny = + (visibility.users?.length ?? 0) > 0 || + (visibility.groups?.length ?? 0) > 0 || + (visibility.permissions?.length ?? 0) > 0; + if (!hasAny) return true; + + return matchesVisibility(defaultWidget, visibility, ctx); +} + +export function isExcluded( + defaultWidget: DefaultWidgetNode, + ctx: UserContext, +): boolean { + const visibility = defaultWidget?.unless; + if (!visibility) return false; + + const hasAny = + (visibility.users?.length ?? 0) > 0 || + (visibility.groups?.length ?? 0) > 0 || + (visibility.permissions?.length ?? 0) > 0; + if (!hasAny) return false; - return matchAny; + return matchesVisibility(defaultWidget, visibility, ctx); } export function filterToVisibleLeafIds( @@ -61,6 +83,7 @@ export function filterToVisibleLeafIds( ): string[] { const out: string[] = []; const walk = (node: DefaultWidgetNode) => { + if (isExcluded(node, ctx)) return; if (!isVisible(node, ctx)) return; if (node.id !== undefined) out.push(node.id); node.children?.forEach(walk); @@ -75,6 +98,7 @@ export function filterToVisibleLeaves( ): VisibleDefaultWidget[] { const out: VisibleDefaultWidget[] = []; const walk = (node: DefaultWidgetNode) => { + if (isExcluded(node, ctx)) return; if (!isVisible(node, ctx)) return; if (node.id !== undefined) { const card: VisibleDefaultWidget = { @@ -83,6 +107,8 @@ export function filterToVisibleLeaves( }; if (node.props !== undefined) card.props = node.props; if (node.layout !== undefined) card.layout = node.layout; + if (node.tags !== undefined && node.tags.length > 0) + card.tags = node.tags; out.push(card); } node.children?.forEach(walk); @@ -90,32 +116,3 @@ export function filterToVisibleLeaves( nodes.forEach(walk); return out; } - -const matches = ( - defaultWidget: DefaultWidgetNode, - filters?: PermissionCriteria< - PermissionCondition - >, -): boolean => { - if (!filters) { - return true; - } - - if ('allOf' in filters) { - return filters.allOf.every(filter => matches(defaultWidget, filter)); - } - - if ('anyOf' in filters) { - return filters.anyOf.some(filter => matches(defaultWidget, filter)); - } - - if ('not' in filters) { - return !matches(defaultWidget, filters.not); - } - - return ( - Object.values(rules) - .find(r => r.name === filters.rule) - ?.apply(defaultWidget, filters.params ?? {}) ?? false - ); -}; diff --git a/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/loadDefaultWidgets.test.ts b/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/loadDefaultWidgets.test.ts index e23bbc9c87..8d77604651 100644 --- a/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/loadDefaultWidgets.test.ts +++ b/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/loadDefaultWidgets.test.ts @@ -179,6 +179,87 @@ describe('loadDefaultWidgets', () => { }); expect(loadDefaultWidgets(config)).toHaveLength(1); }); + + it('parses a node with an unless block', () => { + const config = mockServices.rootConfig({ + data: { + homepage: { + defaultWidgets: [ + { + id: 'x', + ref: 'x', + unless: { groups: ['group:default/contractors'] }, + }, + ], + }, + }, + }); + const result = loadDefaultWidgets(config); + expect(result).toEqual([ + { + id: 'x', + ref: 'x', + unless: { groups: ['group:default/contractors'] }, + }, + ]); + }); + + it('parses a node with tags', () => { + const config = mockServices.rootConfig({ + data: { + homepage: { + defaultWidgets: [ + { + id: 'x', + ref: 'x', + tags: ['admin', 'management'], + }, + ], + }, + }, + }); + const result = loadDefaultWidgets(config); + expect(result).toEqual([ + { id: 'x', ref: 'x', tags: ['admin', 'management'] }, + ]); + }); + + it('throws when unless has an invalid group ref', () => { + const config = mockServices.rootConfig({ + data: { + homepage: { + defaultWidgets: [ + { + id: 'x', + ref: 'x', + unless: { groups: ['not-a-ref'] }, + }, + ], + }, + }, + }); + expect(() => loadDefaultWidgets(config)).toThrow( + /Invalid homepage\.defaultWidgets/, + ); + }); + + it('accepts a node with both if and unless', () => { + const config = mockServices.rootConfig({ + data: { + homepage: { + defaultWidgets: [ + { + id: 'x', + ref: 'x', + if: { groups: ['group:default/engineering'] }, + unless: { users: ['user:default/intern'] }, + }, + ], + }, + }, + }); + expect(loadDefaultWidgets(config)).toHaveLength(1); + }); }); describe('collectReferencedPermissions', () => { @@ -218,4 +299,23 @@ describe('collectReferencedPermissions', () => { new Set(['perm.read', 'perm.write', 'perm.admin']), ); }); + + it('collects permissions from unless blocks', () => { + const tree: DefaultWidgetNode[] = [ + { + id: 'a', + ref: 'a', + if: { permissions: ['perm.read'] }, + unless: { permissions: ['perm.exclude'] }, + }, + { + id: 'b', + ref: 'b', + unless: { permissions: ['perm.deny'] }, + }, + ]; + expect(collectReferencedPermissions(tree)).toEqual( + new Set(['perm.read', 'perm.exclude', 'perm.deny']), + ); + }); }); diff --git a/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/loadDefaultWidgets.ts b/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/loadDefaultWidgets.ts index ea2ab7bb36..f132b0ca2d 100644 --- a/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/loadDefaultWidgets.ts +++ b/workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/loadDefaultWidgets.ts @@ -48,7 +48,9 @@ export const defaultWidgetNodeSchema: z.ZodType = z.lazy( ref: z.string().min(1).optional(), props: z.record(z.string(), z.unknown()).optional(), layout: z.record(z.string(), z.unknown()).optional(), + tags: z.array(z.string().min(1)).optional(), if: visibilitySchema.optional(), + unless: visibilitySchema.optional(), children: z.array(defaultWidgetNodeSchema).optional(), }) .strict() @@ -94,6 +96,7 @@ export function collectReferencedPermissions( const out = new Set(); const walk = (n: DefaultWidgetNode) => { n.if?.permissions?.forEach(p => out.add(p)); + n.unless?.permissions?.forEach(p => out.add(p)); n.children?.forEach(walk); }; nodes.forEach(walk); diff --git a/workspaces/homepage/plugins/homepage-backend/src/permissions/permissionUtils.test.ts b/workspaces/homepage/plugins/homepage-backend/src/permissions/permissionUtils.test.ts new file mode 100644 index 0000000000..289a85e9cd --- /dev/null +++ b/workspaces/homepage/plugins/homepage-backend/src/permissions/permissionUtils.test.ts @@ -0,0 +1,210 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { VisibleDefaultWidget } from '../defaultWidgets/types'; +import { filterAuthorizedWidgets, matches } from './permissionUtils'; + +const widget = (id: string, tags?: string[]): VisibleDefaultWidget => ({ + id, + ref: id, + ...(tags && { tags }), +}); + +describe('matches', () => { + it('returns true when no filter is provided', () => { + expect(matches(widget('a'), undefined)).toBe(true); + }); + + it('matches by HAS_WIDGET_ID rule', () => { + expect( + matches(widget('onboarding'), { + rule: 'HAS_WIDGET_ID', + resourceType: 'homepage-default-widget', + params: { widgetIds: ['onboarding', 'search'] }, + }), + ).toBe(true); + }); + + it('does not match HAS_WIDGET_ID when id is not in list', () => { + expect( + matches(widget('admin-panel'), { + rule: 'HAS_WIDGET_ID', + resourceType: 'homepage-default-widget', + params: { widgetIds: ['onboarding'] }, + }), + ).toBe(false); + }); + + it('matches by HAS_TAG rule', () => { + expect( + matches(widget('dashboard', ['admin', 'management']), { + rule: 'HAS_TAG', + resourceType: 'homepage-default-widget', + params: { tags: ['admin'] }, + }), + ).toBe(true); + }); + + it('does not match HAS_TAG when widget has no tags', () => { + expect( + matches(widget('dashboard'), { + rule: 'HAS_TAG', + resourceType: 'homepage-default-widget', + params: { tags: ['admin'] }, + }), + ).toBe(false); + }); + + it('does not match HAS_TAG when no tag overlaps', () => { + expect( + matches(widget('dashboard', ['developer']), { + rule: 'HAS_TAG', + resourceType: 'homepage-default-widget', + params: { tags: ['admin'] }, + }), + ).toBe(false); + }); + + it('handles anyOf combinator', () => { + expect( + matches(widget('search'), { + anyOf: [ + { + rule: 'HAS_WIDGET_ID', + resourceType: 'homepage-default-widget', + params: { widgetIds: ['onboarding'] }, + }, + { + rule: 'HAS_WIDGET_ID', + resourceType: 'homepage-default-widget', + params: { widgetIds: ['search'] }, + }, + ], + }), + ).toBe(true); + }); + + it('handles allOf combinator', () => { + expect( + matches(widget('dashboard', ['admin']), { + allOf: [ + { + rule: 'HAS_WIDGET_ID', + resourceType: 'homepage-default-widget', + params: { widgetIds: ['dashboard'] }, + }, + { + rule: 'HAS_TAG', + resourceType: 'homepage-default-widget', + params: { tags: ['admin'] }, + }, + ], + }), + ).toBe(true); + }); + + it('handles not combinator', () => { + expect( + matches(widget('public'), { + not: { + rule: 'HAS_TAG', + resourceType: 'homepage-default-widget', + params: { tags: ['admin'] }, + }, + }), + ).toBe(true); + }); + + it('returns false for unknown rule', () => { + expect( + matches(widget('a'), { + rule: 'UNKNOWN_RULE', + resourceType: 'homepage-default-widget', + params: {}, + }), + ).toBe(false); + }); +}); + +describe('filterAuthorizedWidgets', () => { + const widgets = [ + widget('search'), + widget('admin-panel', ['admin']), + widget('dev-tools', ['developer']), + widget('onboarding'), + ]; + + it('returns all widgets when no filter is provided', () => { + expect(filterAuthorizedWidgets(widgets, undefined)).toEqual(widgets); + }); + + it('filters by widget ID', () => { + const result = filterAuthorizedWidgets(widgets, { + rule: 'HAS_WIDGET_ID', + resourceType: 'homepage-default-widget', + params: { widgetIds: ['search', 'onboarding'] }, + }); + expect(result.map(w => w.id)).toEqual(['search', 'onboarding']); + }); + + it('filters by tag (tagless widgets bypass filter)', () => { + const result = filterAuthorizedWidgets(widgets, { + rule: 'HAS_TAG', + resourceType: 'homepage-default-widget', + params: { tags: ['admin'] }, + }); + expect(result.map(w => w.id)).toEqual([ + 'search', + 'admin-panel', + 'onboarding', + ]); + }); + + it('filters with anyOf combining tags (tagless widgets bypass filter)', () => { + const result = filterAuthorizedWidgets(widgets, { + anyOf: [ + { + rule: 'HAS_TAG', + resourceType: 'homepage-default-widget', + params: { tags: ['admin'] }, + }, + { + rule: 'HAS_TAG', + resourceType: 'homepage-default-widget', + params: { tags: ['developer'] }, + }, + ], + }); + expect(result.map(w => w.id)).toEqual([ + 'search', + 'admin-panel', + 'dev-tools', + 'onboarding', + ]); + }); + + it('tagless widgets always included regardless of filter', () => { + const result = filterAuthorizedWidgets( + [widget('no-tags'), widget('tagged', ['secret'])], + { + rule: 'HAS_TAG', + resourceType: 'homepage-default-widget', + params: { tags: ['admin'] }, + }, + ); + expect(result.map(w => w.id)).toEqual(['no-tags']); + }); +}); diff --git a/workspaces/homepage/plugins/homepage-backend/src/permissions/permissionUtils.ts b/workspaces/homepage/plugins/homepage-backend/src/permissions/permissionUtils.ts new file mode 100644 index 0000000000..774fd86b4a --- /dev/null +++ b/workspaces/homepage/plugins/homepage-backend/src/permissions/permissionUtils.ts @@ -0,0 +1,64 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { + PermissionCondition, + PermissionCriteria, + PermissionRuleParams, +} from '@backstage/plugin-permission-common'; +import { VisibleDefaultWidget } from '../defaultWidgets/types'; +import { rules as homepageRules } from './rules'; + +export const matches = ( + widget: VisibleDefaultWidget, + filters?: PermissionCriteria< + PermissionCondition + >, +): boolean => { + if (!filters) { + return true; + } + + if ('allOf' in filters) { + return filters.allOf.every(filter => matches(widget, filter)); + } + + if ('anyOf' in filters) { + return filters.anyOf.some(filter => matches(widget, filter)); + } + + if ('not' in filters) { + return !matches(widget, filters.not); + } + + const matchedRule = Object.values(homepageRules).find( + r => r.name === filters.rule, + ) as any; + return matchedRule?.apply(widget, filters.params ?? {}) ?? false; +}; + +export const filterAuthorizedWidgets = ( + widgets: VisibleDefaultWidget[], + filter?: PermissionCriteria< + PermissionCondition + >, +): VisibleDefaultWidget[] => { + if (!filter) return widgets; + return widgets.filter( + widget => + !widget.tags || widget.tags.length === 0 || matches(widget, filter), + ); +}; diff --git a/workspaces/homepage/plugins/homepage-backend/src/permissions/rules.ts b/workspaces/homepage/plugins/homepage-backend/src/permissions/rules.ts index 131bec0cc5..96a36fb34e 100644 --- a/workspaces/homepage/plugins/homepage-backend/src/permissions/rules.ts +++ b/workspaces/homepage/plugins/homepage-backend/src/permissions/rules.ts @@ -49,4 +49,23 @@ const hasWidgetId = createPermissionRule({ }, }); -export const rules = { hasWidgetId }; +const hasTag = createPermissionRule({ + name: 'HAS_TAG', + description: + 'Should allow users to access homepage widgets with specified tags', + resourceRef: homepageDefaultWidgetPermissionResourceRef, + paramsSchema: z.object({ + tags: z.string().array().optional().describe('List of tags to match on'), + }), + apply: (defaultWidget: DefaultWidgetNode, { tags }) => { + if (!tags || tags.length === 0) return true; + if (!defaultWidget.tags || defaultWidget.tags.length === 0) return false; + return tags.some(tag => defaultWidget.tags!.includes(tag)); + }, + toQuery: ({ tags }) => ({ + key: 'tag', + values: tags, + }), +}); + +export const rules = { hasWidgetId, hasTag }; diff --git a/workspaces/homepage/plugins/homepage-backend/src/plugin.test.ts b/workspaces/homepage/plugins/homepage-backend/src/plugin.test.ts index 6e183549b8..0723fbbd5f 100644 --- a/workspaces/homepage/plugins/homepage-backend/src/plugin.test.ts +++ b/workspaces/homepage/plugins/homepage-backend/src/plugin.test.ts @@ -82,7 +82,11 @@ describe('homepagePlugin', () => { }), mockServices.permissions.mock({ authorizeConditional: async requests => - requests.map(() => ({ result: AuthorizeResult.DENY })), + requests.map((_, i) => + i === 0 + ? { result: AuthorizeResult.ALLOW } + : { result: AuthorizeResult.DENY }, + ), }).factory, ], }); diff --git a/workspaces/homepage/plugins/homepage-backend/src/services/DefaultWidgetsService.ts b/workspaces/homepage/plugins/homepage-backend/src/services/DefaultWidgetsService.ts index daae539c9b..ccc6fb8a5a 100644 --- a/workspaces/homepage/plugins/homepage-backend/src/services/DefaultWidgetsService.ts +++ b/workspaces/homepage/plugins/homepage-backend/src/services/DefaultWidgetsService.ts @@ -25,6 +25,13 @@ import { createServiceRef, } from '@backstage/backend-plugin-api'; import { catalogServiceRef } from '@backstage/plugin-catalog-node'; +import { + AuthorizeResult, + createPermission, + PolicyDecision, + QueryPermissionRequest, +} from '@backstage/plugin-permission-common'; +import { homepageDefaultWidgetsReadPermission } from '@red-hat-developer-hub/backstage-plugin-homepage-common'; import { buildUserContext } from '../defaultWidgets/buildUserContext'; import { filterToVisibleLeaves } from '../defaultWidgets/evaluateVisibility'; import { @@ -34,7 +41,9 @@ import { import { DefaultWidgetNode, DefaultWidgetsResponse, + UserContext, } from '../defaultWidgets/types'; +import { filterAuthorizedWidgets } from '../permissions/permissionUtils'; export interface DefaultWidgetsService { getDefaultWidgets(options: { @@ -95,16 +104,48 @@ export class DefaultWidgetsServiceImpl implements DefaultWidgetsService { if (!this.#tree) { return {}; } - const ctx = await buildUserContext({ + const identity = await buildUserContext({ credentials, catalog: this.#catalog, - permissions: this.#permissions, - referencedPermissions: this.#referencedPermissions, logger: this.#logger, }); - return { - items: filterToVisibleLeaves(this.#tree, ctx), - }; + + const refPermNames = [...this.#referencedPermissions]; + const requests: QueryPermissionRequest[] = [ + { permission: homepageDefaultWidgetsReadPermission }, + ...refPermNames.map(name => ({ + permission: createPermission({ + name, + attributes: { action: 'read' as const }, + resourceType: 'homepage-default-widget', + }), + })), + ]; + + const allDecisions = await this.#permissions.authorizeConditional( + requests, + { + credentials, + }, + ); + + const [rbacDecision, ...refDecisions] = allDecisions; + + const policyDecisions = new Map(); + refDecisions.forEach((decision, index) => { + policyDecisions.set(refPermNames[index], decision); + }); + + const ctx: UserContext = { ...identity, policyDecisions }; + const configFiltered = filterToVisibleLeaves(this.#tree, ctx); + + if (rbacDecision.result === AuthorizeResult.CONDITIONAL) { + return { + items: filterAuthorizedWidgets(configFiltered, rbacDecision.conditions), + }; + } + + return { items: configFiltered }; } } diff --git a/workspaces/homepage/plugins/homepage-common/report.api.md b/workspaces/homepage/plugins/homepage-common/report.api.md index 475db4fb99..d93c152446 100644 --- a/workspaces/homepage/plugins/homepage-common/report.api.md +++ b/workspaces/homepage/plugins/homepage-common/report.api.md @@ -19,6 +19,10 @@ export interface DefaultWidgetNode { props?: Record; // (undocumented) ref?: string; + // (undocumented) + tags?: string[]; + // (undocumented) + unless?: DefaultWidgetVisibility; } // @public (undocumented) @@ -61,5 +65,7 @@ export interface VisibleDefaultWidget { props?: Record; // (undocumented) ref: string; + // (undocumented) + tags?: string[]; } ``` diff --git a/workspaces/homepage/plugins/homepage-common/src/types.ts b/workspaces/homepage/plugins/homepage-common/src/types.ts index 2fcd9182b5..4849e1bb2f 100644 --- a/workspaces/homepage/plugins/homepage-common/src/types.ts +++ b/workspaces/homepage/plugins/homepage-common/src/types.ts @@ -31,7 +31,9 @@ export interface DefaultWidgetNode { ref?: string; props?: Record; layout?: unknown; + tags?: string[]; if?: DefaultWidgetVisibility; + unless?: DefaultWidgetVisibility; children?: DefaultWidgetNode[]; } @@ -43,6 +45,7 @@ export interface VisibleDefaultWidget { ref: string; props?: Record; layout?: unknown; + tags?: string[]; } /** diff --git a/workspaces/homepage/plugins/homepage/src/components/DefaultWidgetsReadOnlyGrid.tsx b/workspaces/homepage/plugins/homepage/src/components/DefaultWidgetsReadOnlyGrid.tsx index 5877bf00ea..66f6035023 100644 --- a/workspaces/homepage/plugins/homepage/src/components/DefaultWidgetsReadOnlyGrid.tsx +++ b/workspaces/homepage/plugins/homepage/src/components/DefaultWidgetsReadOnlyGrid.tsx @@ -148,7 +148,7 @@ export const DefaultWidgetsReadOnlyGrid = ({ return { id, Component: mountPoint.Component, - props: widget.props, + props: { ...mountPoint.config?.props, ...widget.props }, layouts, }; })