feature: gate audit log by permissions (#3464)

* pairing

* restricting audit route

resolvees #3460

* updated tests

* fixing lint

* useSelector instead of useActor
This commit is contained in:
Kira Pilot
2022-08-11 09:34:45 -04:00
committed by GitHub
parent 4e6645af50
commit 6122df6f1f
7 changed files with 174 additions and 136 deletions

View File

@ -88,6 +88,7 @@ var (
// Should be able to read all template details, even in orgs they // Should be able to read all template details, even in orgs they
// are not in. // are not in.
ResourceTemplate: {ActionRead}, ResourceTemplate: {ActionRead},
ResourceAuditLog: {ActionRead},
}), }),
} }
}, },

View File

@ -22,6 +22,12 @@ var (
Type: "workspace", Type: "workspace",
} }
// ResourceAuditLog
// read = access audit log
ResourceAuditLog = Object{
Type: "audit_log",
}
// ResourceTemplate CRUD. Org owner only. // ResourceTemplate CRUD. Org owner only.
// create/delete = Make or delete a new template // create/delete = Make or delete a new template
// update = Update the template, make new template versions // update = Update the template, make new template versions

View File

@ -1,5 +1,8 @@
import { FC, lazy, Suspense } from "react" import { useSelector } from "@xstate/react"
import { FC, lazy, Suspense, useContext } from "react"
import { Navigate, Route, Routes } from "react-router-dom" import { Navigate, Route, Routes } from "react-router-dom"
import { selectPermissions } from "xServices/auth/authSelectors"
import { XServiceContext } from "xServices/StateContext"
import { AuthAndFrame } from "./components/AuthAndFrame/AuthAndFrame" import { AuthAndFrame } from "./components/AuthAndFrame/AuthAndFrame"
import { RequireAuth } from "./components/RequireAuth/RequireAuth" import { RequireAuth } from "./components/RequireAuth/RequireAuth"
import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout" import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout"
@ -27,7 +30,11 @@ const WorkspacesPage = lazy(() => import("./pages/WorkspacesPage/WorkspacesPage"
const CreateWorkspacePage = lazy(() => import("./pages/CreateWorkspacePage/CreateWorkspacePage")) const CreateWorkspacePage = lazy(() => import("./pages/CreateWorkspacePage/CreateWorkspacePage"))
const AuditPage = lazy(() => import("./pages/AuditPage/AuditPage")) const AuditPage = lazy(() => import("./pages/AuditPage/AuditPage"))
export const AppRouter: FC = () => ( export const AppRouter: FC = () => {
const xServices = useContext(XServiceContext)
const permissions = useSelector(xServices.authXService, selectPermissions)
return (
<Suspense fallback={<></>}> <Suspense fallback={<></>}>
<Routes> <Routes>
<Route <Route
@ -117,7 +124,7 @@ export const AppRouter: FC = () => (
<Route <Route
index index
element={ element={
process.env.NODE_ENV === "production" ? ( process.env.NODE_ENV === "production" || !permissions?.viewAuditLog ? (
<Navigate to="/workspaces" /> <Navigate to="/workspaces" />
) : ( ) : (
<AuthAndFrame> <AuthAndFrame>
@ -191,3 +198,4 @@ export const AppRouter: FC = () => (
</Routes> </Routes>
</Suspense> </Suspense>
) )
}

View File

@ -6,8 +6,14 @@ import { NavbarView } from "../NavbarView/NavbarView"
export const Navbar: React.FC = () => { export const Navbar: React.FC = () => {
const xServices = useContext(XServiceContext) const xServices = useContext(XServiceContext)
const [authState, authSend] = useActor(xServices.authXService) const [authState, authSend] = useActor(xServices.authXService)
const { me } = authState.context const { me, permissions } = authState.context
const onSignOut = () => authSend("SIGN_OUT") const onSignOut = () => authSend("SIGN_OUT")
return <NavbarView user={me} onSignOut={onSignOut} /> return (
<NavbarView
user={me}
onSignOut={onSignOut}
canViewAuditLog={permissions?.viewAuditLog ?? false}
/>
)
} }

View File

@ -1,5 +1,5 @@
import { screen } from "@testing-library/react" import { screen } from "@testing-library/react"
import { MockUser } from "../../testHelpers/entities" import { MockUser, MockUser2 } from "../../testHelpers/entities"
import { render } from "../../testHelpers/renderHelpers" import { render } from "../../testHelpers/renderHelpers"
import { Language as navLanguage, NavbarView } from "./NavbarView" import { Language as navLanguage, NavbarView } from "./NavbarView"
@ -22,26 +22,26 @@ describe("NavbarView", () => {
it("renders content", async () => { it("renders content", async () => {
// When // When
render(<NavbarView user={MockUser} onSignOut={noop} />) render(<NavbarView user={MockUser} onSignOut={noop} canViewAuditLog />)
// Then // Then
await screen.findAllByText("Coder", { exact: false }) await screen.findAllByText("Coder", { exact: false })
}) })
it("workspaces nav link has the correct href", async () => { it("workspaces nav link has the correct href", async () => {
render(<NavbarView user={MockUser} onSignOut={noop} />) render(<NavbarView user={MockUser} onSignOut={noop} canViewAuditLog />)
const workspacesLink = await screen.findByText(navLanguage.workspaces) const workspacesLink = await screen.findByText(navLanguage.workspaces)
expect((workspacesLink as HTMLAnchorElement).href).toContain("/workspaces") expect((workspacesLink as HTMLAnchorElement).href).toContain("/workspaces")
}) })
it("templates nav link has the correct href", async () => { it("templates nav link has the correct href", async () => {
render(<NavbarView user={MockUser} onSignOut={noop} />) render(<NavbarView user={MockUser} onSignOut={noop} canViewAuditLog />)
const templatesLink = await screen.findByText(navLanguage.templates) const templatesLink = await screen.findByText(navLanguage.templates)
expect((templatesLink as HTMLAnchorElement).href).toContain("/templates") expect((templatesLink as HTMLAnchorElement).href).toContain("/templates")
}) })
it("users nav link has the correct href", async () => { it("users nav link has the correct href", async () => {
render(<NavbarView user={MockUser} onSignOut={noop} />) render(<NavbarView user={MockUser} onSignOut={noop} canViewAuditLog />)
const userLink = await screen.findByText(navLanguage.users) const userLink = await screen.findByText(navLanguage.users)
expect((userLink as HTMLAnchorElement).href).toContain("/users") expect((userLink as HTMLAnchorElement).href).toContain("/users")
}) })
@ -54,7 +54,7 @@ describe("NavbarView", () => {
} }
// When // When
render(<NavbarView user={mockUser} onSignOut={noop} />) render(<NavbarView user={mockUser} onSignOut={noop} canViewAuditLog />)
// Then // Then
// There should be a 'B' avatar! // There should be a 'B' avatar!
@ -63,7 +63,7 @@ describe("NavbarView", () => {
}) })
it("audit nav link has the correct href", async () => { it("audit nav link has the correct href", async () => {
render(<NavbarView user={MockUser} onSignOut={noop} />) render(<NavbarView user={MockUser} onSignOut={noop} canViewAuditLog />)
const auditLink = await screen.findByText(navLanguage.audit) const auditLink = await screen.findByText(navLanguage.audit)
expect((auditLink as HTMLAnchorElement).href).toContain("/audit") expect((auditLink as HTMLAnchorElement).href).toContain("/audit")
}) })
@ -74,7 +74,13 @@ describe("NavbarView", () => {
NODE_ENV: "production", NODE_ENV: "production",
} }
render(<NavbarView user={MockUser} onSignOut={noop} />) render(<NavbarView user={MockUser} onSignOut={noop} canViewAuditLog />)
const auditLink = screen.queryByText(navLanguage.audit)
expect(auditLink).not.toBeInTheDocument()
})
it("audit nav link is hidden for members", async () => {
render(<NavbarView user={MockUser2} onSignOut={noop} canViewAuditLog={false} />)
const auditLink = screen.queryByText(navLanguage.audit) const auditLink = screen.queryByText(navLanguage.audit)
expect(auditLink).not.toBeInTheDocument() expect(auditLink).not.toBeInTheDocument()
}) })

View File

@ -15,6 +15,7 @@ import { UserDropdown } from "../UserDropdown/UsersDropdown"
export interface NavbarViewProps { export interface NavbarViewProps {
user?: TypesGen.User user?: TypesGen.User
onSignOut: () => void onSignOut: () => void
canViewAuditLog: boolean
} }
export const Language = { export const Language = {
@ -24,7 +25,10 @@ export const Language = {
audit: "Audit", audit: "Audit",
} }
const NavItems: React.FC<{ className?: string; linkClassName?: string }> = ({ className }) => { const NavItems: React.FC<{ className?: string; canViewAuditLog: boolean }> = ({
className,
canViewAuditLog,
}) => {
const styles = useStyles() const styles = useStyles()
const location = useLocation() const location = useLocation()
@ -49,7 +53,7 @@ const NavItems: React.FC<{ className?: string; linkClassName?: string }> = ({ cl
</NavLink> </NavLink>
</ListItem> </ListItem>
{/* REMARK: the below link is under-construction */} {/* REMARK: the below link is under-construction */}
{process.env.NODE_ENV !== "production" && ( {process.env.NODE_ENV !== "production" && canViewAuditLog && (
<ListItem button className={styles.item}> <ListItem button className={styles.item}>
<NavLink className={styles.link} to="/audit"> <NavLink className={styles.link} to="/audit">
{Language.audit} {Language.audit}
@ -60,7 +64,7 @@ const NavItems: React.FC<{ className?: string; linkClassName?: string }> = ({ cl
) )
} }
export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut }) => { export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut, canViewAuditLog }) => {
const styles = useStyles() const styles = useStyles()
const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [isDrawerOpen, setIsDrawerOpen] = useState(false)
@ -81,7 +85,7 @@ export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut }) => {
<div className={styles.drawerHeader}> <div className={styles.drawerHeader}>
<Logo fill="white" opacity={1} width={125} /> <Logo fill="white" opacity={1} width={125} />
</div> </div>
<NavItems /> <NavItems canViewAuditLog={canViewAuditLog} />
</div> </div>
</Drawer> </Drawer>
@ -89,7 +93,7 @@ export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut }) => {
<Logo fill="white" opacity={1} width={125} /> <Logo fill="white" opacity={1} width={125} />
</NavLink> </NavLink>
<NavItems className={styles.desktopNavItems} /> <NavItems className={styles.desktopNavItems} canViewAuditLog={canViewAuditLog} />
<div className={styles.profileButton}> <div className={styles.profileButton}>
{user && <UserDropdown user={user} onSignOut={onSignOut} />} {user && <UserDropdown user={user} onSignOut={onSignOut} />}

View File

@ -14,6 +14,7 @@ export const checks = {
updateUsers: "updateUsers", updateUsers: "updateUsers",
createUser: "createUser", createUser: "createUser",
createTemplates: "createTemplates", createTemplates: "createTemplates",
viewAuditLog: "viewAuditLog",
} as const } as const
export const permissionsToCheck = { export const permissionsToCheck = {
@ -41,6 +42,12 @@ export const permissionsToCheck = {
}, },
action: "write", action: "write",
}, },
[checks.viewAuditLog]: {
object: {
resource_type: "audit_log",
},
action: "read",
},
} as const } as const
type Permissions = Record<keyof typeof permissionsToCheck, boolean> type Permissions = Record<keyof typeof permissionsToCheck, boolean>