Skip to content

Commit 0186e63

Browse files
karthikjeeyarchristoph-jerolimovclaude
authored
feat(homepage): add unless exclusion and tags for RBAC conditional filtering (#3546)
* feat(homepage): add unless exclusion and tags for RBAC conditional filtering Signed-off-by: Karthik <karthik.jk11@gmail.com> * refactor(homepage): simplify permission checks by moving logic into buildUserContext Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Christoph Jerolimov <jerolimov+git@redhat.com> --------- Signed-off-by: Karthik <karthik.jk11@gmail.com> Signed-off-by: Christoph Jerolimov <jerolimov+git@redhat.com> Co-authored-by: Christoph Jerolimov <jerolimov+git@redhat.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d903ed7 commit 0186e63

19 files changed

Lines changed: 728 additions & 224 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-homepage': patch
3+
'@red-hat-developer-hub/backstage-plugin-homepage-backend': minor
4+
'@red-hat-developer-hub/backstage-plugin-homepage-common': minor
5+
---
6+
7+
Add `unless` exclusion block and `tags` for RBAC conditional policy filtering to homepage default widgets.
8+
9+
`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.
10+
11+
`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.

workspaces/homepage/app-config.yaml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ homepage:
168168

169169
- id: onboarding
170170
ref: 'rhdh-onboarding-section'
171+
tags: [public]
171172
layout:
172173
xl: { w: 12, h: 6 }
173174
lg: { w: 12, h: 6 }
@@ -177,6 +178,7 @@ homepage:
177178
xxs: { w: 12, h: 14 }
178179
- id: entity-list
179180
ref: 'rhdh-entity-section'
181+
tags: [general]
180182
layout:
181183
xl: { w: 12, h: 7 }
182184
lg: { w: 12, h: 7 }
@@ -186,6 +188,7 @@ homepage:
186188
xxs: { w: 12, h: 15 }
187189
- id: template-list
188190
ref: 'rhdh-template-section'
191+
tags: [developer]
189192
layout:
190193
xl: { w: 12, h: 5 }
191194
lg: { w: 12, h: 5 }
@@ -195,6 +198,7 @@ homepage:
195198
xxs: { w: 12, h: 13.5 }
196199
- id: quickaccess-card
197200
ref: quickaccess-card
201+
tags: [general]
198202
layout:
199203
xl: { w: 6, h: 8, x: 6 }
200204
lg: { w: 6, h: 8, x: 6 }
@@ -207,6 +211,7 @@ homepage:
207211
children:
208212
- id: featured-docs-card
209213
ref: featured-docs-card
214+
tags: [general]
210215
layout:
211216
xl: { w: 6, h: 4 }
212217
lg: { w: 6, h: 4 }
@@ -216,6 +221,7 @@ homepage:
216221
xxs: { w: 12, h: 4 }
217222
- id: catalog-starred-entities-card
218223
ref: catalog-starred-entities-card
224+
tags: [general]
219225
layout:
220226
xl: { w: 6, h: 4 }
221227
lg: { w: 6, h: 4 }
@@ -228,6 +234,7 @@ homepage:
228234
children:
229235
- id: recently-visited-card
230236
ref: recently-visited-card
237+
tags: [developer]
231238
layout:
232239
xl: { w: 6, h: 4 }
233240
lg: { w: 6, h: 4 }
@@ -237,6 +244,7 @@ homepage:
237244
xxs: { w: 12, h: 4 }
238245
- id: top-visited-card
239246
ref: top-visited-card
247+
tags: [developer]
240248
layout:
241249
xl: { w: 6, h: 4 }
242250
lg: { w: 6, h: 4 }
@@ -245,6 +253,39 @@ homepage:
245253
xs: { w: 12, h: 4 }
246254
xxs: { w: 12, h: 4 }
247255

256+
# --- tags + unless examples ---
257+
258+
# Visible to developers group but hidden from developer-user specifically
259+
- id: test-unless-user
260+
ref: headline
261+
if:
262+
groups: [group:default/developers]
263+
unless:
264+
users: [user:default/developer-user]
265+
props:
266+
title: 'Visible to developers group, but hidden from developer-user via "unless"'
267+
layout:
268+
xl: { w: 12, h: 1 }
269+
270+
# Visible to everyone except the admins group
271+
- id: test-unless-group
272+
ref: headline
273+
unless:
274+
groups: [group:default/admins]
275+
props:
276+
title: 'Hidden from the admins group via "unless"'
277+
layout:
278+
xl: { w: 12, h: 1 }
279+
280+
# Tagged 'admin' — not in the conditional policy tags, so RBAC will filter it out
281+
- id: test-tagged-admin
282+
ref: headline
283+
tags: [admin]
284+
props:
285+
title: 'Tagged admin — filtered by RBAC since the policy only allows public/general/developer'
286+
layout:
287+
xl: { w: 12, h: 1 }
288+
248289
organization:
249290
name: My Company
250291

workspaces/homepage/conditional-policies.yaml

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,16 @@ resourceType: homepage-default-widget
66
permissionMapping:
77
- read
88
conditions:
9-
rule: HAS_WIDGET_ID
10-
resourceType: homepage-default-widget
11-
params:
12-
widgetIds:
13-
- test-if-user-can-read-this-widget
9+
anyOf:
10+
- rule: HAS_WIDGET_ID
11+
resourceType: homepage-default-widget
12+
params:
13+
widgetIds:
14+
- test-if-user-can-read-this-widget
15+
- rule: HAS_TAG
16+
resourceType: homepage-default-widget
17+
params:
18+
tags:
19+
- public
20+
- general
21+
- developer

workspaces/homepage/plugins/homepage-backend/README.md

Lines changed: 65 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,20 @@ For each request to `GET /api/homepage/default-widgets`, the backend looks at th
4343

4444
Rules inside `if` use **OR** logic. If the parent fails its `if`, nothing under it is shown.
4545

46-
At startup the backend finds every permission name used in the tree. Each request checks only those names in one batch.
46+
**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.
47+
48+
- `unless` is checked **before** `if`. Deny wins: if both match, the widget is hidden.
49+
- Rules inside `unless` also use **OR** logic—matching any user, group, or permission triggers the exclusion.
50+
- On a group node, `unless` prunes the entire subtree without evaluating children.
51+
- If `unless` is missing or empty, it never excludes.
52+
53+
**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.
54+
55+
- Tags are passed through to the API response so the RBAC layer can filter on them.
56+
- Widgets **without** tags bypass tag-based RBAC filtering entirely—they are always included when the RBAC decision is `CONDITIONAL`.
57+
- Tags have no effect on config-time `if`/`unless` checks. They only matter at the RBAC layer.
58+
59+
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.
4760

4861
**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`.
4962

@@ -52,51 +65,74 @@ At startup the backend finds every permission name used in the tree. Each reques
5265
```yaml
5366
homepage:
5467
defaultWidgets:
55-
# --- Simple cards (leaves) ---
56-
# id = mountpoint id for the card; ref = which widget to render.
68+
# --- Simple card with tags ---
5769
- id: onboarding
5870
ref: 'rhdh-onboarding-section'
71+
tags: [public]
5972
layout:
6073
xl: { w: 12, h: 6 }
6174
lg: { w: 12, h: 6 }
75+
76+
# --- Card visible to developers, tagged for RBAC filtering ---
77+
- id: template-list
78+
ref: 'rhdh-template-section'
79+
tags: [developer]
80+
if:
81+
groups: [group:default/developers]
82+
layout:
83+
xl: { w: 12, h: 5 }
84+
85+
# --- Card visible to developers but hidden from interns (unless) ---
6286
- id: quickaccess-card
6387
ref: quickaccess-card
88+
tags: [developer]
89+
if:
90+
groups: [group:default/developers]
91+
unless:
92+
groups: [group:default/interns]
6493
layout:
6594
xl: { w: 6, h: 8, x: 6 }
6695

6796
# --- Group with children (shared visibility) ---
68-
# The group row has `if` and `children` only. All listed cards are hidden
69-
# unless the user passes the group's `if` (here: member of admins).
7097
- if:
7198
groups: [group:default/admins]
7299
children:
73100
- id: rbac
74101
ref: RBAC
102+
tags: [admin]
75103
layout:
76104
xl: { w: 12, h: 6 }
77105

78-
# --- group with several children ---
79-
# Use one parent to apply the same visibility to multiple cards.
80-
# - if:
81-
# groups: [group:default/platform-team]
82-
# children:
83-
# - id: metrics-card
84-
# ref: platform-metrics
85-
# layout:
86-
# xl: { w: 6, h: 8 }
87-
# - id: logs-card
88-
# ref: platform-logs
89-
# layout:
90-
# xl: { w: 6, h: 8 }
91-
# # Each child can still have its own `if` for finer rules.
92-
# - id: audit-card
93-
# ref: platform-audit
94-
# if:
95-
# users: [user:default/auditor]
96-
97-
# --- Commented: single card gated by permission ---
98-
# - id: admin-insights
99-
# ref: admin-insights-card
100-
# if:
101-
# permissions: ['homepage.default-widgets.read']
106+
# --- Group hidden from a specific user via unless ---
107+
- if:
108+
groups: [group:default/admins]
109+
unless:
110+
users: [user:default/alice]
111+
children:
112+
- id: audit-log
113+
ref: platform-audit
114+
tags: [admin]
115+
layout:
116+
xl: { w: 12, h: 6 }
117+
118+
# --- Entire subtree hidden from viewers ---
119+
- unless:
120+
groups: [group:default/viewers]
121+
children:
122+
- id: dev-tools
123+
ref: dev-tools-card
124+
tags: [developer]
125+
layout:
126+
xl: { w: 12, h: 4 }
102127
```
128+
129+
### Permission rules
130+
131+
The plugin registers two permission rules for the `homepage-default-widget` resource type:
132+
133+
| Rule | Params | Description |
134+
| --------------- | --------------------- | ------------------------------------------------------ |
135+
| `HAS_WIDGET_ID` | `widgetIds: string[]` | Matches widgets whose `id` is in the list |
136+
| `HAS_TAG` | `tags: string[]` | Matches widgets that have at least one overlapping tag |
137+
138+
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.

workspaces/homepage/plugins/homepage-backend/config.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,14 @@ interface HomepageDefaultWidgetNodeConfig {
3737
props?: Record<string, unknown>;
3838
/** Responsive layout per breakpoint (xl, lg, md, sm, xs, xxs). */
3939
layouts?: Record<string, HomepageDefaultWidgetLayout>;
40+
/** Tags for RBAC conditional policy filtering (e.g. ['admin', 'developer']). */
41+
tags?: string[];
4042
/** Child nodes. Presence makes this a group; must be omitted when `id` is set. */
4143
children?: HomepageDefaultWidgetNodeConfig[];
4244
/** Optional visibility constraints; omitted or empty means visible to all. */
4345
if?: HomepageDefaultWidgetVisibilityConfig;
46+
/** Optional exclusion constraints; if any condition matches, the widget is hidden (deny wins over if). */
47+
unless?: HomepageDefaultWidgetVisibilityConfig;
4448
}
4549

4650
export interface Config {

workspaces/homepage/plugins/homepage-backend/src/defaultWidgets/buildUserContext.ts

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
PolicyDecision,
2828
QueryPermissionRequest,
2929
} from '@backstage/plugin-permission-common';
30+
import { homepageDefaultWidgetsReadPermission } from '@red-hat-developer-hub/backstage-plugin-homepage-common';
3031
import { UserContext } from './types';
3132

3233
export async function buildUserContext(opts: {
@@ -39,6 +40,7 @@ export async function buildUserContext(opts: {
3940
const { credentials, catalog, permissions, referencedPermissions, logger } =
4041
opts;
4142

43+
// user ref
4244
const userEntityRef = credentials.principal.userEntityRef;
4345
const userEntity = await catalog.getEntityByRef(userEntityRef, {
4446
credentials,
@@ -49,34 +51,45 @@ export async function buildUserContext(opts: {
4951
`User entity '${userEntityRef}' not found in catalog; group-based visibility will fail closed`,
5052
);
5153
}
54+
55+
// group refs
5256
const groupEntityRefs = new Set<string>(
5357
(userEntity?.relations ?? [])
5458
.filter(relation => relation.type === RELATION_MEMBER_OF)
5559
.map(relation => relation.targetRef),
5660
);
5761

58-
const policyDecisions = new Map<string, PolicyDecision>();
59-
if (referencedPermissions.size > 0) {
60-
const names = [...referencedPermissions];
61-
62-
const conditionalPermissionRequests = names.map<QueryPermissionRequest>(
63-
name => ({
64-
permission: createPermission({
65-
name,
66-
attributes: { action: 'read' },
67-
resourceType: 'homepage-default-widget',
68-
}),
62+
// permissions
63+
const names = [...referencedPermissions];
64+
const conditionalPermissionRequests = [
65+
// This default permission will be "removed" below and added as a dedicated
66+
// `defaultWidgetsReadDecision` to the user context.
67+
{
68+
permission: homepageDefaultWidgetsReadPermission,
69+
},
70+
...names.map<QueryPermissionRequest>(name => ({
71+
permission: createPermission({
72+
name,
73+
attributes: { action: 'read' },
74+
resourceType: 'homepage-default-widget',
6975
}),
70-
);
76+
})),
77+
];
7178

72-
const conditionalDecisions = await permissions.authorizeConditional(
73-
conditionalPermissionRequests,
74-
{ credentials },
75-
);
76-
conditionalDecisions.forEach((decision, index) => {
77-
policyDecisions.set(names[index], decision);
79+
const [defaultWidgetsReadDecision, ...otherConditionalDecisions] =
80+
await permissions.authorizeConditional(conditionalPermissionRequests, {
81+
credentials,
7882
});
79-
}
8083

81-
return { userEntityRef, groupEntityRefs, policyDecisions };
84+
const otherPolicyDecisions = new Map<string, PolicyDecision>();
85+
otherConditionalDecisions.forEach((decision, index) => {
86+
otherPolicyDecisions.set(names[index], decision);
87+
});
88+
89+
return {
90+
userEntityRef,
91+
groupEntityRefs,
92+
defaultWidgetsReadDecision,
93+
otherPolicyDecisions,
94+
};
8295
}

0 commit comments

Comments
 (0)