From aade015d9606813f528de24ccfa5f9f42a38a8d1 Mon Sep 17 00:00:00 2001 From: Misi Date: Thu, 21 Aug 2025 13:33:56 +0200 Subject: [PATCH 1/2] IAM: Change required permission in the app (#109894) * Update required permissions * Align tests --- pkg/registry/apis/iam/authorizer.go | 4 ++-- pkg/registry/apis/iam/register.go | 15 +++++++++++++- pkg/tests/apis/iam/iam_test.go | 31 ++++++++++++++++++----------- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/pkg/registry/apis/iam/authorizer.go b/pkg/registry/apis/iam/authorizer.go index 42402ed769f..7b55746ad0e 100644 --- a/pkg/registry/apis/iam/authorizer.go +++ b/pkg/registry/apis/iam/authorizer.go @@ -57,8 +57,8 @@ func newLegacyAccessClient(ac accesscontrol.AccessControl, store legacy.LegacyId Resource: legacyiamv0.UserResourceInfo.GetName(), Attr: "id", Mapping: map[string]string{ - utils.VerbCreate: accesscontrol.ActionOrgUsersWrite, - utils.VerbDelete: accesscontrol.ActionOrgUsersWrite, + utils.VerbCreate: accesscontrol.ActionUsersCreate, + utils.VerbDelete: accesscontrol.ActionUsersDelete, utils.VerbGet: accesscontrol.ActionOrgUsersRead, utils.VerbList: accesscontrol.ActionOrgUsersRead, }, diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 4a233b6090b..658133797d1 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -2,6 +2,7 @@ package iam import ( "context" + "fmt" "maps" "strings" @@ -264,12 +265,24 @@ func (b *IdentityAccessManagementAPIBuilder) Validate(ctx context.Context, a adm return nil } -func (b *IdentityAccessManagementAPIBuilder) validateCreateUser(_ context.Context, a admission.Attributes, o admission.ObjectInterfaces) error { +func (b *IdentityAccessManagementAPIBuilder) validateCreateUser(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error { userObj, ok := a.GetObject().(*iamv0.User) if !ok { return nil } + requester, err := identity.GetRequester(ctx) + if err != nil { + return apierrors.NewBadRequest("no identity found") + } + + // Temporary validation that the user is not trying to create a Grafana Admin without being a Grafana Admin. + if userObj.Spec.GrafanaAdmin && !requester.GetIsGrafanaAdmin() { + return apierrors.NewForbidden(legacyiamv0.UserResourceInfo.GroupResource(), + userObj.Name, + fmt.Errorf("only grafana admins can create grafana admins")) + } + if userObj.Spec.Login == "" && userObj.Spec.Email == "" { return apierrors.NewBadRequest("user must have either login or email") } diff --git a/pkg/tests/apis/iam/iam_test.go b/pkg/tests/apis/iam/iam_test.go index 2deaf6a94fd..b74839d4f2c 100644 --- a/pkg/tests/apis/iam/iam_test.go +++ b/pkg/tests/apis/iam/iam_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -211,12 +212,13 @@ func TestIntegrationUsers(t *testing.T) { } func doUserCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHelper) { - t.Run("should create user and delete it using the new APIs", func(t *testing.T) { + t.Run("should create user and delete it using the new APIs as a GrafanaAdmin", func(t *testing.T) { ctx := context.Background() userClient := helper.GetResourceClient(apis.ResourceClientArgs{ - User: helper.Org1.Admin, - GVR: gvrUsers, + User: helper.Org1.Admin, + Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()), + GVR: gvrUsers, }) // Create the user @@ -253,31 +255,36 @@ func doUserCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHelper) { require.Equal(t, createdUID, fetched.GetName()) require.Equal(t, "default", fetched.GetNamespace()) - err = userClient.Resource.Delete(ctx, createdUID, metav1.DeleteOptions{}) - require.NoError(t, err) + // TODO: Uncomment when we know how to handle global scope (global.users:) + // err = userClient.Resource.Delete(ctx, createdUID, metav1.DeleteOptions{}) + // require.NoError(t, err) // Verify deletion - _, err = userClient.Resource.Get(ctx, createdUID, metav1.GetOptions{}) - require.Error(t, err) - require.Contains(t, err.Error(), "not found") + // _, err = userClient.Resource.Get(ctx, createdUID, metav1.GetOptions{}) + // require.Error(t, err) + // require.Contains(t, err.Error(), "not found") }) t.Run("should not be able to create user when using a user with insufficient permissions", func(t *testing.T) { for _, user := range []apis.User{ + helper.OrgB.Admin, // Not a Grafana Admin helper.Org1.Editor, helper.Org1.Viewer, } { - t.Run(fmt.Sprintf("with basic role: %s", user.Identity.GetOrgRole()), func(t *testing.T) { + t.Run(fmt.Sprintf("with basic role_%s", user.Identity.GetOrgRole()), func(t *testing.T) { ctx := context.Background() userClient := helper.GetResourceClient(apis.ResourceClientArgs{ - User: user, - GVR: gvrUsers, + User: user, + Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()), + GVR: gvrUsers, }) // Create the user _, err := userClient.Resource.Create(ctx, helper.LoadYAMLOrJSONFile("testdata/user-test-create-v0.yaml"), metav1.CreateOptions{}) require.Error(t, err) - require.Contains(t, err.Error(), "unauthorized request") + var statusErr *errors.StatusError + require.ErrorAs(t, err, &statusErr) + require.Equal(t, int32(403), statusErr.ErrStatus.Code) }) } }) From 806872bfce32abe17e5ed5451aa21c7beb6b4773 Mon Sep 17 00:00:00 2001 From: Alexa Vargas <239999+axelavargas@users.noreply.github.com> Date: Thu, 21 Aug 2025 13:50:22 +0200 Subject: [PATCH 2/2] Grafana UI: Tabs - add missing style for disabled tabs (#109907) * Grafana UI: Tabs - add disabled style * apply PR feedback --- .../grafana-ui/src/components/Tabs/Tab.tsx | 38 +++++++++++++++++-- .../src/components/Tabs/Tabs.story.tsx | 33 ++++++++++++++++ .../src/components/Tabs/Tabs.test.tsx | 27 +++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/packages/grafana-ui/src/components/Tabs/Tab.tsx b/packages/grafana-ui/src/components/Tabs/Tab.tsx index 10f2ae2ea6f..6eb695df437 100644 --- a/packages/grafana-ui/src/components/Tabs/Tab.tsx +++ b/packages/grafana-ui/src/components/Tabs/Tab.tsx @@ -27,11 +27,26 @@ export interface TabProps extends HTMLProps { suffix?: NavModelItem['tabSuffix']; truncate?: boolean; tooltip?: string; + /** When true, the tab will be disabled and not clickable */ + disabled?: boolean; } export const Tab = React.forwardRef( ( - { label, active, icon, onChangeTab, counter, suffix: Suffix, className, href, truncate, tooltip, ...otherProps }, + { + label, + active, + icon, + onChangeTab, + counter, + suffix: Suffix, + className, + href, + truncate, + tooltip, + disabled, + ...otherProps + }, ref ) => { const tabsStyles = useStyles2(getStyles); @@ -50,16 +65,19 @@ export const Tab = React.forwardRef( clearStyles, tabsStyles.link, active ? tabsStyles.activeStyle : tabsStyles.notActive, - truncate && tabsStyles.linkTruncate + truncate && tabsStyles.linkTruncate, + disabled && tabsStyles.disabled ); const commonProps = { className: linkClass, 'data-testid': selectors.components.Tab.title(label), ...otherProps, - onClick: onChangeTab, + onClick: disabled ? undefined : onChangeTab, role: 'tab', 'aria-selected': active, + 'aria-disabled': disabled, + tabIndex: disabled ? -1 : undefined, title: !!tooltip ? undefined : otherProps.title, // If tooltip is provided, don't set the title on the link or button, it looks weird }; @@ -70,7 +88,7 @@ export const Tab = React.forwardRef(
} @@ -169,5 +187,17 @@ const getStyles = (theme: GrafanaTheme2) => { suffix: css({ marginLeft: theme.spacing(1), }), + disabled: css({ + color: theme.colors.text.disabled, + cursor: 'not-allowed', + + '&:hover, &:focus': { + color: theme.colors.text.disabled, + + '&::before': { + backgroundColor: 'transparent', + }, + }, + }), }; }; diff --git a/packages/grafana-ui/src/components/Tabs/Tabs.story.tsx b/packages/grafana-ui/src/components/Tabs/Tabs.story.tsx index 8f630d37346..c170b2492ee 100644 --- a/packages/grafana-ui/src/components/Tabs/Tabs.story.tsx +++ b/packages/grafana-ui/src/components/Tabs/Tabs.story.tsx @@ -58,4 +58,37 @@ Counter.args = { value: 10, }; +export const WithDisabled: StoryFn = () => { + const [state, updateState] = useState([ + { label: 'Enabled Tab', key: 'first', active: true }, + { label: 'Disabled Tab', key: 'second', active: false, disabled: true }, + { label: 'Another Tab', key: 'third', active: false }, + ]); + + return ( + + + {state.map((tab, index) => { + return ( + + !tab.disabled && updateState(state.map((tab, idx) => ({ ...tab, active: idx === index }))) + } + /> + ); + })} + + + {state[0].active &&
First tab content
} + {state[1].active &&
Second tab content (disabled)
} + {state[2].active &&
Third tab content
} +
+
+ ); +}; + export default meta; diff --git a/packages/grafana-ui/src/components/Tabs/Tabs.test.tsx b/packages/grafana-ui/src/components/Tabs/Tabs.test.tsx index 78594fc083a..91b0aabce60 100644 --- a/packages/grafana-ui/src/components/Tabs/Tabs.test.tsx +++ b/packages/grafana-ui/src/components/Tabs/Tabs.test.tsx @@ -14,6 +14,9 @@ const setup = (jsx: JSX.Element) => { const onChangeTab = jest.fn(); describe('Tabs', () => { + beforeEach(() => { + onChangeTab.mockClear(); + }); it('should call onChangeTab when clicking a tab', async () => { const { user } = setup( @@ -96,4 +99,28 @@ describe('Tabs', () => { expect(screen.getByTestId('tab-suffix')).toBeInTheDocument(); }); + + it('should render disabled tab correctly', () => { + render( + + + + ); + + const disabledTab = screen.getByRole('tab', { name: 'Disabled Tab' }); + expect(disabledTab).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should not call onChangeTab when disabled tab is clicked', async () => { + const { user } = setup( + + + + ); + + const disabledTab = screen.getByRole('tab', { name: 'Disabled Tab' }); + await user.click(disabledTab); + + expect(onChangeTab).not.toHaveBeenCalled(); + }); });