feat: condition Audit log on licensing (#3685)

* Update XService

* Add simple wrapper

* Add selector

* Condition page

* Condition link

* Format and lint

* Integration test

* Add username to api call

* Format

* Format

* Fix link name

* Upgrade xstate/react to fix crashing tests

* Fix tests

* Format

* Abstract strings

* Debug test

* Increase timeout

* Add comments and try shorter timeout

* Use PropsWithChildren

* Undo PropsWithChildren, try lower timeout

* Format, lower timeout
This commit is contained in:
Presley Pizzo
2022-08-25 19:20:31 -04:00
committed by GitHub
parent ca3811499e
commit 623fc5baac
11 changed files with 211 additions and 21 deletions
+1 -1
View File
@@ -35,7 +35,7 @@
"@material-ui/lab": "4.0.0-alpha.42",
"@testing-library/react-hooks": "8.0.1",
"@xstate/inspect": "0.6.5",
"@xstate/react": "3.0.0",
"@xstate/react": "3.0.1",
"axios": "0.26.1",
"can-ndjson-stream": "1.0.2",
"cron-parser": "4.5.0",
+13 -2
View File
@@ -1,9 +1,12 @@
import { useSelector } from "@xstate/react"
import { FeatureNames } from "api/types"
import { RequirePermission } from "components/RequirePermission/RequirePermission"
import { SetupPage } from "pages/SetupPage/SetupPage"
import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateSettingsPage"
import { FC, lazy, Suspense, useContext } from "react"
import { Navigate, Route, Routes } from "react-router-dom"
import { selectPermissions } from "xServices/auth/authSelectors"
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
import { XServiceContext } from "xServices/StateContext"
import { AuthAndFrame } from "./components/AuthAndFrame/AuthAndFrame"
import { RequireAuth } from "./components/RequireAuth/RequireAuth"
@@ -35,6 +38,8 @@ const AuditPage = lazy(() => import("./pages/AuditPage/AuditPage"))
export const AppRouter: FC = () => {
const xServices = useContext(XServiceContext)
const permissions = useSelector(xServices.authXService, selectPermissions)
const featureVisibility = useSelector(xServices.entitlementsXService, selectFeatureVisibility)
return (
<Suspense fallback={<></>}>
<Routes>
@@ -134,11 +139,17 @@ export const AppRouter: FC = () => {
<Route
index
element={
process.env.NODE_ENV === "production" || !permissions?.viewAuditLog ? (
process.env.NODE_ENV === "production" ? (
<Navigate to="/workspaces" />
) : (
<AuthAndFrame>
<AuditPage />
<RequirePermission
isFeatureVisible={
featureVisibility[FeatureNames.AuditLog] && !!permissions?.viewAuditLog
}
>
<AuditPage />
</RequirePermission>
</AuthAndFrame>
)
}
+6
View File
@@ -14,3 +14,9 @@ export interface ReconnectingPTYRequest {
export type WorkspaceBuildTransition = "start" | "stop" | "delete"
export type Message = { message: string }
// Keep up to date with coder/codersdk/features.go
export enum FeatureNames {
AuditLog = "audit_log",
UserLimit = "user_limit",
}
@@ -0,0 +1,69 @@
import { render, screen, waitFor } from "@testing-library/react"
import { App } from "app"
import { Language } from "components/NavbarView/NavbarView"
import { rest } from "msw"
import {
MockEntitlementsWithAuditLog,
MockMemberPermissions,
MockUser,
} from "testHelpers/renderHelpers"
import { server } from "testHelpers/server"
/**
* The LicenseBanner, mounted above the AppRouter, fetches entitlements. Thus, to test their
* effects, we must test at the App level and `waitFor` the fetch to be done.
*/
describe("Navbar", () => {
it("shows Audit Log link when permitted and entitled", async () => {
// set entitlements to allow audit log
server.use(
rest.get("/api/v2/entitlements", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockEntitlementsWithAuditLog))
}),
)
render(<App />)
await waitFor(
() => {
const link = screen.getByText(Language.audit)
expect(link).toBeDefined()
},
{ timeout: 2000 },
)
})
it("does not show Audit Log link when not entitled", async () => {
// by default, user is an Admin with permission to see the audit log,
// but is unlicensed so not entitled to see the audit log
render(<App />)
await waitFor(
() => {
const link = screen.queryByText(Language.audit)
expect(link).toBe(null)
},
{ timeout: 2000 },
)
})
it("does not show Audit Log link when not permitted via role", async () => {
// set permissions to Member (can't audit)
server.use(
rest.post(`/api/v2/users/${MockUser.id}/authorization`, async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockMemberPermissions))
}),
)
// set entitlements to allow audit log
server.use(
rest.get("/api/v2/entitlements", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockEntitlementsWithAuditLog))
}),
)
render(<App />)
await waitFor(
() => {
const link = screen.queryByText(Language.audit)
expect(link).toBe(null)
},
{ timeout: 2000 },
)
})
})
+10 -8
View File
@@ -1,5 +1,7 @@
import { useActor } from "@xstate/react"
import { shallowEqual, useActor, useSelector } from "@xstate/react"
import { FeatureNames } from "api/types"
import React, { useContext } from "react"
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
import { XServiceContext } from "../../xServices/StateContext"
import { NavbarView } from "../NavbarView/NavbarView"
@@ -7,13 +9,13 @@ export const Navbar: React.FC = () => {
const xServices = useContext(XServiceContext)
const [authState, authSend] = useActor(xServices.authXService)
const { me, permissions } = authState.context
const featureVisibility = useSelector(
xServices.entitlementsXService,
selectFeatureVisibility,
shallowEqual,
)
const canViewAuditLog = featureVisibility[FeatureNames.AuditLog] && !!permissions?.viewAuditLog
const onSignOut = () => authSend("SIGN_OUT")
return (
<NavbarView
user={me}
onSignOut={onSignOut}
canViewAuditLog={permissions?.viewAuditLog ?? false}
/>
)
return <NavbarView user={me} onSignOut={onSignOut} canViewAuditLog={canViewAuditLog} />
}
@@ -0,0 +1,18 @@
import { FC } from "react"
import { Navigate } from "react-router"
export interface RequirePermissionProps {
children: JSX.Element
isFeatureVisible: boolean
}
/**
* Wraps routes that are available based on RBAC or licensing.
*/
export const RequirePermission: FC<RequirePermissionProps> = ({ children, isFeatureVisible }) => {
if (!isFeatureVisible) {
return <Navigate to="/workspaces" />
} else {
return children
}
}
+21 -2
View File
@@ -51,6 +51,10 @@ export function assignableRole(role: TypesGen.Role, assignable: boolean): TypesG
}
}
export const MockMemberPermissions = {
viewAuditLog: false,
}
export const MockUser: TypesGen.User = {
id: "test-user",
username: "TestUser",
@@ -657,11 +661,26 @@ export const MockEntitlementsWithWarnings: TypesGen.Entitlements = {
warnings: ["You are over your active user limit.", "And another thing."],
has_license: true,
features: {
activeUsers: {
user_limit: {
enabled: true,
entitlement: "entitled",
entitlement: "grace_period",
limit: 100,
actual: 102,
},
audit_log: {
enabled: true,
entitlement: "entitled",
},
},
}
export const MockEntitlementsWithAuditLog: TypesGen.Entitlements = {
warnings: [],
has_license: true,
features: {
audit_log: {
enabled: true,
entitlement: "entitled",
},
},
}
@@ -0,0 +1,34 @@
import { getFeatureVisibility } from "./entitlementsSelectors"
describe("getFeatureVisibility", () => {
it("returns empty object if there is no license", () => {
const result = getFeatureVisibility(false, {
audit_log: { entitlement: "entitled", enabled: true },
})
expect(result).toEqual(expect.objectContaining({}))
})
it("returns false for a feature that is not enabled", () => {
const result = getFeatureVisibility(true, {
audit_log: { entitlement: "entitled", enabled: false },
})
expect(result).toEqual(expect.objectContaining({ audit_log: false }))
})
it("returns false for a feature that is not entitled", () => {
const result = getFeatureVisibility(true, {
audit_log: { entitlement: "not_entitled", enabled: true },
})
expect(result).toEqual(expect.objectContaining({ audit_log: false }))
})
it("returns true for a feature that is in grace period", () => {
const result = getFeatureVisibility(true, {
audit_log: { entitlement: "grace_period", enabled: true },
})
expect(result).toEqual(expect.objectContaining({ audit_log: true }))
})
it("returns true for a feature that is in entitled", () => {
const result = getFeatureVisibility(true, {
audit_log: { entitlement: "entitled", enabled: true },
})
expect(result).toEqual(expect.objectContaining({ audit_log: true }))
})
})
@@ -0,0 +1,34 @@
import { Feature } from "api/typesGenerated"
import { State } from "xstate"
import { EntitlementsContext, EntitlementsEvent } from "./entitlementsXService"
type EntitlementState = State<EntitlementsContext, EntitlementsEvent>
/**
* @param hasLicense true if Enterprise edition
* @param features record from feature name to feature object
* @returns record from feature name whether to show the feature
*/
export const getFeatureVisibility = (
hasLicense: boolean,
features: Record<string, Feature>,
): Record<string, boolean> => {
if (hasLicense) {
const permissionPairs = Object.keys(features).map((feature) => {
const { entitlement, limit, actual, enabled } = features[feature]
const entitled = ["entitled", "grace_period"].includes(entitlement)
const limitCompliant = limit && actual ? limit >= actual : true
return [feature, entitled && limitCompliant && enabled]
})
return Object.fromEntries(permissionPairs)
} else {
return {}
}
}
export const selectFeatureVisibility = (state: EntitlementState): Record<string, boolean> => {
return getFeatureVisibility(
state.context.entitlements.has_license,
state.context.entitlements.features,
)
}
@@ -47,7 +47,7 @@ export const entitlementsMachine = createMachine(
on: {
GET_ENTITLEMENTS: "gettingEntitlements",
SHOW_MOCK_BANNER: { actions: "assignMockEntitlements" },
HIDE_MOCK_BANNER: { actions: "clearMockEntitlements" },
HIDE_MOCK_BANNER: "gettingEntitlements",
},
},
gettingEntitlements: {
@@ -81,9 +81,6 @@ export const entitlementsMachine = createMachine(
assignMockEntitlements: assign({
entitlements: (_) => MockEntitlementsWithWarnings,
}),
clearMockEntitlements: assign({
entitlements: (_) => emptyEntitlements,
}),
},
services: {
getEntitlements: () => API.getEntitlements(),
+4 -4
View File
@@ -3896,10 +3896,10 @@
resolved "https://registry.yarnpkg.com/@xstate/machine-extractor/-/machine-extractor-0.7.0.tgz#3f46a3686462f309ee208a97f6285e7d6a55cdb8"
integrity sha512-dXHI/sWWWouN/yG687ZuRCP7Cm6XggFWSK1qWj3NohBTyhaYWSR7ojwP6OUK6e1cbiJqxmM9EDnE2Auf+Xlp+A==
"@xstate/react@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@xstate/react/-/react-3.0.0.tgz#888d9a6f128c70b632c18ad55f1f851f6ab092ba"
integrity sha512-KHSCfwtb8gZ7QH2luihvmKYI+0lcdHQOmGNRUxUEs4zVgaJCyd8csCEmwPsudpliLdUmyxX2pzUBojFkINpotw==
"@xstate/react@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@xstate/react/-/react-3.0.1.tgz#937eeb5d5d61734ab756ca40146f84a6fe977095"
integrity sha512-/tq/gg92P9ke8J+yDNDBv5/PAxBvXJf2cYyGDByzgtl5wKaxKxzDT82Gj3eWlCJXkrBg4J5/V47//gRJuVH2fA==
dependencies:
use-isomorphic-layout-effect "^1.0.0"
use-sync-external-store "^1.0.0"