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,167 +30,172 @@ 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 = () => {
<Suspense fallback={<></>}> const xServices = useContext(XServiceContext)
<Routes> const permissions = useSelector(xServices.authXService, selectPermissions)
<Route
index
element={
<RequireAuth>
<IndexPage />
</RequireAuth>
}
/>
<Route path="login" element={<LoginPage />} /> return (
<Route path="healthz" element={<HealthzPage />} /> <Suspense fallback={<></>}>
<Route <Routes>
path="cli-auth"
element={
<RequireAuth>
<CliAuthenticationPage />
</RequireAuth>
}
/>
<Route path="workspaces">
<Route <Route
index index
element={ element={
<AuthAndFrame> <RequireAuth>
<WorkspacesPage /> <IndexPage />
</AuthAndFrame> </RequireAuth>
} }
/> />
</Route>
<Route path="templates"> <Route path="login" element={<LoginPage />} />
<Route path="healthz" element={<HealthzPage />} />
<Route <Route
index path="cli-auth"
element={ element={
<AuthAndFrame> <RequireAuth>
<TemplatesPage /> <CliAuthenticationPage />
</AuthAndFrame> </RequireAuth>
} }
/> />
<Route path=":template"> <Route path="workspaces">
<Route <Route
index index
element={ element={
<AuthAndFrame> <AuthAndFrame>
<TemplatePage /> <WorkspacesPage />
</AuthAndFrame>
}
/>
</Route>
<Route path="templates">
<Route
index
element={
<AuthAndFrame>
<TemplatesPage />
</AuthAndFrame>
}
/>
<Route path=":template">
<Route
index
element={
<AuthAndFrame>
<TemplatePage />
</AuthAndFrame>
}
/>
<Route
path="workspace"
element={
<RequireAuth>
<CreateWorkspacePage />
</RequireAuth>
}
/>
</Route>
</Route>
<Route path="users">
<Route
index
element={
<AuthAndFrame>
<UsersPage />
</AuthAndFrame> </AuthAndFrame>
} }
/> />
<Route <Route
path="workspace" path="create"
element={ element={
<RequireAuth> <RequireAuth>
<CreateWorkspacePage /> <CreateUserPage />
</RequireAuth> </RequireAuth>
} }
/> />
</Route> </Route>
</Route>
<Route path="users"> {/* REMARK: Route under construction
<Route Eventually, we should gate this page
index with permissions and licensing */}
element={ <Route path="/audit">
<AuthAndFrame>
<UsersPage />
</AuthAndFrame>
}
/>
<Route
path="create"
element={
<RequireAuth>
<CreateUserPage />
</RequireAuth>
}
/>
</Route>
{/* REMARK: Route under construction
Eventually, we should gate this page
with permissions and licensing */}
<Route path="/audit">
<Route
index
element={
process.env.NODE_ENV === "production" ? (
<Navigate to="/workspaces" />
) : (
<AuthAndFrame>
<AuditPage />
</AuthAndFrame>
)
}
></Route>
</Route>
<Route path="settings" element={<SettingsLayout />}>
<Route path="account" element={<AccountPage />} />
<Route path="security" element={<SecurityPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
</Route>
<Route path="/@:username">
<Route path=":workspace">
<Route <Route
index index
element={ element={
<AuthAndFrame> process.env.NODE_ENV === "production" || !permissions?.viewAuditLog ? (
<WorkspacePage /> <Navigate to="/workspaces" />
</AuthAndFrame> ) : (
<AuthAndFrame>
<AuditPage />
</AuthAndFrame>
)
} }
/> ></Route>
<Route </Route>
path="schedule"
element={
<RequireAuth>
<WorkspaceSchedulePage />
</RequireAuth>
}
/>
<Route <Route path="settings" element={<SettingsLayout />}>
path="terminal" <Route path="account" element={<AccountPage />} />
element={ <Route path="security" element={<SecurityPage />} />
<RequireAuth> <Route path="ssh-keys" element={<SSHKeysPage />} />
<TerminalPage /> </Route>
</RequireAuth>
}
/>
<Route path="apps"> <Route path="/@:username">
<Route path=":workspace">
<Route <Route
path=":app/*" index
element={ element={
<AuthAndFrame> <AuthAndFrame>
<WorkspaceAppErrorPage /> <WorkspacePage />
</AuthAndFrame>
}
/>
<Route
path="schedule"
element={
<RequireAuth>
<WorkspaceSchedulePage />
</RequireAuth>
}
/>
<Route
path="terminal"
element={
<RequireAuth>
<TerminalPage />
</RequireAuth>
}
/>
<Route path="apps">
<Route
path=":app/*"
element={
<AuthAndFrame>
<WorkspaceAppErrorPage />
</AuthAndFrame>
}
/>
</Route>
<Route
path="builds/:buildNumber"
element={
<AuthAndFrame>
<WorkspaceBuildPage />
</AuthAndFrame> </AuthAndFrame>
} }
/> />
</Route> </Route>
<Route
path="builds/:buildNumber"
element={
<AuthAndFrame>
<WorkspaceBuildPage />
</AuthAndFrame>
}
/>
</Route> </Route>
</Route>
{/* Using path="*"" means "match anything", so this route {/* Using path="*"" means "match anything", so this route
acts like a catch-all for URLs that we don't have explicit acts like a catch-all for URLs that we don't have explicit
routes for. */} routes for. */}
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</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>