Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat: condition Audit log on licensing #3685

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Aug 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What was the testing issue this fixed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getSnapshot warning/crash and the shallowEqual util for avoiding it. https://newreleases.io/project/github/statelyai/xstate/release/@xstate%[email protected]

"axios": "0.26.1",
"can-ndjson-stream": "1.0.2",
"cron-parser": "4.5.0",
Expand Down
15 changes: 13 additions & 2 deletions site/src/AppRouter.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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>
)
}
Expand Down
6 changes: 6 additions & 0 deletions site/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this something that can be autogenerated?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is there's some reason why we can't get enums in our generated types.

export enum FeatureNames {
AuditLog = "audit_log",
UserLimit = "user_limit",
}
69 changes: 69 additions & 0 deletions site/src/components/Navbar/Navbar.test.tsx
Original file line number Diff line number Diff line change
@@ -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 () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading this code, how does one know Audit Log link is not entitled? Is it due to the lack of the mocked request or something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the default handler in handlers.ts returns empty license data, as if you're an OSS user.

// 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 () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same with this test. How does one know that a member is not permitted? I see MockMemberPermissions but hard to tell from code what role is being given or why they don't have permission.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Member role does not have auditing permissions.

// 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 },
)
})
})
18 changes: 10 additions & 8 deletions site/src/components/Navbar/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
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"

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} />
}
18 changes: 18 additions & 0 deletions site/src/components/RequirePermission/RequirePermission.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { FC } from "react"
import { Navigate } from "react-router"

export interface RequirePermissionProps {
children: JSX.Element
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would PropsWithChildren help here? reference

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried using it here and got a TS error, not sure why!

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
}
}
23 changes: 21 additions & 2 deletions site/src/testHelpers/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export function assignableRole(role: TypesGen.Role, assignable: boolean): TypesG
}
}

export const MockMemberPermissions = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be helpful to convey permissions in name i.e. MockMemberWithoutAuditLogPermissions

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added comments, does that help?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it does! Thank you!

viewAuditLog: false,
}

export const MockUser: TypesGen.User = {
id: "test-user",
username: "TestUser",
Expand Down Expand Up @@ -647,11 +651,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",
},
},
}
34 changes: 34 additions & 0 deletions site/src/xServices/entitlements/entitlementsSelectors.test.ts
Original file line number Diff line number Diff line change
@@ -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 }))
})
})
34 changes: 34 additions & 0 deletions site/src/xServices/entitlements/entitlementsSelectors.ts
Original file line number Diff line number Diff line change
@@ -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,
)
}
5 changes: 1 addition & 4 deletions site/src/xServices/entitlements/entitlementsXService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -81,9 +81,6 @@ export const entitlementsMachine = createMachine(
assignMockEntitlements: assign({
entitlements: (_) => MockEntitlementsWithWarnings,
}),
clearMockEntitlements: assign({
entitlements: (_) => emptyEntitlements,
}),
},
services: {
getEntitlements: () => API.getEntitlements(),
Expand Down
8 changes: 4 additions & 4 deletions site/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected].0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@xstate/react/-/react-3.0.0.tgz#888d9a6f128c70b632c18ad55f1f851f6ab092ba"
integrity sha512-KHSCfwtb8gZ7QH2luihvmKYI+0lcdHQOmGNRUxUEs4zVgaJCyd8csCEmwPsudpliLdUmyxX2pzUBojFkINpotw==
"@xstate/[email protected].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"
Expand Down