diff --git a/src/components/PrimaryNav/PrimaryNav.test.tsx b/src/components/PrimaryNav/PrimaryNav.test.tsx
index b243c1533..3343bb935 100644
--- a/src/components/PrimaryNav/PrimaryNav.test.tsx
+++ b/src/components/PrimaryNav/PrimaryNav.test.tsx
@@ -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,
@@ -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";
@@ -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(, { 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(, { state });
expect(screen.getByRole("link", { name: Label.LOGS })).toBeInTheDocument();
@@ -249,6 +336,43 @@ 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(, { 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({
@@ -256,12 +380,29 @@ it("should show Permissions navigation button if the controller supports it", ()
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(, { state });
expect(
diff --git a/src/components/PrimaryNav/PrimaryNav.tsx b/src/components/PrimaryNav/PrimaryNav.tsx
index 70eb69c64..2fcceb7ca 100644
--- a/src/components/PrimaryNav/PrimaryNav.tsx
+++ b/src/components/PrimaryNav/PrimaryNav.tsx
@@ -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);
@@ -82,6 +89,7 @@ const useControllersLink = () => {
};
const PrimaryNav = () => {
+ const dispatch = useDispatch();
const appVersion = useSelector(getAppVersion);
const [updateAvailable, setUpdateAvailable] = useState(false);
const versionRequested = useRef(false);
@@ -89,7 +97,24 @@ const PrimaryNav = () => {
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) {
@@ -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[] = [
{
component: NavLink,
@@ -112,7 +169,7 @@ const PrimaryNav = () => {
},
controllersLink,
];
- if (auditLogsEnabled) {
+ if (auditLogsAllowed) {
navigation.push({
component: NavLink,
to: urls.logs,
@@ -128,7 +185,7 @@ const PrimaryNav = () => {
label: <>{Label.ADVANCED_SEARCH}>,
});
}
- if (rebacEnabled) {
+ if (rebacAllowed) {
navigation.push({
component: NavLink,
icon: "user",
diff --git a/src/juju/api.test.ts b/src/juju/api.test.ts
index a1ca30582..f7e85beaf 100644
--- a/src/juju/api.test.ts
+++ b/src/juju/api.test.ts
@@ -51,8 +51,6 @@ import {
setModelSharingPermissions,
startModelWatcher,
stopModelWatcher,
- findAuditEvents,
- crossModelQuery,
connectToModel,
Label,
} from "./api";
@@ -1823,121 +1821,4 @@ describe("Juju API", () => {
);
});
});
-
- describe("findAuditEvents", () => {
- it("fetches audit events", async () => {
- const events = { events: [] };
- const conn = {
- facades: {
- jimM: {
- findAuditEvents: vi
- .fn()
- .mockImplementation(() => Promise.resolve(events)),
- },
- },
- } as unknown as Connection;
- const response = await findAuditEvents(conn);
- expect(conn.facades.jimM.findAuditEvents).toHaveBeenCalled();
- expect(response).toMatchObject(events);
- });
-
- it("fetches audit events with supplied params", async () => {
- const events = { events: [] };
- const conn = {
- facades: {
- jimM: {
- findAuditEvents: vi
- .fn()
- .mockImplementation(() => Promise.resolve(events)),
- },
- },
- } as unknown as Connection;
- const response = await findAuditEvents(conn, {
- "user-tag": "user-eggman@external",
- });
- expect(conn.facades.jimM.findAuditEvents).toHaveBeenCalledWith({
- "user-tag": "user-eggman@external",
- });
- expect(response).toMatchObject(events);
- });
-
- it("handles errors", async () => {
- const error = new Error("Request failed");
- const conn = {
- facades: {
- jimM: {
- findAuditEvents: vi.fn().mockImplementation(() => {
- throw error;
- }),
- },
- },
- } as unknown as Connection;
- await expect(findAuditEvents(conn)).rejects.toBe(error);
- });
-
- it("handles no JIMM connection", async () => {
- const conn = {
- facades: {},
- } as unknown as Connection;
- await expect(findAuditEvents(conn)).rejects.toEqual(
- new Error("Not connected to JIMM."),
- );
- });
- });
-
- describe("crossModelQuery", () => {
- it("fetches cross model query result with supplied params", async () => {
- const result = { results: {}, errors: {} };
- const conn = {
- facades: {
- jimM: {
- crossModelQuery: vi.fn(() => Promise.resolve(result)),
- },
- },
- } as unknown as Connection;
- const response = await crossModelQuery(conn, ".");
- expect(conn.facades.jimM.crossModelQuery).toHaveBeenCalledWith(".");
- expect(response).toMatchObject(result);
- });
-
- it("handles errors", async () => {
- const error = new Error("Request failed");
- const conn = {
- facades: {
- jimM: {
- crossModelQuery: vi.fn().mockImplementation(() => {
- throw error;
- }),
- },
- },
- } as unknown as Connection;
- await expect(crossModelQuery(conn, ".")).rejects.toBe(error);
- });
-
- it("handles no JIMM connection", async () => {
- const conn = {
- facades: {},
- } as unknown as Connection;
- await expect(crossModelQuery(conn, ".")).rejects.toMatchObject(
- new Error("Not connected to JIMM."),
- );
- });
-
- it("should handle exceptions", async () => {
- const conn = {
- facades: {
- jimM: {
- crossModelQuery: vi.fn(() =>
- Promise.reject(
- new Error("Error while trying to run cross model query!"),
- ),
- ),
- },
- },
- } as unknown as Connection;
- await expect(crossModelQuery(conn, ".")).rejects.toMatchObject(
- new Error("Error while trying to run cross model query!"),
- );
- });
- });
});
diff --git a/src/juju/api.ts b/src/juju/api.ts
index f75ca9f4a..82f81caed 100644
--- a/src/juju/api.ts
+++ b/src/juju/api.ts
@@ -43,8 +43,6 @@ import { toErrorString } from "utils";
import { getModelByUUID } from "../store/juju/selectors";
-import type { AuditEvents, FindAuditEventsRequest } from "./jimm/JIMMV3";
-import type { CrossModelQueryResponse } from "./jimm/JIMMV4";
import type {
AllWatcherDelta,
ApplicationInfo,
@@ -698,35 +696,3 @@ export async function getCharmsURLFromApplications(
);
return charms.filter((charm) => !!charm).map((charm) => charm?.url);
}
-
-/**
- Fetch audit events via the JIMM facade on the given controller connection.
- */
-export function findAuditEvents(
- conn: ConnectionWithFacades,
- params?: FindAuditEventsRequest,
-) {
- return new Promise((resolve, reject) => {
- if (conn?.facades?.jimM) {
- conn.facades.jimM
- .findAuditEvents(params)
- .then((events) => resolve(events))
- .catch((error) => reject(error));
- } else {
- reject(new Error("Not connected to JIMM."));
- }
- });
-}
-
-export function crossModelQuery(conn: ConnectionWithFacades, query: string) {
- return new Promise((resolve, reject) => {
- if (conn?.facades?.jimM) {
- conn.facades.jimM
- .crossModelQuery(query)
- .then((crossModelQueryResponse) => resolve(crossModelQueryResponse))
- .catch((error) => reject(error));
- } else {
- reject(new Error("Not connected to JIMM."));
- }
- });
-}
diff --git a/src/juju/jimm/JIMMV4.test.ts b/src/juju/jimm/JIMMV4.test.ts
index 7ce22d5a6..b7bb2d421 100644
--- a/src/juju/jimm/JIMMV4.test.ts
+++ b/src/juju/jimm/JIMMV4.test.ts
@@ -2,7 +2,7 @@ import type { ConnectionInfo, Transport } from "@canonical/jujulib";
import { connectionInfoFactory } from "testing/factories/juju/jujulib";
-import JIMMV4 from "./JIMMV4";
+import JIMMV4, { JIMMRelation } from "./JIMMV4";
describe("JIMMV4", () => {
let transport: Transport;
@@ -19,7 +19,7 @@ describe("JIMMV4", () => {
const jimm = new JIMMV4(transport, connectionInfo);
const params = {
object: "user-eggman@external",
- relation: "member",
+ relation: JIMMRelation.MEMBER,
target_object: "group-administrators",
};
void jimm.checkRelation(params);
diff --git a/src/juju/jimm/JIMMV4.ts b/src/juju/jimm/JIMMV4.ts
index d5d9972f0..e882d1bf6 100644
--- a/src/juju/jimm/JIMMV4.ts
+++ b/src/juju/jimm/JIMMV4.ts
@@ -17,24 +17,37 @@ export type CrossModelQueryResponse = {
errors: Record;
};
+// As typed in JIMM
+// https://github.com/canonical/jimm/blob/v3/internal/jimmhttp/rebac_admin/entitlements.go
export enum JIMMRelation {
- AUDIT_LOG_VIEWER = "audit_log_viewer",
ADMINISTRATOR = "administrator",
+ AUDIT_LOG_VIEWER = "audit_log_viewer",
+ CAN_ADDMODEL = "can_addmodel",
+ CONSUMER = "consumer",
+ MEMBER = "member",
+ READER = "reader",
+ WRITER = "writer",
+}
+
+export enum JIMMTarget {
+ JIMM_CONTROLLER = "controller-jimm",
}
// As typed in JIMM:
// https://github.com/canonical/jimm/blob/c1e1642ac701bcbef2fdd8f4e347de9dcf16ac50/api/params/params.go#L296
export type RelationshipTuple = {
object: string;
- relation: JIMMRelation | string;
- target_object: string;
+ relation: JIMMRelation;
+ target_object: JIMMTarget | string;
};
// As typed in JIMM:
// https://github.com/canonical/jimm/blob/c1e1642ac701bcbef2fdd8f4e347de9dcf16ac50/api/params/params.go#L324
-export type CheckRelationResponse = {
- allowed: boolean;
-};
+export type CheckRelationResponse =
+ | {
+ allowed: boolean;
+ }
+ | { error: string };
class JIMMV4 extends JIMMV3 {
static NAME: string;
diff --git a/src/juju/jimm/api.test.ts b/src/juju/jimm/api.test.ts
index 763bdd881..710bfa645 100644
--- a/src/juju/jimm/api.test.ts
+++ b/src/juju/jimm/api.test.ts
@@ -1,6 +1,16 @@
+import type { Connection } from "@canonical/jujulib";
+import { vi } from "vitest";
+
+import { relationshipTupleFactory } from "testing/factories/juju/juju";
import type { WindowConfig } from "types";
-import { endpoints } from "./api";
+import {
+ crossModelQuery,
+ endpoints,
+ findAuditEvents,
+ checkRelation,
+ Label,
+} from "./api";
describe("JIMM API", () => {
afterEach(() => {
@@ -26,4 +36,181 @@ describe("JIMM API", () => {
expect(logout).toEqual("https://example.com/auth/logout");
expect(whoami).toEqual("https://example.com/auth/whoami");
});
+
+ describe("findAuditEvents", () => {
+ it("fetches audit events", async () => {
+ const events = { events: [] };
+ const conn = {
+ facades: {
+ jimM: {
+ findAuditEvents: vi
+ .fn()
+ .mockImplementation(() => Promise.resolve(events)),
+ },
+ },
+ } as unknown as Connection;
+ const response = await findAuditEvents(conn);
+ expect(conn.facades.jimM.findAuditEvents).toHaveBeenCalled();
+ expect(response).toMatchObject(events);
+ });
+
+ it("fetches audit events with supplied params", async () => {
+ const events = { events: [] };
+ const conn = {
+ facades: {
+ jimM: {
+ findAuditEvents: vi
+ .fn()
+ .mockImplementation(() => Promise.resolve(events)),
+ },
+ },
+ } as unknown as Connection;
+ const response = await findAuditEvents(conn, {
+ "user-tag": "user-eggman@external",
+ });
+ expect(conn.facades.jimM.findAuditEvents).toHaveBeenCalledWith({
+ "user-tag": "user-eggman@external",
+ });
+ expect(response).toMatchObject(events);
+ });
+
+ it("handles errors", async () => {
+ const error = new Error("Request failed");
+ const conn = {
+ facades: {
+ jimM: {
+ findAuditEvents: vi.fn().mockImplementation(() => {
+ throw error;
+ }),
+ },
+ },
+ } as unknown as Connection;
+ await expect(findAuditEvents(conn)).rejects.toBe(error);
+ });
+
+ it("handles no JIMM connection", async () => {
+ const conn = {
+ facades: {},
+ } as unknown as Connection;
+ await expect(findAuditEvents(conn)).rejects.toEqual(
+ new Error(Label.NO_JIMM),
+ );
+ });
+ });
+
+ describe("crossModelQuery", () => {
+ it("fetches cross model query result with supplied params", async () => {
+ const result = { results: {}, errors: {} };
+ const conn = {
+ facades: {
+ jimM: {
+ crossModelQuery: vi.fn(() => Promise.resolve(result)),
+ },
+ },
+ } as unknown as Connection;
+ const response = await crossModelQuery(conn, ".");
+ expect(conn.facades.jimM.crossModelQuery).toHaveBeenCalledWith(".");
+ expect(response).toMatchObject(result);
+ });
+
+ it("handles errors", async () => {
+ const error = new Error("Request failed");
+ const conn = {
+ facades: {
+ jimM: {
+ crossModelQuery: vi.fn().mockImplementation(() => {
+ throw error;
+ }),
+ },
+ },
+ } as unknown as Connection;
+ await expect(crossModelQuery(conn, ".")).rejects.toBe(error);
+ });
+
+ it("handles no JIMM connection", async () => {
+ const conn = {
+ facades: {},
+ } as unknown as Connection;
+ await expect(crossModelQuery(conn, ".")).rejects.toMatchObject(
+ new Error(Label.NO_JIMM),
+ );
+ });
+
+ it("should handle exceptions", async () => {
+ const conn = {
+ facades: {
+ jimM: {
+ crossModelQuery: vi.fn(() =>
+ Promise.reject(
+ new Error("Error while trying to run cross model query!"),
+ ),
+ ),
+ },
+ },
+ } as unknown as Connection;
+ await expect(crossModelQuery(conn, ".")).rejects.toMatchObject(
+ new Error("Error while trying to run cross model query!"),
+ );
+ });
+ });
+
+ describe("checkRelation", () => {
+ it("fetches cross model query result with supplied params", async () => {
+ const tuple = relationshipTupleFactory.build();
+ const conn = {
+ facades: {
+ jimM: {
+ checkRelation: vi.fn(() => Promise.resolve(true)),
+ },
+ },
+ } as unknown as Connection;
+ const response = await checkRelation(conn, tuple);
+ expect(conn.facades.jimM.checkRelation).toHaveBeenCalledWith(tuple);
+ expect(response).toStrictEqual(true);
+ });
+
+ it("handles errors", async () => {
+ const error = new Error("Request failed");
+ const conn = {
+ facades: {
+ jimM: {
+ checkRelation: vi.fn().mockImplementation(() => {
+ throw error;
+ }),
+ },
+ },
+ } as unknown as Connection;
+ await expect(
+ checkRelation(conn, relationshipTupleFactory.build()),
+ ).rejects.toBe(error);
+ });
+
+ it("handles no JIMM connection", async () => {
+ const conn = {
+ facades: {},
+ } as unknown as Connection;
+ await expect(
+ checkRelation(conn, relationshipTupleFactory.build()),
+ ).rejects.toMatchObject(new Error(Label.NO_JIMM));
+ });
+
+ it("should handle exceptions", async () => {
+ const conn = {
+ facades: {
+ jimM: {
+ checkRelation: vi.fn(() =>
+ Promise.reject(
+ new Error("Error while trying to run cross model query!"),
+ ),
+ ),
+ },
+ },
+ } as unknown as Connection;
+ await expect(
+ checkRelation(conn, relationshipTupleFactory.build()),
+ ).rejects.toMatchObject(
+ new Error("Error while trying to run cross model query!"),
+ );
+ });
+ });
});
diff --git a/src/juju/jimm/api.ts b/src/juju/jimm/api.ts
index 2d90bb43c..285faea3c 100644
--- a/src/juju/jimm/api.ts
+++ b/src/juju/jimm/api.ts
@@ -1,3 +1,12 @@
+import type { ConnectionWithFacades } from "juju/types";
+
+import type { FindAuditEventsRequest, AuditEvents } from "./JIMMV3";
+import type { CrossModelQueryResponse, RelationshipTuple } from "./JIMMV4";
+
+export enum Label {
+ NO_JIMM = "Not connected to JIMM.",
+}
+
export const endpoints = () => {
const jimmEndpoint =
window.jujuDashboardConfig?.controllerAPIEndpoint
@@ -11,3 +20,45 @@ export const endpoints = () => {
whoami: `${jimmEndpoint}/auth/whoami`,
};
};
+
+/**
+ Fetch audit events via the JIMM facade on the given controller connection.
+ */
+export function findAuditEvents(
+ conn: ConnectionWithFacades,
+ params?: FindAuditEventsRequest,
+) {
+ return new Promise((resolve, reject) => {
+ if (conn?.facades?.jimM) {
+ conn.facades.jimM
+ .findAuditEvents(params)
+ .then((events) => resolve(events))
+ .catch((error) => reject(error));
+ } else {
+ reject(new Error(Label.NO_JIMM));
+ }
+ });
+}
+
+export function crossModelQuery(conn: ConnectionWithFacades, query: string) {
+ return new Promise((resolve, reject) => {
+ if (conn?.facades?.jimM) {
+ conn.facades.jimM
+ .crossModelQuery(query)
+ .then((crossModelQueryResponse) => resolve(crossModelQueryResponse))
+ .catch((error) => reject(error));
+ } else {
+ reject(new Error(Label.NO_JIMM));
+ }
+ });
+}
+
+export const checkRelation = async (
+ conn: ConnectionWithFacades,
+ tuple: RelationshipTuple,
+) => {
+ if (!conn.facades.jimM) {
+ throw new Error(Label.NO_JIMM);
+ }
+ return await conn.facades.jimM.checkRelation(tuple);
+};
diff --git a/src/store/general/selectors.test.ts b/src/store/general/selectors.test.ts
index 9b318eb93..24d80d87a 100644
--- a/src/store/general/selectors.test.ts
+++ b/src/store/general/selectors.test.ts
@@ -5,6 +5,7 @@ import {
controllerFeaturesStateFactory,
credentialFactory,
generalStateFactory,
+ authUserInfoFactory,
} from "testing/factories/general";
import {
@@ -29,6 +30,7 @@ import {
isAuditLogsEnabled,
getAnalyticsEnabled,
isReBACEnabled,
+ getControllerUserTag,
} from "./selectors";
describe("selectors", () => {
@@ -421,4 +423,23 @@ describe("selectors", () => {
),
).toBe("wss://controller.example.com");
});
+
+ it("getControllerUserTag", () => {
+ expect(
+ getControllerUserTag(
+ rootStateFactory.build({
+ general: generalStateFactory.build({
+ config: configFactory.build({
+ controllerAPIEndpoint: "wss://controller.example.com",
+ }),
+ controllerConnections: {
+ "wss://controller.example.com": {
+ user: authUserInfoFactory.build(),
+ },
+ },
+ }),
+ }),
+ ),
+ ).toBe("user-eggman@external");
+ });
});
diff --git a/src/store/general/selectors.ts b/src/store/general/selectors.ts
index 510020ae0..6fdb5b127 100644
--- a/src/store/general/selectors.ts
+++ b/src/store/general/selectors.ts
@@ -169,6 +169,11 @@ export const getWSControllerURL = createSelector(
(config) => config?.controllerAPIEndpoint,
);
+export const getControllerUserTag = createSelector(
+ [(state) => state, getWSControllerURL],
+ getActiveUserTag,
+);
+
export const isAuditLogsEnabled = createSelector(
[getIsJuju, getWSControllerURL, (state) => state],
(isJuju, wsControllerURL, state) =>
diff --git a/src/store/juju/actions.test.ts b/src/store/juju/actions.test.ts
index c7f34c85b..c24fe22e6 100644
--- a/src/store/juju/actions.test.ts
+++ b/src/store/juju/actions.test.ts
@@ -4,7 +4,10 @@ import {
} from "testing/factories/juju/Charms";
import { fullStatusFactory } from "testing/factories/juju/ClientV6";
import { auditEventFactory } from "testing/factories/juju/jimm";
-import { listSecretResultFactory } from "testing/factories/juju/juju";
+import {
+ listSecretResultFactory,
+ relationshipTupleFactory,
+} from "testing/factories/juju/juju";
import { actions } from "./slice";
@@ -420,4 +423,46 @@ describe("actions", () => {
},
});
});
+
+ it("checkRelation", () => {
+ const tuple = relationshipTupleFactory.build();
+ expect(
+ actions.checkRelation({
+ tuple,
+ wsControllerURL: "wss://test.example.com",
+ }),
+ ).toStrictEqual({
+ type: "juju/checkRelation",
+ payload: {
+ tuple,
+ wsControllerURL: "wss://test.example.com",
+ },
+ });
+ });
+
+ it("addCheckRelation", () => {
+ const tuple = relationshipTupleFactory.build();
+ expect(actions.addCheckRelation({ tuple, allowed: true })).toStrictEqual({
+ type: "juju/addCheckRelation",
+ payload: { tuple, allowed: true },
+ });
+ });
+
+ it("addCheckRelationErrors", () => {
+ const tuple = relationshipTupleFactory.build();
+ expect(
+ actions.addCheckRelationErrors({ tuple, errors: "oops!" }),
+ ).toStrictEqual({
+ type: "juju/addCheckRelationErrors",
+ payload: { tuple, errors: "oops!" },
+ });
+ });
+
+ it("removeCheckRelation", () => {
+ const tuple = relationshipTupleFactory.build();
+ expect(actions.removeCheckRelation({ tuple })).toStrictEqual({
+ type: "juju/removeCheckRelation",
+ payload: { tuple },
+ });
+ });
});
diff --git a/src/store/juju/reducers.test.ts b/src/store/juju/reducers.test.ts
index 2e089618a..4adde54f0 100644
--- a/src/store/juju/reducers.test.ts
+++ b/src/store/juju/reducers.test.ts
@@ -18,6 +18,8 @@ import {
modelSecretsFactory,
modelFeaturesStateFactory,
modelSecretsContentFactory,
+ relationshipTupleFactory,
+ rebacRelationFactory,
} from "testing/factories/juju/juju";
import {
modelWatcherModelDataFactory,
@@ -1026,4 +1028,140 @@ describe("reducers", () => {
}),
});
});
+
+ it("checkRelation", () => {
+ const tuple = relationshipTupleFactory.build();
+ const state = jujuStateFactory.build({
+ rebacRelations: [],
+ });
+ expect(
+ reducer(
+ state,
+ actions.checkRelation({
+ tuple,
+ wsControllerURL: "wss://example.com",
+ }),
+ ),
+ ).toStrictEqual({
+ ...state,
+ rebacRelations: [
+ rebacRelationFactory.build({
+ tuple,
+ loading: true,
+ }),
+ ],
+ });
+ });
+
+ it("checkRelation already exists", () => {
+ const tuple = relationshipTupleFactory.build();
+ const state = jujuStateFactory.build({
+ rebacRelations: [
+ rebacRelationFactory.build({
+ tuple,
+ loaded: true,
+ }),
+ ],
+ });
+ expect(
+ reducer(
+ state,
+ actions.checkRelation({
+ tuple,
+ wsControllerURL: "wss://example.com",
+ }),
+ ),
+ ).toStrictEqual({
+ ...state,
+ rebacRelations: [
+ rebacRelationFactory.build({
+ tuple,
+ loading: true,
+ loaded: false,
+ }),
+ ],
+ });
+ });
+
+ it("addCheckRelation", () => {
+ const tuple = relationshipTupleFactory.build();
+ const state = jujuStateFactory.build({
+ rebacRelations: [
+ rebacRelationFactory.build({
+ tuple,
+ loading: true,
+ }),
+ ],
+ });
+ expect(
+ reducer(state, actions.addCheckRelation({ tuple, allowed: true })),
+ ).toStrictEqual({
+ ...state,
+ rebacRelations: [
+ rebacRelationFactory.build({
+ allowed: true,
+ tuple,
+ loading: false,
+ loaded: true,
+ }),
+ ],
+ });
+ });
+
+ it("addCheckRelationErrors", () => {
+ const tuple = relationshipTupleFactory.build();
+ const state = jujuStateFactory.build({
+ rebacRelations: [
+ rebacRelationFactory.build({
+ tuple,
+ loading: true,
+ }),
+ ],
+ });
+ expect(
+ reducer(
+ state,
+ actions.addCheckRelationErrors({ tuple, errors: "Oops!" }),
+ ),
+ ).toStrictEqual({
+ ...state,
+ rebacRelations: [
+ rebacRelationFactory.build({
+ tuple,
+ allowed: null,
+ errors: "Oops!",
+ loading: false,
+ loaded: false,
+ }),
+ ],
+ });
+ });
+
+ it("removeCheckRelation", () => {
+ const tuple = relationshipTupleFactory.build();
+ const state = jujuStateFactory.build({
+ rebacRelations: [
+ rebacRelationFactory.build({
+ tuple,
+ }),
+ rebacRelationFactory.build({
+ tuple: relationshipTupleFactory.build({
+ object: "model-1234",
+ }),
+ }),
+ ],
+ });
+ expect(
+ reducer(state, actions.removeCheckRelation({ tuple })),
+ ).toStrictEqual({
+ ...state,
+ rebacRelations: [
+ rebacRelationFactory.build({
+ tuple: relationshipTupleFactory.build({
+ object: "model-1234",
+ }),
+ }),
+ ],
+ });
+ });
});
diff --git a/src/store/juju/selectors.test.ts b/src/store/juju/selectors.test.ts
index 9eb5ceb0b..e8dbd69a2 100644
--- a/src/store/juju/selectors.test.ts
+++ b/src/store/juju/selectors.test.ts
@@ -1,5 +1,10 @@
+import { JIMMRelation, JIMMTarget } from "juju/jimm/JIMMV4";
import { rootStateFactory } from "testing/factories";
-import { generalStateFactory } from "testing/factories/general";
+import {
+ generalStateFactory,
+ authUserInfoFactory,
+ configFactory,
+} from "testing/factories/general";
import {
charmApplicationFactory,
charmInfoFactory,
@@ -26,6 +31,8 @@ import {
modelSecretsFactory,
modelFeaturesFactory,
modelFeaturesStateFactory,
+ rebacRelationFactory,
+ relationshipTupleFactory,
} from "testing/factories/juju/juju";
import {
applicationInfoFactory,
@@ -113,6 +120,9 @@ import {
getSecretsContentLoaded,
getSecretsContentLoading,
isKubernetesModel,
+ getReBACRelationsState,
+ hasReBACPermission,
+ isJIMMAdmin,
} from "./selectors";
describe("selectors", () => {
@@ -626,6 +636,121 @@ describe("selectors", () => {
).toStrictEqual(controllers);
});
+ it("getReBACRelationsState", () => {
+ const rebacRelations = [
+ rebacRelationFactory.build({
+ tuple: relationshipTupleFactory.build({
+ object: "user-eggman@external",
+ relation: JIMMRelation.ADMINISTRATOR,
+ target_object: JIMMTarget.JIMM_CONTROLLER,
+ }),
+ allowed: true,
+ }),
+ ];
+ expect(
+ getReBACRelationsState(
+ rootStateFactory.build({
+ juju: jujuStateFactory.build({
+ rebacRelations,
+ }),
+ }),
+ ),
+ ).toStrictEqual(rebacRelations);
+ });
+
+ it("hasReBACPermission exists", () => {
+ const tuple = relationshipTupleFactory.build({
+ object: "user-eggman@external",
+ relation: JIMMRelation.ADMINISTRATOR,
+ target_object: JIMMTarget.JIMM_CONTROLLER,
+ });
+ expect(
+ hasReBACPermission(
+ rootStateFactory.build({
+ juju: jujuStateFactory.build({
+ rebacRelations: [
+ rebacRelationFactory.build({
+ tuple,
+ allowed: true,
+ }),
+ ],
+ }),
+ }),
+ tuple,
+ ),
+ ).toStrictEqual(true);
+ });
+
+ it("hasReBACPermission doesn't exist", () => {
+ const tuple = relationshipTupleFactory.build({
+ object: "user-eggman@external",
+ relation: JIMMRelation.ADMINISTRATOR,
+ target_object: JIMMTarget.JIMM_CONTROLLER,
+ });
+ expect(
+ hasReBACPermission(
+ rootStateFactory.build({
+ juju: jujuStateFactory.build({
+ rebacRelations: [],
+ }),
+ }),
+ tuple,
+ ),
+ ).toStrictEqual(false);
+ });
+
+ it("isJIMMAdmin exists", () => {
+ expect(
+ isJIMMAdmin(
+ rootStateFactory.build({
+ general: generalStateFactory.build({
+ config: configFactory.build({
+ controllerAPIEndpoint: "wss://controller.example.com",
+ }),
+ controllerConnections: {
+ "wss://controller.example.com": {
+ user: authUserInfoFactory.build({
+ identity: "user-eggman@external",
+ }),
+ },
+ },
+ }),
+ juju: jujuStateFactory.build({
+ rebacRelations: [
+ rebacRelationFactory.build({
+ tuple: relationshipTupleFactory.build({
+ object: "user-eggman@external",
+ relation: JIMMRelation.ADMINISTRATOR,
+ target_object: JIMMTarget.JIMM_CONTROLLER,
+ }),
+ allowed: true,
+ }),
+ ],
+ }),
+ }),
+ ),
+ ).toStrictEqual(true);
+ });
+
+ it("isJIMMAdmin doesn't exist", () => {
+ expect(
+ isJIMMAdmin(
+ rootStateFactory.build({
+ general: generalStateFactory.build({
+ controllerConnections: {
+ "wss://controller.example.com": {
+ user: authUserInfoFactory.build(),
+ },
+ },
+ }),
+ juju: jujuStateFactory.build({
+ rebacRelations: [],
+ }),
+ }),
+ ),
+ ).toStrictEqual(false);
+ });
+
describe("getControllersCount", () => {
it("without controllers", () => {
expect(
diff --git a/src/store/juju/selectors.ts b/src/store/juju/selectors.ts
index 0666bf471..33f2fa472 100644
--- a/src/store/juju/selectors.ts
+++ b/src/store/juju/selectors.ts
@@ -5,8 +5,11 @@ import type {
} from "@canonical/jujulib/dist/api/facades/client/ClientV6";
import { createSelector } from "@reduxjs/toolkit";
import cloneDeep from "clone-deep";
+import isEqual from "lodash.isequal";
import type { AuditEvent } from "juju/jimm/JIMMV3";
+import type { RelationshipTuple } from "juju/jimm/JIMMV4";
+import { JIMMRelation, JIMMTarget } from "juju/jimm/JIMMV4";
import type {
AnnotationData,
ApplicationData,
@@ -18,6 +21,7 @@ import type {
import {
getActiveUserTag,
getActiveUserControllerAccess,
+ getControllerUserTag,
} from "store/general/selectors";
import type { RootState } from "store/store";
import { getUserName } from "utils";
@@ -1052,3 +1056,30 @@ export const isKubernetesModel = createSelector(
modelData?.info?.["provider-type"] === "kubernetes" ||
modelInfo?.type === "kubernetes",
);
+
+export const getReBACRelationsState = createSelector(
+ [slice],
+ (sliceState) => sliceState.rebacRelations,
+);
+
+export const hasReBACPermission = createSelector(
+ [getReBACRelationsState, (_state, tuple: RelationshipTuple) => tuple],
+ (rebacRelations, tuple) => {
+ const relation = rebacRelations.find((relation) =>
+ isEqual(relation.tuple, tuple),
+ );
+ return relation?.allowed ?? false;
+ },
+);
+
+export const isJIMMAdmin = createSelector(
+ [(state) => state, getControllerUserTag],
+ (state, user) =>
+ user
+ ? hasReBACPermission(state, {
+ object: user,
+ relation: JIMMRelation.ADMINISTRATOR,
+ target_object: JIMMTarget.JIMM_CONTROLLER,
+ })
+ : false,
+);
diff --git a/src/store/juju/slice.ts b/src/store/juju/slice.ts
index 61577f27a..9a821d247 100644
--- a/src/store/juju/slice.ts
+++ b/src/store/juju/slice.ts
@@ -10,11 +10,13 @@ import type {
} from "@canonical/jujulib/dist/api/facades/secrets/SecretsV2";
import type { PayloadAction } from "@reduxjs/toolkit";
import { createSlice } from "@reduxjs/toolkit";
+import isEqual from "lodash.isequal";
import type { AuditEvent, FindAuditEventsRequest } from "juju/jimm/JIMMV3";
-import {
- type CrossModelQueryRequest,
- type CrossModelQueryResponse,
+import type {
+ CrossModelQueryRequest,
+ CrossModelQueryResponse,
+ RelationshipTuple,
} from "juju/jimm/JIMMV4";
import type {
AllWatcherDelta,
@@ -30,6 +32,7 @@ import type {
ModelFeatures,
ModelSecrets,
SecretsContent,
+ ReBACRelation,
} from "./types";
export const DEFAULT_AUDIT_EVENTS_LIMIT = 50;
@@ -93,6 +96,7 @@ const slice = createSlice({
modelFeatures: {},
modelWatcherData: {},
charms: [],
+ rebacRelations: [],
secrets: {},
selectedApplications: [],
} as JujuState,
@@ -431,6 +435,75 @@ const slice = createSlice({
delete secrets.content;
}
},
+ checkRelation: (
+ state,
+ {
+ payload,
+ }: PayloadAction<{ tuple: RelationshipTuple } & WsControllerURLParam>,
+ ) => {
+ const tuple = payload.tuple;
+ const relationState: ReBACRelation = {
+ errors: null,
+ loaded: false,
+ loading: true,
+ tuple,
+ };
+ const existingIndex = state.rebacRelations.findIndex((relation) =>
+ isEqual(relation.tuple, tuple),
+ );
+ if (existingIndex >= 0) {
+ state.rebacRelations[existingIndex] = relationState;
+ } else {
+ state.rebacRelations.push(relationState);
+ }
+ },
+ addCheckRelation: (
+ state,
+ {
+ payload,
+ }: PayloadAction<{ tuple: RelationshipTuple; allowed: boolean }>,
+ ) => {
+ const existingIndex = state.rebacRelations.findIndex((relation) =>
+ isEqual(relation.tuple, payload.tuple),
+ );
+ if (existingIndex >= 0) {
+ state.rebacRelations[existingIndex] = {
+ ...state.rebacRelations[existingIndex],
+ allowed: payload.allowed,
+ errors: null,
+ loaded: true,
+ loading: false,
+ };
+ }
+ },
+ addCheckRelationErrors: (
+ state,
+ { payload }: PayloadAction<{ tuple: RelationshipTuple; errors: string }>,
+ ) => {
+ const existingIndex = state.rebacRelations.findIndex((relation) =>
+ isEqual(relation.tuple, payload.tuple),
+ );
+ if (existingIndex >= 0) {
+ state.rebacRelations[existingIndex] = {
+ ...state.rebacRelations[existingIndex],
+ allowed: null,
+ errors: payload.errors,
+ loaded: false,
+ loading: false,
+ };
+ }
+ },
+ removeCheckRelation: (
+ state,
+ { payload }: PayloadAction<{ tuple: RelationshipTuple }>,
+ ) => {
+ const existingIndex = state.rebacRelations.findIndex((relation) =>
+ isEqual(relation.tuple, payload.tuple),
+ );
+ if (existingIndex >= 0) {
+ state.rebacRelations.splice(existingIndex, 1);
+ }
+ },
},
});
diff --git a/src/store/juju/types.ts b/src/store/juju/types.ts
index 4cbb9ed7f..34fceb58e 100644
--- a/src/store/juju/types.ts
+++ b/src/store/juju/types.ts
@@ -7,7 +7,10 @@ import type {
import type { ControllerInfo } from "juju/jimm/JIMMV3";
import type { AuditEvent } from "juju/jimm/JIMMV3";
-import type { CrossModelQueryResponse } from "juju/jimm/JIMMV4";
+import type {
+ CrossModelQueryResponse,
+ RelationshipTuple,
+} from "juju/jimm/JIMMV4";
import type {
ApplicationInfo,
FullStatusWithAnnotations,
@@ -89,6 +92,11 @@ export type ModelFeatures = {
export type ModelFeaturesState = Record;
+export type ReBACRelation = GenericState & {
+ tuple: RelationshipTuple;
+ allowed?: boolean | null;
+};
+
export type JujuState = {
auditEvents: AuditEventsState;
crossModelQuery: CrossModelQueryState;
@@ -100,6 +108,7 @@ export type JujuState = {
modelFeatures: ModelFeaturesState;
modelWatcherData?: ModelWatcherData;
charms: Charm[];
+ rebacRelations: ReBACRelation[];
secrets: SecretsState;
selectedApplications: ApplicationInfo[];
};
diff --git a/src/store/middleware/check-auth.ts b/src/store/middleware/check-auth.ts
index 9144eb1b9..fafb57163 100644
--- a/src/store/middleware/check-auth.ts
+++ b/src/store/middleware/check-auth.ts
@@ -87,6 +87,10 @@ export const checkAuthMiddleware: Middleware<
jujuActions.updateCrossModelQueryResults.type,
jujuActions.updateCrossModelQueryErrors.type,
jujuActions.updateControllerList.type,
+ jujuActions.addCheckRelationErrors.type,
+ jujuActions.addCheckRelation.type,
+ jujuActions.checkRelation.type,
+ jujuActions.removeCheckRelation.type,
jujuActions.clearControllerData.type,
jujuActions.clearModelData.type,
jujuActions.clearCrossModelQuery.type,
diff --git a/src/store/middleware/model-poller.test.ts b/src/store/middleware/model-poller.test.ts
index 596afb6d1..553b0f044 100644
--- a/src/store/middleware/model-poller.test.ts
+++ b/src/store/middleware/model-poller.test.ts
@@ -4,7 +4,7 @@ import type { Mock } from "vitest";
import { vi } from "vitest";
import * as jujuModule from "juju/api";
-import type { RelationshipTuple } from "juju/jimm/JIMMV4";
+import * as jimmModule from "juju/jimm/api";
import { pollWhoamiStart } from "juju/jimm/listeners";
import { actions as appActions, thunks as appThunks } from "store/app";
import type { ControllerArgs } from "store/app/actions";
@@ -17,14 +17,10 @@ import { auditEventFactory } from "testing/factories/juju/jimm";
import {
controllerFactory,
jujuStateFactory,
+ relationshipTupleFactory,
} from "testing/factories/juju/juju";
-import {
- AuditLogsError,
- LoginError,
- ModelsError,
- modelPollerMiddleware,
-} from "./model-poller";
+import { LoginError, ModelsError, modelPollerMiddleware } from "./model-poller";
vi.mock("juju/api", () => ({
disableControllerUUIDMasking: vi
@@ -36,8 +32,12 @@ vi.mock("juju/api", () => ({
loginWithBakery: vi.fn(),
fetchAllModelStatuses: vi.fn(),
setModelSharingPermissions: vi.fn(),
- findAuditEvents: vi.fn(),
+}));
+
+vi.mock("juju/jimm/api", () => ({
+ checkRelation: vi.fn(),
crossModelQuery: vi.fn(),
+ findAuditEvents: vi.fn(),
}));
describe("model poller", () => {
@@ -350,114 +350,6 @@ describe("model poller", () => {
);
});
- it("enables audit logs if the user has audit log permissions", async () => {
- conn.facades.modelManager.listModels.mockResolvedValue({
- "user-models": [],
- });
- conn.facades.jimM = {
- checkRelation: vi
- .fn()
- .mockImplementation(async (payload: RelationshipTuple) => {
- if (payload.relation === "audit_log_viewer") {
- return {
- allowed: true,
- };
- }
- }),
- version: 4,
- };
- vi.spyOn(jujuModule, "loginWithBakery").mockImplementation(async () => ({
- conn,
- intervalId,
- juju,
- }));
- await runMiddleware();
- expect(next).not.toHaveBeenCalled();
- expect(fakeStore.dispatch).toHaveBeenCalledWith(
- generalActions.updateControllerFeatures({
- wsControllerURL,
- features: {
- auditLogs: true,
- crossModelQueries: true,
- rebac: false,
- },
- }),
- );
- });
-
- it("enables audit logs if the user is an administrator", async () => {
- conn.facades.modelManager.listModels.mockResolvedValue({
- "user-models": [],
- });
- conn.facades.jimM = {
- checkRelation: vi
- .fn()
- .mockImplementation(async (payload: RelationshipTuple) => {
- if (payload.relation === "audit_log_viewer") {
- return {
- allowed: false,
- };
- }
- if (payload.relation === "administrator") {
- return {
- allowed: true,
- };
- }
- }),
- version: 4,
- };
- vi.spyOn(jujuModule, "loginWithBakery").mockImplementation(async () => ({
- conn,
- intervalId,
- juju,
- }));
- await runMiddleware();
- expect(next).not.toHaveBeenCalled();
- expect(fakeStore.dispatch).toHaveBeenCalledWith(
- generalActions.updateControllerFeatures({
- wsControllerURL,
- features: {
- auditLogs: true,
- crossModelQueries: true,
- rebac: true,
- },
- }),
- );
- });
-
- it("enables ReBAC if the user is an administrator", async () => {
- conn.facades.modelManager.listModels.mockResolvedValue({
- "user-models": [],
- });
- conn.facades.jimM = {
- checkRelation: vi
- .fn()
- .mockImplementation(async (payload: RelationshipTuple) => {
- return {
- allowed: payload.relation === "administrator",
- };
- }),
- version: 4,
- };
- vi.spyOn(jujuModule, "loginWithBakery").mockImplementation(async () => ({
- conn,
- intervalId,
- juju,
- }));
- await runMiddleware();
- expect(next).not.toHaveBeenCalled();
- expect(fakeStore.dispatch).toHaveBeenCalledWith(
- generalActions.updateControllerFeatures({
- wsControllerURL,
- features: {
- auditLogs: true,
- crossModelQueries: true,
- rebac: true,
- },
- }),
- );
- });
-
it("dispatches an error if the info is not returned", async () => {
vi.spyOn(jujuModule, "loginWithBakery").mockImplementation(async () => ({
conn: { ...conn, info: {} },
@@ -784,7 +676,7 @@ describe("model poller", () => {
intervalId,
juju,
}));
- vi.spyOn(jujuModule, "findAuditEvents").mockImplementation(() =>
+ vi.spyOn(jimmModule, "findAuditEvents").mockImplementation(() =>
Promise.resolve(events),
);
const middleware = await runMiddleware();
@@ -793,7 +685,7 @@ describe("model poller", () => {
wsControllerURL: "wss://example.com",
});
await middleware(next)(action);
- expect(jujuModule.findAuditEvents).toHaveBeenCalledWith(
+ expect(jimmModule.findAuditEvents).toHaveBeenCalledWith(
expect.any(Object),
{ "user-tag": "user-eggman@external" },
);
@@ -810,7 +702,7 @@ describe("model poller", () => {
intervalId,
juju,
}));
- vi.spyOn(jujuModule, "findAuditEvents").mockImplementation(() =>
+ vi.spyOn(jimmModule, "findAuditEvents").mockImplementation(() =>
Promise.resolve(events),
);
const middleware = await runMiddleware();
@@ -819,27 +711,7 @@ describe("model poller", () => {
wsControllerURL: "nothing",
});
await middleware(next)(action);
- expect(jujuModule.findAuditEvents).not.toHaveBeenCalled();
- });
-
- it("should handle Audit Logs user permission error", async () => {
- conn.facades.jimM = {
- checkRelation: vi.fn().mockRejectedValue(new Error("Oops!")),
- version: 4,
- };
- vi.spyOn(jujuModule, "loginWithBakery").mockImplementation(async () => ({
- conn,
- intervalId,
- juju,
- }));
- await runMiddleware();
- expect(fakeStore.dispatch).toHaveBeenCalledWith(
- jujuActions.updateAuditEventsErrors(AuditLogsError.CHECK_PERMISSIONS),
- );
- expect(console.error).toHaveBeenCalledWith(
- AuditLogsError.CHECK_PERMISSIONS,
- new Error("Oops!"),
- );
+ expect(jimmModule.findAuditEvents).not.toHaveBeenCalled();
});
it("should handle Audit Logs error", async () => {
@@ -848,7 +720,7 @@ describe("model poller", () => {
intervalId,
juju,
}));
- vi.spyOn(jujuModule, "findAuditEvents").mockImplementation(() =>
+ vi.spyOn(jimmModule, "findAuditEvents").mockImplementation(() =>
Promise.reject(new Error("Uh oh!")),
);
const middleware = await runMiddleware();
@@ -873,7 +745,7 @@ describe("model poller", () => {
intervalId,
juju,
}));
- vi.spyOn(jujuModule, "crossModelQuery").mockImplementation(() =>
+ vi.spyOn(jimmModule, "crossModelQuery").mockImplementation(() =>
Promise.resolve(crossModelQueryResponse),
);
const middleware = await runMiddleware();
@@ -882,7 +754,7 @@ describe("model poller", () => {
query: ".",
});
await middleware(next)(action);
- expect(jujuModule.crossModelQuery).toHaveBeenCalledWith(
+ expect(jimmModule.crossModelQuery).toHaveBeenCalledWith(
expect.any(Object),
".",
);
@@ -899,7 +771,7 @@ describe("model poller", () => {
intervalId,
juju,
}));
- vi.spyOn(jujuModule, "crossModelQuery").mockImplementation(() =>
+ vi.spyOn(jimmModule, "crossModelQuery").mockImplementation(() =>
Promise.resolve(crossModelQueryResponse),
);
const middleware = await runMiddleware();
@@ -908,7 +780,7 @@ describe("model poller", () => {
query: ".",
});
await middleware(next)(action);
- expect(jujuModule.crossModelQuery).not.toHaveBeenCalled();
+ expect(jimmModule.crossModelQuery).not.toHaveBeenCalled();
});
it("handles errors object from response when fetching cross model query results", async () => {
@@ -921,7 +793,7 @@ describe("model poller", () => {
intervalId,
juju,
}));
- vi.spyOn(jujuModule, "crossModelQuery").mockImplementation(() =>
+ vi.spyOn(jimmModule, "crossModelQuery").mockImplementation(() =>
Promise.resolve(crossModelQueryResponse),
);
const middleware = await runMiddleware();
@@ -941,7 +813,7 @@ describe("model poller", () => {
intervalId,
juju,
}));
- vi.spyOn(jujuModule, "crossModelQuery").mockImplementation(() =>
+ vi.spyOn(jimmModule, "crossModelQuery").mockImplementation(() =>
Promise.reject(new Error("Uh oh!")),
);
const middleware = await runMiddleware();
@@ -965,7 +837,7 @@ describe("model poller", () => {
intervalId,
juju,
}));
- vi.spyOn(jujuModule, "crossModelQuery")
+ vi.spyOn(jimmModule, "crossModelQuery")
// eslint-disable-next-line prefer-promise-reject-errors
.mockImplementation(() => Promise.reject("Uh oh!"));
const middleware = await runMiddleware();
@@ -984,4 +856,102 @@ describe("model poller", () => {
),
);
});
+
+ it("handles checking relations", async () => {
+ const tuple = relationshipTupleFactory.build();
+ const checkRelationResponse = { allowed: true };
+ vi.spyOn(jujuModule, "loginWithBakery").mockImplementation(async () => ({
+ conn,
+ intervalId,
+ juju,
+ }));
+ vi.spyOn(jimmModule, "checkRelation").mockImplementation(() =>
+ Promise.resolve(checkRelationResponse),
+ );
+ const middleware = await runMiddleware();
+ const action = jujuActions.checkRelation({
+ wsControllerURL: "wss://example.com",
+ tuple,
+ });
+ await middleware(next)(action);
+ expect(jimmModule.checkRelation).toHaveBeenCalledWith(
+ expect.any(Object),
+ tuple,
+ );
+ expect(next).toHaveBeenCalledWith(action);
+ expect(fakeStore.dispatch).toHaveBeenCalledWith(
+ jujuActions.addCheckRelation({ tuple, allowed: true }),
+ );
+ });
+
+ it("handles no controller when checking relations", async () => {
+ const tuple = relationshipTupleFactory.build();
+ const checkRelationResponse = { allowed: true };
+ vi.spyOn(jujuModule, "loginWithBakery").mockImplementation(async () => ({
+ conn,
+ intervalId,
+ juju,
+ }));
+ vi.spyOn(jimmModule, "checkRelation").mockImplementation(() =>
+ Promise.resolve(checkRelationResponse),
+ );
+ const middleware = await runMiddleware();
+ const action = jujuActions.checkRelation({
+ wsControllerURL: "nothing",
+ tuple,
+ });
+ await middleware(next)(action);
+ expect(jimmModule.checkRelation).not.toHaveBeenCalled();
+ });
+
+ it("handles errors from response when checking relations", async () => {
+ const tuple = relationshipTupleFactory.build();
+ const checkRelationResponse = {
+ error: "target not found",
+ };
+ vi.spyOn(jujuModule, "loginWithBakery").mockImplementation(async () => ({
+ conn,
+ intervalId,
+ juju,
+ }));
+ vi.spyOn(jimmModule, "checkRelation").mockImplementation(() =>
+ Promise.resolve(checkRelationResponse),
+ );
+ const middleware = await runMiddleware();
+ const action = jujuActions.checkRelation({
+ wsControllerURL: "wss://example.com",
+ tuple,
+ });
+ await middleware(next)(action);
+ expect(fakeStore.dispatch).toHaveBeenCalledWith(
+ jujuActions.addCheckRelationErrors({
+ tuple,
+ errors: checkRelationResponse.error,
+ }),
+ );
+ });
+
+ it("handles non-standard errors when checking relations", async () => {
+ const tuple = relationshipTupleFactory.build();
+ vi.spyOn(jujuModule, "loginWithBakery").mockImplementation(async () => ({
+ conn,
+ intervalId,
+ juju,
+ }));
+ vi.spyOn(jimmModule, "checkRelation")
+ // eslint-disable-next-line prefer-promise-reject-errors
+ .mockImplementation(() => Promise.reject("Uh oh!"));
+ const middleware = await runMiddleware();
+ const action = jujuActions.checkRelation({
+ wsControllerURL: "wss://example.com",
+ tuple,
+ });
+ await middleware(next)(action);
+ expect(fakeStore.dispatch).toHaveBeenCalledWith(
+ jujuActions.addCheckRelationErrors({
+ tuple,
+ errors: "Could not check permissions.",
+ }),
+ );
+ });
});
diff --git a/src/store/middleware/model-poller.ts b/src/store/middleware/model-poller.ts
index b4f4ca1f8..3285a391b 100644
--- a/src/store/middleware/model-poller.ts
+++ b/src/store/middleware/model-poller.ts
@@ -7,12 +7,10 @@ import {
disableControllerUUIDMasking,
fetchAllModelStatuses,
fetchControllerList,
- findAuditEvents,
- crossModelQuery,
loginWithBakery,
setModelSharingPermissions,
} from "juju/api";
-import { JIMMRelation } from "juju/jimm/JIMMV4";
+import { checkRelation, crossModelQuery, findAuditEvents } from "juju/jimm/api";
import { pollWhoamiStart } from "juju/jimm/listeners";
import { whoami } from "juju/jimm/thunks";
import type { ConnectionWithFacades } from "juju/types";
@@ -25,10 +23,6 @@ import type { RootState, Store } from "store/store";
import { isSpecificAction } from "types";
import { toErrorString } from "utils";
-export enum AuditLogsError {
- CHECK_PERMISSIONS = "Unable to check Audit Logs user permission.",
-}
-
export enum LoginError {
LOG = "Unable to log into controller.",
NO_INFO = "Unable to retrieve controller details.",
@@ -42,19 +36,6 @@ export enum ModelsError {
LIST_OR_UPDATE_MODELS = "Unable to list or update models.",
}
-const checkJIMMRelation = async (
- conn: ConnectionWithFacades,
- identity: string,
- relation: string,
-) => {
- const response = await conn.facades.jimM?.checkRelation({
- object: identity,
- relation: relation,
- target_object: "controller-jimm",
- });
- return !!response?.allowed;
-};
-
export const modelPollerMiddleware: Middleware<
void,
RootState,
@@ -164,51 +145,13 @@ export const modelPollerMiddleware: Middleware<
}),
);
const jimmVersion = conn.facades.jimM?.version ?? 0;
- const auditLogsAvailable = jimmVersion >= 4;
- const rebacAvailable = jimmVersion >= 4;
- const identity = conn.info.user?.identity;
- let isJIMMAdmin = false;
- let auditLogsAllowed = false;
- let rebacAllowed = false;
- if (identity) {
- try {
- isJIMMAdmin = await checkJIMMRelation(
- conn,
- identity,
- JIMMRelation.ADMINISTRATOR,
- );
- } catch (error) {
- console.error(error);
- }
- rebacAllowed = rebacAvailable && isJIMMAdmin;
- if (auditLogsAvailable) {
- try {
- auditLogsAllowed = await checkJIMMRelation(
- conn,
- identity,
- JIMMRelation.AUDIT_LOG_VIEWER,
- );
- if (!auditLogsAllowed) {
- auditLogsAllowed = isJIMMAdmin;
- }
- reduxStore.dispatch(jujuActions.updateAuditEventsErrors(null));
- } catch (error) {
- reduxStore.dispatch(
- jujuActions.updateAuditEventsErrors(
- AuditLogsError.CHECK_PERMISSIONS,
- ),
- );
- console.error(AuditLogsError.CHECK_PERMISSIONS, error);
- }
- }
- }
reduxStore.dispatch(
generalActions.updateControllerFeatures({
wsControllerURL,
features: {
- auditLogs: auditLogsAllowed && auditLogsAvailable,
+ auditLogs: jimmVersion >= 4,
crossModelQueries: jimmVersion >= 4,
- rebac: rebacAllowed,
+ rebac: jimmVersion >= 4,
},
}),
);
@@ -344,7 +287,6 @@ export const modelPollerMiddleware: Middleware<
) {
// Intercept fetchAuditEvents actions and fetch and store audit events via the
// controller connection.
-
const { wsControllerURL, ...params } = action.payload;
// Immediately pass the action along so that it can be handled by the
// reducer to update the loading state.
@@ -373,7 +315,6 @@ export const modelPollerMiddleware: Middleware<
) {
// Intercept fetchCrossModelQuery actions and fetch and store
// cross model query via the controller connection.
-
const { wsControllerURL, query } = action.payload;
// Immediately pass the action along so that it can be handled by the
// reducer to update the loading state.
@@ -406,6 +347,47 @@ export const modelPollerMiddleware: Middleware<
// The action has already been passed to the next middleware
// at the top of this handler.
return;
+ } else if (
+ isSpecificAction>(
+ action,
+ jujuActions.checkRelation.type,
+ )
+ ) {
+ // Intercept checkRelation actions and fetch and store
+ // the relation via the controller connection.
+ const { wsControllerURL, tuple } = action.payload;
+ // Immediately pass the action along so that it can be handled by the
+ // reducer to update the loading state.
+ next(action);
+ const conn = controllers.get(wsControllerURL);
+ if (!conn) {
+ return;
+ }
+ try {
+ const response = await checkRelation(conn, tuple);
+ reduxStore.dispatch(
+ "error" in response
+ ? jujuActions.addCheckRelationErrors({
+ tuple,
+ errors: response.error,
+ })
+ : jujuActions.addCheckRelation({
+ tuple,
+ allowed: response.allowed,
+ }),
+ );
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error
+ ? error.message
+ : "Could not check permissions.";
+ reduxStore.dispatch(
+ jujuActions.addCheckRelationErrors({ tuple, errors: errorMessage }),
+ );
+ }
+ // The action has already been passed to the next middleware
+ // at the top of this handler.
+ return;
}
return next(action);
};
diff --git a/src/testing/factories/general.ts b/src/testing/factories/general.ts
index 309cddfaa..75078733c 100644
--- a/src/testing/factories/general.ts
+++ b/src/testing/factories/general.ts
@@ -1,3 +1,4 @@
+import type { AuthUserInfo } from "@canonical/jujulib/dist/api/facades/admin/AdminV3";
import { Factory } from "fishery";
import {
@@ -33,6 +34,13 @@ export const controllerFeaturesFactory = Factory.define(
export const controllerFeaturesStateFactory =
Factory.define(() => ({}));
+export const authUserInfoFactory = Factory.define(() => ({
+ "display-name": "eggman",
+ identity: "user-eggman@external",
+ "controller-access": "",
+ "model-access": "",
+}));
+
class GeneralStateFactory extends Factory {
withConfig() {
return this.params({
diff --git a/src/testing/factories/juju/juju.ts b/src/testing/factories/juju/juju.ts
index 0235d9905..976498b22 100644
--- a/src/testing/factories/juju/juju.ts
+++ b/src/testing/factories/juju/juju.ts
@@ -13,6 +13,8 @@ import type {
} from "@canonical/jujulib/dist/api/facades/secrets/SecretsV2";
import { Factory } from "fishery";
+import type { RelationshipTuple } from "juju/jimm/JIMMV4";
+import { JIMMRelation } from "juju/jimm/JIMMV4";
import { DEFAULT_AUDIT_EVENTS_LIMIT } from "store/juju/slice";
import type {
AuditEventsState,
@@ -25,6 +27,7 @@ import type {
ModelFeaturesState,
ModelListInfo,
ModelSecrets,
+ ReBACRelation,
SecretsState,
} from "store/juju/types";
import type { SecretsContent } from "store/juju/types";
@@ -232,6 +235,21 @@ export const modelFeaturesStateFactory = Factory.define(
() => ({}),
);
+export const relationshipTupleFactory = Factory.define(
+ () => ({
+ object: "user-eggman@external",
+ relation: JIMMRelation.MEMBER,
+ target_object: "admins",
+ }),
+);
+
+export const rebacRelationFactory = Factory.define(() => ({
+ errors: null,
+ loaded: false,
+ loading: false,
+ tuple: relationshipTupleFactory.build(),
+}));
+
export const jujuStateFactory = Factory.define(() => ({
auditEvents: auditEventsStateFactory.build(),
crossModelQuery: crossModelQueryStateFactory.build(),
@@ -243,6 +261,7 @@ export const jujuStateFactory = Factory.define(() => ({
modelFeatures: {},
modelWatcherData: {},
charms: [],
+ rebacRelations: [],
secrets: {},
selectedApplications: [],
}));