Skip to content

Commit

Permalink
feat: add check relation middleware (#1841)
Browse files Browse the repository at this point in the history
* feat: add check relation middleware
  • Loading branch information
huwshimi authored Jan 30, 2025
1 parent b9db5b4 commit bc62164
Show file tree
Hide file tree
Showing 21 changed files with 1,109 additions and 383 deletions.
145 changes: 143 additions & 2 deletions src/components/PrimaryNav/PrimaryNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import * as versionsAPI from "@canonical/jujulib/dist/api/versions";
import { screen, waitFor, within } from "@testing-library/react";
import { vi } from "vitest";

import { configFactory, generalStateFactory } from "testing/factories/general";
import { JIMMRelation, JIMMTarget } from "juju/jimm/JIMMV4";
import {
authUserInfoFactory,
configFactory,
generalStateFactory,
} from "testing/factories/general";
import {
controllerFeaturesFactory,
controllerFeaturesStateFactory,
Expand All @@ -13,6 +18,8 @@ import {
jujuStateFactory,
modelDataApplicationFactory,
modelDataFactory,
rebacRelationFactory,
relationshipTupleFactory,
} from "testing/factories/juju/juju";
import { rootStateFactory } from "testing/factories/root";
import { renderComponent } from "testing/utils";
Expand Down Expand Up @@ -176,19 +183,99 @@ describe("Primary Nav", () => {
).not.toBeInTheDocument();
});

it("should show LogsLink navigation button if the controller supports it", () => {
it("should not show LogsLink navigation button if the controller doesn't support it", () => {
const state = rootStateFactory.build({
general: generalStateFactory.build({
config: configFactory.build({
controllerAPIEndpoint: "wss://controller.example.com",
isJuju: false,
}),
controllerConnections: {
"wss://controller.example.com": {
user: {
"display-name": "eggman",
identity: "user-eggman@external",
"controller-access": "",
"model-access": "",
},
},
},
controllerFeatures: controllerFeaturesStateFactory.build({
"wss://controller.example.com": controllerFeaturesFactory.build({
auditLogs: false,
}),
}),
}),
juju: jujuStateFactory.build({
rebacRelations: [
rebacRelationFactory.build({
tuple: relationshipTupleFactory.build({
object: "user-eggman@external",
relation: JIMMRelation.AUDIT_LOG_VIEWER,
target_object: JIMMTarget.JIMM_CONTROLLER,
}),
allowed: true,
}),
rebacRelationFactory.build({
tuple: relationshipTupleFactory.build({
object: "user-eggman@external",
relation: JIMMRelation.ADMINISTRATOR,
target_object: JIMMTarget.JIMM_CONTROLLER,
}),
allowed: true,
}),
],
}),
});
renderComponent(<PrimaryNav />, { state });
expect(
screen.queryByRole("link", { name: Label.LOGS }),
).not.toBeInTheDocument();
});

it("should show LogsLink navigation button if the user has permission", () => {
const state = rootStateFactory.build({
general: generalStateFactory.build({
config: configFactory.build({
controllerAPIEndpoint: "wss://controller.example.com",
isJuju: false,
}),
controllerConnections: {
"wss://controller.example.com": {
user: {
"display-name": "eggman",
identity: "user-eggman@external",
"controller-access": "",
"model-access": "",
},
},
},
controllerFeatures: controllerFeaturesStateFactory.build({
"wss://controller.example.com": controllerFeaturesFactory.build({
auditLogs: true,
}),
}),
}),
juju: jujuStateFactory.build({
rebacRelations: [
rebacRelationFactory.build({
tuple: relationshipTupleFactory.build({
object: "user-eggman@external",
relation: JIMMRelation.AUDIT_LOG_VIEWER,
target_object: JIMMTarget.JIMM_CONTROLLER,
}),
allowed: true,
}),
rebacRelationFactory.build({
tuple: relationshipTupleFactory.build({
object: "user-eggman@external",
relation: JIMMRelation.ADMINISTRATOR,
target_object: JIMMTarget.JIMM_CONTROLLER,
}),
allowed: true,
}),
],
}),
});
renderComponent(<PrimaryNav />, { state });
expect(screen.getByRole("link", { name: Label.LOGS })).toBeInTheDocument();
Expand Down Expand Up @@ -249,19 +336,73 @@ it("should not show Permissions navigation button under Juju", () => {
).not.toBeInTheDocument();
});

it("should not show Permissions navigation button if the controller doesn't support it", () => {
const state = rootStateFactory.build({
general: generalStateFactory.build({
config: configFactory.build({
controllerAPIEndpoint: "wss://controller.example.com",
isJuju: false,
}),
controllerConnections: {
"wss://controller.example.com": {
user: authUserInfoFactory.build(),
},
},
controllerFeatures: controllerFeaturesStateFactory.build({
"wss://controller.example.com": controllerFeaturesFactory.build({
rebac: false,
}),
}),
}),
juju: jujuStateFactory.build({
rebacRelations: [
rebacRelationFactory.build({
tuple: relationshipTupleFactory.build({
object: "user-eggman@external",
relation: JIMMRelation.ADMINISTRATOR,
target_object: JIMMTarget.JIMM_CONTROLLER,
}),
allowed: true,
}),
],
}),
});
renderComponent(<PrimaryNav />, { state });
expect(
screen.queryByRole("link", { name: Label.PERMISSIONS }),
).not.toBeInTheDocument();
});

it("should show Permissions navigation button if the controller supports it", () => {
const state = rootStateFactory.build({
general: generalStateFactory.build({
config: configFactory.build({
controllerAPIEndpoint: "wss://controller.example.com",
isJuju: false,
}),
controllerConnections: {
"wss://controller.example.com": {
user: authUserInfoFactory.build(),
},
},
controllerFeatures: controllerFeaturesStateFactory.build({
"wss://controller.example.com": controllerFeaturesFactory.build({
rebac: true,
}),
}),
}),
juju: jujuStateFactory.build({
rebacRelations: [
rebacRelationFactory.build({
tuple: relationshipTupleFactory.build({
object: "user-eggman@external",
relation: JIMMRelation.ADMINISTRATOR,
target_object: JIMMTarget.JIMM_CONTROLLER,
}),
allowed: true,
}),
],
}),
});
renderComponent(<PrimaryNav />, { state });
expect(
Expand Down
63 changes: 60 additions & 3 deletions src/components/PrimaryNav/PrimaryNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,35 @@ import type { NavItem } from "@canonical/react-components/dist/components/SideNa
import { urls as generateReBACURLS } from "@canonical/rebac-admin";
import type { HTMLProps, ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSelector } from "react-redux";
import { useSelector, useDispatch } from "react-redux";
import type { NavLinkProps } from "react-router";
import { NavLink } from "react-router";

import UserMenu from "components/UserMenu/UserMenu";
import { DARK_THEME } from "consts";
import { JIMMRelation, JIMMTarget } from "juju/jimm/JIMMV4";
import {
getAppVersion,
isAuditLogsEnabled,
isCrossModelQueriesEnabled,
getVisitURLs,
isReBACEnabled,
getActiveUserTag,
getWSControllerURL,
} from "store/general/selectors";
import { actions as jujuActions } from "store/juju";
import {
getControllerData,
getGroupedModelStatusCounts,
hasReBACPermission,
isJIMMAdmin,
} from "store/juju/selectors";
import type { Controllers } from "store/juju/types";
import { useAppSelector } from "store/store";
import urls, { externalURLs } from "urls";

import "./_primary-nav.scss";

import { Label } from "./types";

const rebacURLS = generateReBACURLS(urls.permissions);
Expand Down Expand Up @@ -82,14 +89,32 @@ const useControllersLink = () => {
};

const PrimaryNav = () => {
const dispatch = useDispatch();
const appVersion = useSelector(getAppVersion);
const [updateAvailable, setUpdateAvailable] = useState(false);
const versionRequested = useRef(false);
const crossModelQueriesEnabled = useAppSelector(isCrossModelQueriesEnabled);
const auditLogsEnabled = useAppSelector(isAuditLogsEnabled);
const rebacEnabled = useAppSelector(isReBACEnabled);
const { blocked: blockedModels } = useSelector(getGroupedModelStatusCounts);
const wsControllerURL = useAppSelector(getWSControllerURL);
const isJIMMControllerAdmin = useAppSelector(isJIMMAdmin);
const activeUser = useAppSelector((state) =>
getActiveUserTag(state, wsControllerURL),
);
const auditLogsPermitted = useAppSelector(
(state) =>
activeUser &&
hasReBACPermission(state, {
object: activeUser,
relation: JIMMRelation.AUDIT_LOG_VIEWER,
target_object: JIMMTarget.JIMM_CONTROLLER,
}),
);
const controllersLink = useControllersLink();
const rebacAllowed = rebacEnabled && isJIMMControllerAdmin;
const auditLogsAllowed =
auditLogsEnabled && auditLogsPermitted && isJIMMControllerAdmin;

useEffect(() => {
if (appVersion && !versionRequested.current) {
Expand All @@ -100,6 +125,38 @@ const PrimaryNav = () => {
}
}, [appVersion]);

useEffect(() => {
// Only check the relation if the controller supports audit logs or ReBAC.
if (wsControllerURL && activeUser && (rebacEnabled || auditLogsEnabled)) {
dispatch(
jujuActions.checkRelation({
tuple: {
object: activeUser,
relation: JIMMRelation.ADMINISTRATOR,
target_object: JIMMTarget.JIMM_CONTROLLER,
},
wsControllerURL,
}),
);
}
}, [activeUser, auditLogsEnabled, dispatch, rebacEnabled, wsControllerURL]);

useEffect(() => {
// Only check the relation if the controller supports audit logs.
if (wsControllerURL && activeUser && auditLogsEnabled) {
dispatch(
jujuActions.checkRelation({
tuple: {
object: activeUser,
relation: JIMMRelation.AUDIT_LOG_VIEWER,
target_object: JIMMTarget.JIMM_CONTROLLER,
},
wsControllerURL,
}),
);
}
}, [activeUser, auditLogsEnabled, dispatch, wsControllerURL]);

const navigation: NavItem<NavLinkProps>[] = [
{
component: NavLink,
Expand All @@ -112,7 +169,7 @@ const PrimaryNav = () => {
},
controllersLink,
];
if (auditLogsEnabled) {
if (auditLogsAllowed) {
navigation.push({
component: NavLink,
to: urls.logs,
Expand All @@ -128,7 +185,7 @@ const PrimaryNav = () => {
label: <>{Label.ADVANCED_SEARCH}</>,
});
}
if (rebacEnabled) {
if (rebacAllowed) {
navigation.push({
component: NavLink,
icon: "user",
Expand Down
Loading

0 comments on commit bc62164

Please sign in to comment.