Skip to content

Commit

Permalink
feat: update useCanConfigureModel hook to check permissions via rebac
Browse files Browse the repository at this point in the history
  • Loading branch information
huwshimi committed Feb 11, 2025
1 parent 5401e10 commit 676d901
Show file tree
Hide file tree
Showing 12 changed files with 148 additions and 18 deletions.
105 changes: 93 additions & 12 deletions src/hooks/useCanConfigureModel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,35 @@
import { renderHook } from "@testing-library/react";
import { renderHook, waitFor } from "@testing-library/react";
import type { PropsWithChildren } from "react";
import { Provider } from "react-redux";
import { BrowserRouter, Route, Routes } from "react-router";
import configureStore from "redux-mock-store";

import { JIMMRelation } from "juju/jimm/JIMMV4";
import { actions as jujuActions } from "store/juju";
import type { RootState } from "store/store";
import { rootStateFactory } from "testing/factories";
import {
generalStateFactory,
configFactory,
credentialFactory,
authUserInfoFactory,
controllerFeaturesFactory,
controllerFeaturesStateFactory,
configFactory,
} from "testing/factories/general";
import { modelUserInfoFactory } from "testing/factories/juju/ModelManagerV9";
import {
jujuStateFactory,
modelDataFactory,
modelDataInfoFactory,
modelListInfoFactory,
rebacRelationFactory,
} from "testing/factories/juju/juju";
import { modelWatcherModelDataFactory } from "testing/factories/juju/model-watcher";
import { renderWrappedHook } from "testing/utils";

import useCanConfigureModel from "./useCanConfigureModel";

const mockStore = configureStore();
const mockStore = configureStore<RootState, unknown>([]);

const generateContainer =
(state: RootState, path: string, url: string) =>
Expand Down Expand Up @@ -53,12 +60,7 @@ describe("useModelStatus", () => {
}),
controllerConnections: {
"wss://jimm.jujucharms.com/api": {
user: {
"display-name": "eggman",
identity: "user-eggman@external",
"controller-access": "",
"model-access": "",
},
user: authUserInfoFactory.build(),
},
},
credentials: {
Expand All @@ -83,7 +85,10 @@ describe("useModelStatus", () => {
});
});

it("should return true when user has admin access", () => {
it("should return true when juju user has admin access", () => {
if (state.general.config) {
state.general.config.isJuju = true;
}
state.juju.modelData.abc123.info = modelDataInfoFactory.build({
uuid: "abc123",
name: "test1",
Expand All @@ -101,7 +106,10 @@ describe("useModelStatus", () => {
expect(result.current).toBe(true);
});

it("should return true when user has write access", () => {
it("should return true when juju user has write access", () => {
if (state.general.config) {
state.general.config.isJuju = true;
}
state.juju.modelData.abc123.info = modelDataInfoFactory.build({
uuid: "abc123",
name: "test1",
Expand All @@ -119,7 +127,10 @@ describe("useModelStatus", () => {
expect(result.current).toBe(true);
});

it("should return false when user has read access", () => {
it("should return false when juju user has read access", () => {
if (state.general.config) {
state.general.config.isJuju = true;
}
state.juju.modelData.abc123.info = modelDataInfoFactory.build({
uuid: "abc123",
name: "test1",
Expand All @@ -136,4 +147,74 @@ describe("useModelStatus", () => {
});
expect(result.current).toBe(false);
});

it("should request permissions for the JAAS user", async () => {
if (state.general.config) {
state.general.config.isJuju = false;
}
state.general.controllerFeatures = controllerFeaturesStateFactory.build({
"wss://jimm.jujucharms.com/api": controllerFeaturesFactory.build({
rebac: true,
}),
});
const store = mockStore(state);
renderWrappedHook(() => useCanConfigureModel(), {
store,
path,
url,
});
const action = jujuActions.checkRelation({
tuple: {
object: "user-eggman@external",
relation: JIMMRelation.WRITER,
target_object: "model-abc123",
},
wsControllerURL: "wss://jimm.jujucharms.com/api",
});
await waitFor(() => {
expect(
store.getActions().find((dispatch) => dispatch.type === action.type),
).toMatchObject(action);
});
});

it("should return true when a JAAS user has write access", () => {
if (state.general.config) {
state.general.config.isJuju = false;
}
state.juju.rebacRelations = [
rebacRelationFactory.build({
tuple: {
object: "user-eggman@external",
relation: JIMMRelation.WRITER,
target_object: "model-abc123",
},
allowed: true,
}),
];
const { result } = renderHook(() => useCanConfigureModel(), {
wrapper: generateContainer(state, path, url),
});
expect(result.current).toBe(true);
});

it("should return false when a JAAS user doesn't have write access", () => {
if (state.general.config) {
state.general.config.isJuju = false;
}
state.juju.rebacRelations = [
rebacRelationFactory.build({
tuple: {
object: "user-eggman@external",
relation: JIMMRelation.WRITER,
target_object: "model-abc123",
},
allowed: false,
}),
];
const { result } = renderHook(() => useCanConfigureModel(), {
wrapper: generateContainer(state, path, url),
});
expect(result.current).toBe(false);
});
});
39 changes: 35 additions & 4 deletions src/hooks/useCanConfigureModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,49 @@ import { useParams } from "react-router";

import type { EntityDetailsRoute } from "components/Routes";
import useModelStatus from "hooks/useModelStatus";
import { useCheckPermissions } from "juju/api-hooks";
import { JIMMRelation } from "juju/jimm/JIMMV4";
import { getIsJuju, getControllerUserTag } from "store/general/selectors";
import { getActiveUser, getModelUUIDFromList } from "store/juju/selectors";
import { canAdministerModel } from "store/juju/utils/models";
import { useAppSelector } from "store/store";

const useCanConfigureModel = () => {
const { userName, modelName } = useParams<EntityDetailsRoute>();
const modelUUID = useAppSelector(getModelUUIDFromList(modelName, userName));
const useCheckJujuPermissions = (modelUUID: string, enabled?: boolean) => {
const activeUser = useAppSelector((state) => getActiveUser(state, modelUUID));
const modelStatusData = useModelStatus();
return (
!!activeUser && canAdministerModel(activeUser, modelStatusData?.info?.users)
enabled &&
!!activeUser &&
canAdministerModel(activeUser, modelStatusData?.info?.users)
);
};

const useCheckJIMMPermissions = (
modelUUID: string,
enabled?: boolean,
cleanup?: boolean,
) => {
const controllerUser = useAppSelector((state) => getControllerUserTag(state));
const { permitted } = useCheckPermissions(
enabled && controllerUser && modelUUID
? {
object: controllerUser,
relation: JIMMRelation.WRITER,
target_object: `model-${modelUUID}`,
}
: null,
cleanup,
);
return permitted;
};

const useCanConfigureModel = (cleanup?: boolean) => {
const isJuju = useAppSelector(getIsJuju);
const { userName, modelName } = useParams<EntityDetailsRoute>();
const modelUUID = useAppSelector(getModelUUIDFromList(modelName, userName));
const jujuPermissions = useCheckJujuPermissions(modelUUID, isJuju);
const jimmPermissions = useCheckJIMMPermissions(modelUUID, !isJuju, cleanup);
return isJuju ? jujuPermissions : jimmPermissions;
};

export default useCanConfigureModel;
1 change: 1 addition & 0 deletions src/hooks/useCanManageSecrets.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ describe("useCanManageSecrets", () => {
state = rootStateFactory.build({
general: generalStateFactory.build({
config: configFactory.build({
isJuju: true,
controllerAPIEndpoint: "wss://jimm.jujucharms.com/api",
}),
controllerConnections: {
Expand Down
1 change: 1 addition & 0 deletions src/pages/EntityDetails/App/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ describe("Entity Details App", () => {
state = rootStateFactory.build({
general: generalStateFactory.build({
config: configFactory.build({
isJuju: true,
controllerAPIEndpoint: "wss://jimm.jujucharms.com/api",
}),
credentials: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event";
import { actions as jujuActions } from "store/juju";
import type { RootState } from "store/store";
import { jujuStateFactory, rootStateFactory } from "testing/factories";
import { generalStateFactory } from "testing/factories/general";
import { generalStateFactory, configFactory } from "testing/factories/general";
import { charmApplicationFactory } from "testing/factories/juju/Charms";
import { modelUserInfoFactory } from "testing/factories/juju/ModelManagerV9";
import {
Expand All @@ -26,6 +26,9 @@ describe("LocalAppsTable", () => {
beforeEach(() => {
state = rootStateFactory.build({
general: generalStateFactory.build({
config: configFactory.build({
isJuju: true,
}),
controllerConnections: {
"wss://jimm.jujucharms.com/api": {
user: {
Expand Down
4 changes: 4 additions & 0 deletions src/pages/EntityDetails/Model/Model.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ describe("Model", () => {
state = rootStateFactory.build({
general: generalStateFactory.build({
config: configFactory.build({
isJuju: true,
controllerAPIEndpoint: "wss://jimm.jujucharms.com/api",
}),
controllerConnections: {
Expand Down Expand Up @@ -341,6 +342,9 @@ describe("Model", () => {
});

it("can display the audit logs table", async () => {
if (state.general.config) {
state.general.config.isJuju = false;
}
state.juju.modelWatcherData = {
abc123: modelWatcherModelDataFactory.build({
applications: {
Expand Down
6 changes: 5 additions & 1 deletion src/pages/EntityDetails/Model/Model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ const Model = () => {
const canListSecrets = useAppSelector((state) =>
getCanListSecrets(state, modelUUID),
);
const canConfigureModel = useCanConfigureModel();
// Cleanup is set for this hook, but not for the instances of
// useCanConfigureModel in other model components as this component wraps all
// model routes so the model permissions are removed once the user navigates
// away from the model.
const canConfigureModel = useCanConfigureModel(true);

const machinesTableRows = useMemo(() => {
return modelName && userName
Expand Down
1 change: 1 addition & 0 deletions src/pages/EntityDetails/Model/Secrets/Secrets.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ describe("Secrets", () => {
"wss://example.com/api": credentialFactory.build(),
},
config: configFactory.build({
isJuju: true,
controllerAPIEndpoint: "wss://example.com/api",
}),
controllerConnections: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ describe("SecretsTable", () => {
state = rootStateFactory.build({
general: generalStateFactory.build({
config: configFactory.build({
isJuju: true,
controllerAPIEndpoint: "wss://example.com/api",
}),
controllerConnections: {
Expand Down
1 change: 1 addition & 0 deletions src/panels/ConfigPanel/ConfigPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ describe("ConfigPanel", () => {
state = rootStateFactory.build({
general: generalStateFactory.build({
config: configFactory.build({
isJuju: true,
controllerAPIEndpoint: "wss://example.com/api",
}),
controllerConnections: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ describe("SecretsPicker", () => {
"wss://example.com/api": credentialFactory.build(),
},
config: configFactory.build({
isJuju: true,
controllerAPIEndpoint: "wss://example.com/api",
}),
controllerConnections: {
Expand Down
1 change: 1 addition & 0 deletions src/panels/Panels.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ describe("Panels", () => {
state = rootStateFactory.build({
general: generalStateFactory.build({
config: configFactory.build({
isJuju: true,
controllerAPIEndpoint: "wss://jimm.jujucharms.com/api",
}),
controllerConnections: {
Expand Down

0 comments on commit 676d901

Please sign in to comment.