Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions workspaces/homepage/.changeset/unless-and-tags.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 41 additions & 0 deletions workspaces/homepage/app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ homepage:

- id: onboarding
ref: 'rhdh-onboarding-section'
tags: [public]
layout:
xl: { w: 12, h: 6 }
lg: { w: 12, h: 6 }
Expand All @@ -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 }
Expand All @@ -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 }
Expand All @@ -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 }
Expand All @@ -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 }
Expand All @@ -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 }
Expand All @@ -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 }
Expand All @@ -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 }
Expand All @@ -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

Expand Down
18 changes: 13 additions & 5 deletions workspaces/homepage/conditional-policies.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
105 changes: 76 additions & 29 deletions workspaces/homepage/plugins/homepage-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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.
4 changes: 4 additions & 0 deletions workspaces/homepage/plugins/homepage-backend/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,14 @@ interface HomepageDefaultWidgetNodeConfig {
props?: Record<string, unknown>;
/** Responsive layout per breakpoint (xl, lg, md, sm, xs, xxs). */
layouts?: Record<string, HomepageDefaultWidgetLayout>;
/** 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<BackstageUserPrincipal>;
catalog: CatalogService;
permissions: PermissionsService;
referencedPermissions: Set<string>;
logger: LoggerService;
}): Promise<UserContext> {
const { credentials, catalog, permissions, referencedPermissions, logger } =
opts;
}): Promise<{ userEntityRef: string; groupEntityRefs: Set<string> }> {
const { credentials, catalog, logger } = opts;

const userEntityRef = credentials.principal.userEntityRef;
const userEntity = await catalog.getEntityByRef(userEntityRef, {
Expand All @@ -55,28 +45,5 @@ export async function buildUserContext(opts: {
.map(relation => relation.targetRef),
);

const policyDecisions = new Map<string, PolicyDecision>();
if (referencedPermissions.size > 0) {
const names = [...referencedPermissions];

const conditionalPermissionRequests = names.map<QueryPermissionRequest>(
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 };
}
Loading
Loading