mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
+1
-1
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user