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: [], }));