-
Notifications
You must be signed in to change notification settings - Fork 881
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
Changes from all commits
aced91f
49f5652
a641881
4e79b2d
6dfe4df
17d8d1b
8fdc834
3627ef3
8b88265
4fea785
733954b
fc237a0
cb20ccd
75f7f6e
2e94269
3c54e1e
4d40c30
a26786e
a7d836b
4f975b8
9505faa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this something that can be autogenerated? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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", | ||
} |
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 () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, the default handler in |
||
// 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 () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
// 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 }, | ||
) | ||
}) | ||
}) |
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} /> | ||
} |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would There was a problem hiding this comment. Choose a reason for hiding this commentThe 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! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Up to you though: just a suggestion |
||
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 | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -51,6 +51,10 @@ export function assignableRole(role: TypesGen.Role, assignable: boolean): TypesG | |
} | ||
} | ||
|
||
export const MockMemberPermissions = { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might be helpful to convey permissions in name i.e. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added comments, does that help? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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", | ||
|
@@ -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", | ||
}, | ||
}, | ||
} |
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 })) | ||
}) | ||
}) |
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, | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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" | ||
|
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 theshallowEqual
util for avoiding it. https://newreleases.io/project/github/statelyai/xstate/release/@xstate%[email protected]