Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add RBAC rules to side-nav #9159

Closed
wants to merge 1 commit into from
Closed
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
65 changes: 61 additions & 4 deletions awx/ui_next/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import {
useRouteMatch,
useLocation,
Expand All @@ -13,6 +13,7 @@ import AppContainer from './components/AppContainer';
import Background from './components/Background';
import NotFound from './screens/NotFound';
import Login from './screens/Login';
import { MeAPI, UsersAPI, OrganizationsAPI } from './api';

import ja from './locales/ja/messages';
import en from './locales/en/messages';
Expand All @@ -30,6 +31,12 @@ const ProtectedRoute = ({ children, ...rest }) =>

function App() {
const catalogs = { en, ja };
const [userData, setUserData] = useState({});
const [isOrgAdmin, setIsOrgAdmin] = useState(false);
const [isNotifAdmin, setIsNotifAdmin] = useState(false);
const [fetchUserError, setFetchUserError] = useState(null);
const [isUserDataReady, setIsUserDataReady] = useState(false);

let language = getLanguageWithoutRegionCode(navigator);
if (!Object.keys(catalogs).includes(language)) {
// If there isn't a string catalog available for the browser's
Expand All @@ -39,6 +46,48 @@ function App() {
const match = useRouteMatch();
const { hash, search, pathname } = useLocation();

const fetchUserData = async () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds a lot of logic to the App component... I'm wondering if it can be moved down into a new component instead (maybe something wrapping <Login>? We did a lot of work last year to keep these top level components nice and streamlined, and I'd hate to lose some of that

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey @keithjgrant, thanks for your comment. I did think about a context that could be updated by the login component, and store information about the logged user. This could avoid de-duplicate a few requests inside the AppContainer that check the same data. The drawback is that we will have one of more context. In this case we will probably need to wrap the whole application on this new context. I will explore the idea to move logic to the login component. If you have a specific suggestion, just let me know.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'd avoid a new context I think. I'd see what would happen you either move it into tho Login component, or create a new component that wraps the Login component to encapsulate that logic and expose the callbacks props needed to interact with it

try {
const [
{
data: {
results: [me],
},
},
] = await Promise.all([MeAPI.read()]);

const [
{
data: { count: adminOrgCount },
},
{
data: { count: notifAdminCount },
},
] = await Promise.all([
UsersAPI.readAdminOfOrganizations(me?.id),
OrganizationsAPI.read({
page_size: 1,
role_level: 'notification_admin_role',
}),
]);
setUserData(me);
setIsOrgAdmin(Boolean(adminOrgCount));
setIsNotifAdmin(Boolean(notifAdminCount));
setIsUserDataReady(true);
} catch (err) {
setFetchUserError(err);
}
};

const user = {
isSuperUser: Boolean(userData?.is_superuser),
isSystemAuditor: Boolean(userData?.is_system_auditor),
isOrgAdmin,
isNotifAdmin,
};

const handleSetUserIsDataReady = () => setIsUserDataReady(false);

return (
<I18nProvider language={language} catalogs={catalogs}>
<I18n>
Expand All @@ -49,15 +98,23 @@ function App() {
<Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />
</Route>
<Route path="/login">
<Login isAuthenticated={isAuthenticated} />
<Login
isAuthenticated={isAuthenticated}
fetchUserData={fetchUserData}
userError={fetchUserError}
isUserDataReady={isUserDataReady}
/>
</Route>
<Route exact path="/">
<Redirect to="/home" />
</Route>
<ProtectedRoute>
<AppContainer navRouteConfig={getRouteConfig(i18n)}>
<AppContainer
handleSetIsReady={handleSetUserIsDataReady}
navRouteConfig={getRouteConfig(i18n, user)}
>
<Switch>
{getRouteConfig(i18n)
{getRouteConfig(i18n, user)
.flatMap(({ routes }) => routes)
.map(({ path, screen: Screen }) => (
<ProtectedRoute key={path} path={path}>
Expand Down
11 changes: 9 additions & 2 deletions awx/ui_next/src/components/AppContainer/AppContainer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,12 @@ function useStorage(key) {
return [storageVal, setValue];
}

function AppContainer({ i18n, navRouteConfig = [], children }) {
function AppContainer({
i18n,
navRouteConfig = [],
handleSetIsReady,
children,
}) {
const history = useHistory();
const { pathname } = useLocation();
const [config, setConfig] = useState({});
Expand All @@ -105,7 +110,8 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
const handleLogout = useCallback(async () => {
await RootAPI.logout();
setSessionTimeout(null);
}, [setSessionTimeout]);
handleSetIsReady();
}, [setSessionTimeout, handleSetIsReady]);

const handleSessionContinue = () => {
MeAPI.read();
Expand Down Expand Up @@ -203,6 +209,7 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
<Page isManagedSidebar header={header} sidebar={sidebar}>
{isReady && <ConfigProvider value={config}>{children}</ConfigProvider>}
</Page>

<About
ansible_version={config?.ansible_version}
version={config?.version}
Expand Down
107 changes: 105 additions & 2 deletions awx/ui_next/src/routeConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,25 @@ import WorkflowApprovals from './screens/WorkflowApproval';
// need the i18n. When lingui3 arrives, we will be able to import i18n
// directly and we can replace this function with a simple export.

function getRouteConfig(i18n) {
return [
export function verifyUserRole(user) {
if (!(user.isSuperUser || user.isSystemAuditor)) {
if (user.isOrgAdmin) {
return `isVisibleOrgAdmin`;
}

if (!user.isOrgAdmin && user.isNotifAdmin) {
return `isVisibleNotifAdmin`;
}

if (!user.isOrgAdmin && !user.isNotifAdmin) {
return `isVisibleNormalUser`;
}
}
return `isSuperUser`;
}

function getRouteConfig(i18n, user) {
const sidebar = [
{
groupTitle: i18n._(t`Views`),
groupId: 'views_group',
Expand All @@ -34,16 +51,25 @@ function getRouteConfig(i18n) {
title: i18n._(t`Dashboard`),
path: '/home',
screen: Dashboard,
isVisibleNormalUser: true,
isVisibleOrgAdmin: true,
isVisibleNotifAdmin: true,
},
{
title: i18n._(t`Jobs`),
path: '/jobs',
screen: Jobs,
isVisibleNormalUser: true,
isVisibleOrgAdmin: true,
isVisibleNotifAdmin: true,
},
{
title: i18n._(t`Schedules`),
path: '/schedules',
screen: Schedules,
isVisibleNormalUser: true,
isVisibleOrgAdmin: true,
isVisibleNotifAdmin: true,
},
{
title: i18n._(t`Activity Stream`),
Expand All @@ -54,6 +80,9 @@ function getRouteConfig(i18n) {
title: i18n._(t`Workflow Approvals`),
path: '/workflow_approvals',
screen: WorkflowApprovals,
isVisibleNormalUser: true,
isVisibleOrgAdmin: true,
isVisibleNotifAdmin: true,
},
],
},
Expand All @@ -65,26 +94,41 @@ function getRouteConfig(i18n) {
title: i18n._(t`Templates`),
path: '/templates',
screen: Templates,
isVisibleNormalUser: true,
isVisibleOrgAdmin: true,
isVisibleNotifAdmin: true,
},
{
title: i18n._(t`Credentials`),
path: '/credentials',
screen: Credentials,
isVisibleNormalUser: true,
isVisibleOrgAdmin: true,
isVisibleNotifAdmin: true,
},
{
title: i18n._(t`Projects`),
path: '/projects',
screen: Projects,
isVisibleNormalUser: true,
isVisibleOrgAdmin: true,
isVisibleNotifAdmin: true,
},
{
title: i18n._(t`Inventories`),
path: '/inventories',
screen: Inventory,
isVisibleNormalUser: true,
isVisibleOrgAdmin: true,
isVisibleNotifAdmin: true,
},
{
title: i18n._(t`Hosts`),
path: '/hosts',
screen: Hosts,
isVisibleNormalUser: true,
isVisibleOrgAdmin: true,
isVisibleNotifAdmin: true,
},
],
},
Expand All @@ -96,16 +140,25 @@ function getRouteConfig(i18n) {
title: i18n._(t`Organizations`),
path: '/organizations',
screen: Organizations,
isVisibleNormalUser: true,
isVisibleOrgAdmin: true,
isVisibleNotifAdmin: true,
},
{
title: i18n._(t`Users`),
path: '/users',
screen: Users,
isVisibleNormalUser: true,
isVisibleOrgAdmin: true,
isVisibleNotifAdmin: true,
},
{
title: i18n._(t`Teams`),
path: '/teams',
screen: Teams,
isVisibleNormalUser: true,
isVisibleOrgAdmin: true,
isVisibleNotifAdmin: true,
},
],
},
Expand All @@ -117,26 +170,41 @@ function getRouteConfig(i18n) {
title: i18n._(t`Credential Types`),
path: '/credential_types',
screen: CredentialTypes,
isVisibleNormalUser: false,
isVisibleOrgAdmin: false,
isVisibleNotifAdmin: false,
},
{
title: i18n._(t`Notifications`),
path: '/notification_templates',
screen: NotificationTemplates,
isVisibleNormalUser: false,
isVisibleOrgAdmin: true,
isVisibleNotifAdmin: true,
},
{
title: i18n._(t`Management Jobs`),
path: '/management_jobs',
screen: ManagementJobs,
isVisibleNormalUser: false,
isVisibleOrgAdmin: false,
isVisibleNotifAdmin: false,
},
{
title: i18n._(t`Instance Groups`),
path: '/instance_groups',
screen: InstanceGroups,
isVisibleNormalUser: false,
isVisibleOrgAdmin: true,
isVisibleNotifAdmin: false,
},
{
title: i18n._(t`Applications`),
path: '/applications',
screen: Applications,
isVisibleNormalUser: false,
isVisibleOrgAdmin: true,
isVisibleNotifAdmin: false,
},
],
},
Expand All @@ -148,10 +216,45 @@ function getRouteConfig(i18n) {
title: i18n._(t`Settings`),
path: '/settings',
screen: Settings,
isVisibleNormalUser: false,
isVisibleOrgAdmin: false,
isVisibleNotifAdmin: false,
},
],
},
];

const filterRoutes = (groupTitle, groupId, routes, filter) => {
const filteredRoutes = routes.filter(item => item[filter] === true);

if (filteredRoutes.length > 0) {
return { groupTitle, groupId, routes: filteredRoutes };
}
return undefined;
};

let userRole = '';

userRole = verifyUserRole(user);

if (userRole !== `isSuperUser`) {
const modifiedSideBar = [];

sidebar.forEach(({ groupTitle, groupId, routes }) => {
const filteredSideBar = filterRoutes(
groupTitle,
groupId,
routes,
userRole
);
if (filteredSideBar !== undefined) {
modifiedSideBar.push(filteredSideBar);
}
});

return modifiedSideBar;
}
return sidebar;
}

export default getRouteConfig;
Loading