Skip to content

Commit

Permalink
Merge pull request #1851 from huwshimi/rebac-can-configure-model
Browse files Browse the repository at this point in the history
feat: update useCanConfigureModel hook to check permissions via rebac
  • Loading branch information
Ninfa-Jeon authored Feb 25, 2025
2 parents cff040a + d304ffe commit 602b277
Show file tree
Hide file tree
Showing 13 changed files with 274 additions and 24 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
89 changes: 89 additions & 0 deletions src/juju/api-hooks/permissions.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { renderHook, waitFor } from "@testing-library/react";
import configureStore from "redux-mock-store";

import type { RelationshipTuple } from "juju/jimm/JIMMV4";
import { JIMMRelation, JIMMTarget } from "juju/jimm/JIMMV4";
import { actions as jujuActions } from "store/juju";
import type { RootState } from "store/store";
Expand Down Expand Up @@ -109,6 +110,39 @@ describe("useCheckPermissions", () => {
});
});

it("does not try and fetch a relation if the tuple object has a new reference", async () => {
const tuple = {
object: "user-eggman@external",
relation: JIMMRelation.MEMBER,
target_object: "group-admins",
};
const store = mockStore(state);
const { rerender } = renderHook(() => useCheckPermissions(tuple), {
wrapper: (props) => (
<ComponentProviders {...props} path="/" store={store} />
),
});
await waitFor(() => {
expect(
store
.getActions()
.filter(
(dispatch) => dispatch.type === jujuActions.checkRelation.type,
),
).toHaveLength(1);
});
rerender({ ...tuple });
await waitFor(() => {
expect(
store
.getActions()
.filter(
(dispatch) => dispatch.type === jujuActions.checkRelation.type,
),
).toHaveLength(1);
});
});

it("does not fetch a relation that is already loading", async () => {
const tuple = {
object: "user-eggman@external",
Expand Down Expand Up @@ -163,6 +197,61 @@ describe("useCheckPermissions", () => {
});
});

it("cleans up a previous relation if the tuple changes", async () => {
const tuple = {
object: "user-eggman@external",
relation: JIMMRelation.MEMBER,
target_object: "group-admins",
};
state.juju.rebacRelations = [
rebacRelationFactory.build({
tuple: tuple,
loaded: true,
}),
];
const store = mockStore(state);
const { rerender } = renderHook<
{
permitted: boolean;
loading: boolean;
loaded: boolean;
},
{
tupleObject: RelationshipTuple;
cleanup: boolean;
}
>(
({ tupleObject, cleanup } = { tupleObject: tuple, cleanup: true }) =>
useCheckPermissions(tupleObject, cleanup),
{
wrapper: (props) => (
<ComponentProviders {...props} path="/" store={store} />
),
},
);
await waitFor(() => {
expect(
store
.getActions()
.find((dispatch) => dispatch.type === jujuActions.checkRelation.type),
).toBeUndefined();
});
rerender({ tupleObject: { ...tuple, object: "newobject" }, cleanup: true });
const action = jujuActions.removeCheckRelation({
tuple,
});
await waitFor(() => {
expect(
store
.getActions()
.find(
(dispatch) =>
dispatch.type === jujuActions.removeCheckRelation.type,
),
).toMatchObject(action);
});
});

it("can clean up a relation", async () => {
const tuple = {
object: "user-eggman@external",
Expand Down
Loading

0 comments on commit 602b277

Please sign in to comment.