Skip to content
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

feat: client side access control checks #27635

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
46 changes: 46 additions & 0 deletions frontend/src/lib/components/AccessControlAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
type AccessControlLevelNone = 'none'
type AccessControlLevelMember = AccessControlLevelNone | 'member' | 'admin'
type AccessControlLevelResource = AccessControlLevelNone | 'viewer' | 'editor'
type AccessControlLevel = AccessControlLevelMember | AccessControlLevelResource

interface AccessControlActionProps {
children: (props: { disabled: boolean; disabledReason: string | null }) => React.ReactElement
userAccessLevel?: AccessControlLevel
minAccessLevel: AccessControlLevel
resourceType: string
}

const orderedAccessLevels = (resourceType: string): AccessControlLevel[] => {
if (resourceType === 'project' || resourceType === 'organization') {
return ['none', 'member', 'admin']
}
return ['none', 'viewer', 'editor']
}

const accessLevelSatisfied = (
resourceType: string,
currentLevel: AccessControlLevel,
requiredLevel: AccessControlLevel
): boolean => {
const levels = orderedAccessLevels(resourceType)
return levels.indexOf(currentLevel) >= levels.indexOf(requiredLevel)
}

// This is a wrapper around a component that checks if the user has access to the resource
// and if not, it disables the component and shows a reason why
export const AccessControlAction = ({
children,
userAccessLevel,
minAccessLevel,
resourceType = 'resource',
}: AccessControlActionProps): JSX.Element => {
const hasAccess = userAccessLevel ? accessLevelSatisfied(resourceType, userAccessLevel, minAccessLevel) : false
const disabledReason = !hasAccess
? `You don't have sufficient permissions for this ${resourceType}. Your access level (${userAccessLevel}) doesn't meet the required level (${minAccessLevel}).`
: null

return children({
disabled: !hasAccess,
disabledReason,
})
}
32 changes: 32 additions & 0 deletions frontend/src/lib/components/AccessControlledLemonButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { LemonButton, LemonButtonProps } from 'lib/lemon-ui/LemonButton'

import { WithAccessControl } from '../../types'
import { AccessControlAction } from './AccessControlAction'

export type AccessControlledLemonButtonProps = LemonButtonProps & {
userAccessLevel?: WithAccessControl['user_access_level']
minAccessLevel: WithAccessControl['user_access_level']
resourceType: string
}

export const AccessControlledLemonButton = ({
userAccessLevel,
minAccessLevel,
resourceType,
children,
...props
}: AccessControlledLemonButtonProps): JSX.Element => {
return (
<AccessControlAction
userAccessLevel={userAccessLevel}
minAccessLevel={minAccessLevel}
resourceType={resourceType}
>
{({ disabledReason: accessControlDisabledReason }) => (
<LemonButton {...props} disabledReason={accessControlDisabledReason || props.disabledReason}>
{children}
</LemonButton>
)}
</AccessControlAction>
)
}
2 changes: 2 additions & 0 deletions frontend/src/lib/components/Cards/InsightCard/InsightMeta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export function InsightMeta({
const { nameSortedDashboards } = useValues(dashboardsModel)

const otherDashboards = nameSortedDashboards.filter((d) => !dashboards?.includes(d.id))

// (@zach) Access Control TODO: add access control checks for remove from dashboard
const editable = insight.effective_privilege_level >= DashboardPrivilegeLevel.CanEdit

const summary = useSummarizeInsight()(insight.query)
Expand Down
24 changes: 17 additions & 7 deletions frontend/src/scenes/dashboard/DashboardHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useActions, useValues } from 'kea'
import { router } from 'kea-router'
import { AccessControlledLemonButton } from 'lib/components/AccessControlledLemonButton'
import { TextCardModal } from 'lib/components/Cards/TextCard/TextCardModal'
import { EditableField } from 'lib/components/EditableField/EditableField'
import { ExportButton, ExportButtonItem } from 'lib/components/ExportButton/ExportButton'
Expand Down Expand Up @@ -260,16 +261,20 @@ export function DashboardHeader(): JSX.Element | null {
>
Create notebook from dashboard
</LemonButton>

{canEditDashboard && (
<LemonButton
<AccessControlledLemonButton
userAccessLevel={dashboard.user_access_level}
minAccessLevel="editor"
resourceType="dashboard"
onClick={() => {
showDeleteDashboardModal(dashboard.id)
}}
status="danger"
fullWidth
>
Delete dashboard
</LemonButton>
</AccessControlledLemonButton>
)}
</>
) : undefined
Expand All @@ -292,25 +297,30 @@ export function DashboardHeader(): JSX.Element | null {
</>
)}
{dashboard ? (
<LemonButton
<AccessControlledLemonButton
userAccessLevel={dashboard.user_access_level}
minAccessLevel="editor"
resourceType="dashboard"
onClick={showAddInsightToDashboardModal}
type="primary"
data-attr="dashboard-add-graph-header"
disabledReason={canEditDashboard ? null : DASHBOARD_CANNOT_EDIT_MESSAGE}
sideAction={{
dropdown: {
placement: 'bottom-end',
overlay: (
<>
<LemonButton
<AccessControlledLemonButton
userAccessLevel={dashboard.user_access_level}
minAccessLevel="editor"
resourceType="dashboard"
fullWidth
onClick={() => {
push(urls.dashboardTextTile(dashboard.id, 'new'))
}}
data-attr="add-text-tile-to-dashboard"
>
Add text card
</LemonButton>
</AccessControlledLemonButton>
</>
),
},
Expand All @@ -319,7 +329,7 @@ export function DashboardHeader(): JSX.Element | null {
}}
>
Add insight
</LemonButton>
</AccessControlledLemonButton>
) : null}
</>
)
Expand Down
36 changes: 22 additions & 14 deletions frontend/src/scenes/dashboard/dashboardLogic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import { loaders } from 'kea-loaders'
import { router, urlToAction } from 'kea-router'
import api, { ApiMethodOptions, getJSONOrNull } from 'lib/api'
import { DashboardPrivilegeLevel, OrganizationMembershipLevel } from 'lib/constants'
import { DashboardPrivilegeLevel, FEATURE_FLAGS, OrganizationMembershipLevel } from 'lib/constants'
import { Dayjs, dayjs, now } from 'lib/dayjs'
import { currentSessionId, TimeToSeeDataPayload } from 'lib/internalMetrics'
import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast'
Expand Down Expand Up @@ -945,8 +945,14 @@ export const dashboardLogic = kea<dashboardLogicType>([
},
],
canEditDashboard: [
(s) => [s.dashboard],
(dashboard) => !!dashboard && dashboard.effective_privilege_level >= DashboardPrivilegeLevel.CanEdit,
(s) => [s.dashboard, s.featureFlags],
(dashboard, featureFlags) => {
if (featureFlags[FEATURE_FLAGS.ROLE_BASED_ACCESS_CONTROL]) {
const requiredLevels = ['admin', 'editor']
return dashboard?.user_access_level ? requiredLevels.includes(dashboard.user_access_level) : true
}
return !!dashboard && dashboard.effective_privilege_level >= DashboardPrivilegeLevel.CanEdit
},
],
canRestrictDashboard: [
// Sync conditions with backend can_user_restrict
Expand Down Expand Up @@ -991,8 +997,8 @@ export const dashboardLogic = kea<dashboardLogicType>([
},
],
breadcrumbs: [
(s) => [s.dashboard, s._dashboardLoading, s.dashboardFailedToLoad],
(dashboard, dashboardLoading, dashboardFailedToLoad): Breadcrumb[] => [
(s) => [s.dashboard, s._dashboardLoading, s.dashboardFailedToLoad, s.canEditDashboard],
(dashboard, dashboardLoading, dashboardFailedToLoad, canEditDashboard): Breadcrumb[] => [
{
key: Scene.Dashboards,
name: 'Dashboards',
Expand All @@ -1007,15 +1013,17 @@ export const dashboardLogic = kea<dashboardLogicType>([
: !dashboardLoading
? 'Not found'
: null,
onRename: async (name) => {
if (dashboard) {
await dashboardsModel.asyncActions.updateDashboard({
id: dashboard.id,
name,
allowUndo: true,
})
}
},
onRename: canEditDashboard
? async (name) => {
if (dashboard) {
await dashboardsModel.asyncActions.updateDashboard({
id: dashboard.id,
name,
allowUndo: true,
})
}
}
: undefined,
},
],
],
Expand Down
22 changes: 17 additions & 5 deletions frontend/src/scenes/dashboard/dashboards/DashboardsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { IconHome, IconLock, IconPin, IconPinFilled, IconShare } from '@posthog/icons'
import { LemonInput } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { AccessControlledLemonButton } from 'lib/components/AccessControlledLemonButton'
import { MemberSelect } from 'lib/components/MemberSelect'
import { ObjectTags } from 'lib/components/ObjectTags/ObjectTags'
import { DashboardPrivilegeLevel } from 'lib/constants'
Expand Down Expand Up @@ -131,7 +132,7 @@ export function DashboardsTable({
? {}
: {
width: 0,
render: function RenderActions(_, { id, name }: DashboardType) {
render: function RenderActions(_, { id, name, user_access_level }: DashboardType) {
return (
<More
overlay={
Expand All @@ -149,7 +150,11 @@ export function DashboardsTable({
>
View
</LemonButton>
<LemonButton

<AccessControlledLemonButton
userAccessLevel={user_access_level}
minAccessLevel="editor"
resourceType="dashboard"
to={urls.dashboard(id)}
onClick={() => {
dashboardLogic({ id }).mount()
Expand All @@ -161,7 +166,8 @@ export function DashboardsTable({
fullWidth
>
Edit
</LemonButton>
</AccessControlledLemonButton>

<LemonButton
onClick={() => {
showDuplicateDashboardModal(id, name)
Expand All @@ -170,7 +176,9 @@ export function DashboardsTable({
>
Duplicate
</LemonButton>

<LemonDivider />

<LemonRow icon={<IconHome className="text-warning" />} fullWidth status="warning">
<span className="text-muted">
Change the default dashboard
Expand All @@ -180,15 +188,19 @@ export function DashboardsTable({
</LemonRow>

<LemonDivider />
<LemonButton

<AccessControlledLemonButton
userAccessLevel={user_access_level}
minAccessLevel="editor"
resourceType="dashboard"
onClick={() => {
showDeleteDashboardModal(id)
}}
fullWidth
status="danger"
>
Delete dashboard
</LemonButton>
</AccessControlledLemonButton>
</>
}
/>
Expand Down
Loading
Loading