Skip to content

Commit 623fc5b

Browse files
authored
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
1 parent ca38114 commit 623fc5b

File tree

11 files changed

+211
-21
lines changed

11 files changed

+211
-21
lines changed

site/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"@material-ui/lab": "4.0.0-alpha.42",
3636
"@testing-library/react-hooks": "8.0.1",
3737
"@xstate/inspect": "0.6.5",
38-
"@xstate/react": "3.0.0",
38+
"@xstate/react": "3.0.1",
3939
"axios": "0.26.1",
4040
"can-ndjson-stream": "1.0.2",
4141
"cron-parser": "4.5.0",

site/src/AppRouter.tsx

+13-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { useSelector } from "@xstate/react"
2+
import { FeatureNames } from "api/types"
3+
import { RequirePermission } from "components/RequirePermission/RequirePermission"
24
import { SetupPage } from "pages/SetupPage/SetupPage"
35
import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateSettingsPage"
46
import { FC, lazy, Suspense, useContext } from "react"
57
import { Navigate, Route, Routes } from "react-router-dom"
68
import { selectPermissions } from "xServices/auth/authSelectors"
9+
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
710
import { XServiceContext } from "xServices/StateContext"
811
import { AuthAndFrame } from "./components/AuthAndFrame/AuthAndFrame"
912
import { RequireAuth } from "./components/RequireAuth/RequireAuth"
@@ -35,6 +38,8 @@ const AuditPage = lazy(() => import("./pages/AuditPage/AuditPage"))
3538
export const AppRouter: FC = () => {
3639
const xServices = useContext(XServiceContext)
3740
const permissions = useSelector(xServices.authXService, selectPermissions)
41+
const featureVisibility = useSelector(xServices.entitlementsXService, selectFeatureVisibility)
42+
3843
return (
3944
<Suspense fallback={<></>}>
4045
<Routes>
@@ -134,11 +139,17 @@ export const AppRouter: FC = () => {
134139
<Route
135140
index
136141
element={
137-
process.env.NODE_ENV === "production" || !permissions?.viewAuditLog ? (
142+
process.env.NODE_ENV === "production" ? (
138143
<Navigate to="/workspaces" />
139144
) : (
140145
<AuthAndFrame>
141-
<AuditPage />
146+
<RequirePermission
147+
isFeatureVisible={
148+
featureVisibility[FeatureNames.AuditLog] && !!permissions?.viewAuditLog
149+
}
150+
>
151+
<AuditPage />
152+
</RequirePermission>
142153
</AuthAndFrame>
143154
)
144155
}

site/src/api/types.ts

+6
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,9 @@ export interface ReconnectingPTYRequest {
1414
export type WorkspaceBuildTransition = "start" | "stop" | "delete"
1515

1616
export type Message = { message: string }
17+
18+
// Keep up to date with coder/codersdk/features.go
19+
export enum FeatureNames {
20+
AuditLog = "audit_log",
21+
UserLimit = "user_limit",
22+
}
+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { render, screen, waitFor } from "@testing-library/react"
2+
import { App } from "app"
3+
import { Language } from "components/NavbarView/NavbarView"
4+
import { rest } from "msw"
5+
import {
6+
MockEntitlementsWithAuditLog,
7+
MockMemberPermissions,
8+
MockUser,
9+
} from "testHelpers/renderHelpers"
10+
import { server } from "testHelpers/server"
11+
12+
/**
13+
* The LicenseBanner, mounted above the AppRouter, fetches entitlements. Thus, to test their
14+
* effects, we must test at the App level and `waitFor` the fetch to be done.
15+
*/
16+
describe("Navbar", () => {
17+
it("shows Audit Log link when permitted and entitled", async () => {
18+
// set entitlements to allow audit log
19+
server.use(
20+
rest.get("/api/v2/entitlements", (req, res, ctx) => {
21+
return res(ctx.status(200), ctx.json(MockEntitlementsWithAuditLog))
22+
}),
23+
)
24+
render(<App />)
25+
await waitFor(
26+
() => {
27+
const link = screen.getByText(Language.audit)
28+
expect(link).toBeDefined()
29+
},
30+
{ timeout: 2000 },
31+
)
32+
})
33+
34+
it("does not show Audit Log link when not entitled", async () => {
35+
// by default, user is an Admin with permission to see the audit log,
36+
// but is unlicensed so not entitled to see the audit log
37+
render(<App />)
38+
await waitFor(
39+
() => {
40+
const link = screen.queryByText(Language.audit)
41+
expect(link).toBe(null)
42+
},
43+
{ timeout: 2000 },
44+
)
45+
})
46+
47+
it("does not show Audit Log link when not permitted via role", async () => {
48+
// set permissions to Member (can't audit)
49+
server.use(
50+
rest.post(`/api/v2/users/${MockUser.id}/authorization`, async (req, res, ctx) => {
51+
return res(ctx.status(200), ctx.json(MockMemberPermissions))
52+
}),
53+
)
54+
// set entitlements to allow audit log
55+
server.use(
56+
rest.get("/api/v2/entitlements", (req, res, ctx) => {
57+
return res(ctx.status(200), ctx.json(MockEntitlementsWithAuditLog))
58+
}),
59+
)
60+
render(<App />)
61+
await waitFor(
62+
() => {
63+
const link = screen.queryByText(Language.audit)
64+
expect(link).toBe(null)
65+
},
66+
{ timeout: 2000 },
67+
)
68+
})
69+
})

site/src/components/Navbar/Navbar.tsx

+10-8
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
1-
import { useActor } from "@xstate/react"
1+
import { shallowEqual, useActor, useSelector } from "@xstate/react"
2+
import { FeatureNames } from "api/types"
23
import React, { useContext } from "react"
4+
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
35
import { XServiceContext } from "../../xServices/StateContext"
46
import { NavbarView } from "../NavbarView/NavbarView"
57

68
export const Navbar: React.FC = () => {
79
const xServices = useContext(XServiceContext)
810
const [authState, authSend] = useActor(xServices.authXService)
911
const { me, permissions } = authState.context
12+
const featureVisibility = useSelector(
13+
xServices.entitlementsXService,
14+
selectFeatureVisibility,
15+
shallowEqual,
16+
)
17+
const canViewAuditLog = featureVisibility[FeatureNames.AuditLog] && !!permissions?.viewAuditLog
1018
const onSignOut = () => authSend("SIGN_OUT")
1119

12-
return (
13-
<NavbarView
14-
user={me}
15-
onSignOut={onSignOut}
16-
canViewAuditLog={permissions?.viewAuditLog ?? false}
17-
/>
18-
)
20+
return <NavbarView user={me} onSignOut={onSignOut} canViewAuditLog={canViewAuditLog} />
1921
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { FC } from "react"
2+
import { Navigate } from "react-router"
3+
4+
export interface RequirePermissionProps {
5+
children: JSX.Element
6+
isFeatureVisible: boolean
7+
}
8+
9+
/**
10+
* Wraps routes that are available based on RBAC or licensing.
11+
*/
12+
export const RequirePermission: FC<RequirePermissionProps> = ({ children, isFeatureVisible }) => {
13+
if (!isFeatureVisible) {
14+
return <Navigate to="/workspaces" />
15+
} else {
16+
return children
17+
}
18+
}

site/src/testHelpers/entities.ts

+21-2
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ export function assignableRole(role: TypesGen.Role, assignable: boolean): TypesG
5151
}
5252
}
5353

54+
export const MockMemberPermissions = {
55+
viewAuditLog: false,
56+
}
57+
5458
export const MockUser: TypesGen.User = {
5559
id: "test-user",
5660
username: "TestUser",
@@ -657,11 +661,26 @@ export const MockEntitlementsWithWarnings: TypesGen.Entitlements = {
657661
warnings: ["You are over your active user limit.", "And another thing."],
658662
has_license: true,
659663
features: {
660-
activeUsers: {
664+
user_limit: {
661665
enabled: true,
662-
entitlement: "entitled",
666+
entitlement: "grace_period",
663667
limit: 100,
664668
actual: 102,
665669
},
670+
audit_log: {
671+
enabled: true,
672+
entitlement: "entitled",
673+
},
674+
},
675+
}
676+
677+
export const MockEntitlementsWithAuditLog: TypesGen.Entitlements = {
678+
warnings: [],
679+
has_license: true,
680+
features: {
681+
audit_log: {
682+
enabled: true,
683+
entitlement: "entitled",
684+
},
666685
},
667686
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { getFeatureVisibility } from "./entitlementsSelectors"
2+
3+
describe("getFeatureVisibility", () => {
4+
it("returns empty object if there is no license", () => {
5+
const result = getFeatureVisibility(false, {
6+
audit_log: { entitlement: "entitled", enabled: true },
7+
})
8+
expect(result).toEqual(expect.objectContaining({}))
9+
})
10+
it("returns false for a feature that is not enabled", () => {
11+
const result = getFeatureVisibility(true, {
12+
audit_log: { entitlement: "entitled", enabled: false },
13+
})
14+
expect(result).toEqual(expect.objectContaining({ audit_log: false }))
15+
})
16+
it("returns false for a feature that is not entitled", () => {
17+
const result = getFeatureVisibility(true, {
18+
audit_log: { entitlement: "not_entitled", enabled: true },
19+
})
20+
expect(result).toEqual(expect.objectContaining({ audit_log: false }))
21+
})
22+
it("returns true for a feature that is in grace period", () => {
23+
const result = getFeatureVisibility(true, {
24+
audit_log: { entitlement: "grace_period", enabled: true },
25+
})
26+
expect(result).toEqual(expect.objectContaining({ audit_log: true }))
27+
})
28+
it("returns true for a feature that is in entitled", () => {
29+
const result = getFeatureVisibility(true, {
30+
audit_log: { entitlement: "entitled", enabled: true },
31+
})
32+
expect(result).toEqual(expect.objectContaining({ audit_log: true }))
33+
})
34+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Feature } from "api/typesGenerated"
2+
import { State } from "xstate"
3+
import { EntitlementsContext, EntitlementsEvent } from "./entitlementsXService"
4+
5+
type EntitlementState = State<EntitlementsContext, EntitlementsEvent>
6+
7+
/**
8+
* @param hasLicense true if Enterprise edition
9+
* @param features record from feature name to feature object
10+
* @returns record from feature name whether to show the feature
11+
*/
12+
export const getFeatureVisibility = (
13+
hasLicense: boolean,
14+
features: Record<string, Feature>,
15+
): Record<string, boolean> => {
16+
if (hasLicense) {
17+
const permissionPairs = Object.keys(features).map((feature) => {
18+
const { entitlement, limit, actual, enabled } = features[feature]
19+
const entitled = ["entitled", "grace_period"].includes(entitlement)
20+
const limitCompliant = limit && actual ? limit >= actual : true
21+
return [feature, entitled && limitCompliant && enabled]
22+
})
23+
return Object.fromEntries(permissionPairs)
24+
} else {
25+
return {}
26+
}
27+
}
28+
29+
export const selectFeatureVisibility = (state: EntitlementState): Record<string, boolean> => {
30+
return getFeatureVisibility(
31+
state.context.entitlements.has_license,
32+
state.context.entitlements.features,
33+
)
34+
}

site/src/xServices/entitlements/entitlementsXService.ts

+1-4
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const entitlementsMachine = createMachine(
4747
on: {
4848
GET_ENTITLEMENTS: "gettingEntitlements",
4949
SHOW_MOCK_BANNER: { actions: "assignMockEntitlements" },
50-
HIDE_MOCK_BANNER: { actions: "clearMockEntitlements" },
50+
HIDE_MOCK_BANNER: "gettingEntitlements",
5151
},
5252
},
5353
gettingEntitlements: {
@@ -81,9 +81,6 @@ export const entitlementsMachine = createMachine(
8181
assignMockEntitlements: assign({
8282
entitlements: (_) => MockEntitlementsWithWarnings,
8383
}),
84-
clearMockEntitlements: assign({
85-
entitlements: (_) => emptyEntitlements,
86-
}),
8784
},
8885
services: {
8986
getEntitlements: () => API.getEntitlements(),

site/yarn.lock

+4-4
Original file line numberDiff line numberDiff line change
@@ -3896,10 +3896,10 @@
38963896
resolved "https://registry.yarnpkg.com/@xstate/machine-extractor/-/machine-extractor-0.7.0.tgz#3f46a3686462f309ee208a97f6285e7d6a55cdb8"
38973897
integrity sha512-dXHI/sWWWouN/yG687ZuRCP7Cm6XggFWSK1qWj3NohBTyhaYWSR7ojwP6OUK6e1cbiJqxmM9EDnE2Auf+Xlp+A==
38983898

3899-
"@xstate/react@3.0.0":
3900-
version "3.0.0"
3901-
resolved "https://registry.yarnpkg.com/@xstate/react/-/react-3.0.0.tgz#888d9a6f128c70b632c18ad55f1f851f6ab092ba"
3902-
integrity sha512-KHSCfwtb8gZ7QH2luihvmKYI+0lcdHQOmGNRUxUEs4zVgaJCyd8csCEmwPsudpliLdUmyxX2pzUBojFkINpotw==
3899+
"@xstate/react@3.0.1":
3900+
version "3.0.1"
3901+
resolved "https://registry.yarnpkg.com/@xstate/react/-/react-3.0.1.tgz#937eeb5d5d61734ab756ca40146f84a6fe977095"
3902+
integrity sha512-/tq/gg92P9ke8J+yDNDBv5/PAxBvXJf2cYyGDByzgtl5wKaxKxzDT82Gj3eWlCJXkrBg4J5/V47//gRJuVH2fA==
39033903
dependencies:
39043904
use-isomorphic-layout-effect "^1.0.0"
39053905
use-sync-external-store "^1.0.0"

0 commit comments

Comments
 (0)