Skip to content

Commit

Permalink
feat: client side access control checks (#27635)
Browse files Browse the repository at this point in the history
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Michael Matloka <[email protected]>
  • Loading branch information
3 people authored and timgl committed Jan 28, 2025
1 parent c993003 commit 0233fbb
Show file tree
Hide file tree
Showing 23 changed files with 431 additions and 100 deletions.
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.
59 changes: 59 additions & 0 deletions frontend/src/lib/components/AccessControlAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { AccessControlResourceType } from '~/types'

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: AccessControlResourceType
}

const orderedAccessLevels = (resourceType: AccessControlResourceType): AccessControlLevel[] => {
if (resourceType === AccessControlResourceType.Project || resourceType === AccessControlResourceType.Organization) {
return ['none', 'member', 'admin']
}
return ['none', 'viewer', 'editor']
}

const resourceTypeToString = (resourceType: AccessControlResourceType): string => {
if (resourceType === AccessControlResourceType.FeatureFlag) {
return 'feature flag'
}

// The rest are single words
return resourceType
}

export const accessLevelSatisfied = (
resourceType: AccessControlResourceType,
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 = AccessControlResourceType.Project,
}: AccessControlActionProps): JSX.Element => {
const hasAccess = userAccessLevel ? accessLevelSatisfied(resourceType, userAccessLevel, minAccessLevel) : true
const disabledReason = !hasAccess
? `You don't have sufficient permissions for this ${resourceTypeToString(
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 { AccessControlResourceType, WithAccessControl } from '../../types'
import { AccessControlAction } from './AccessControlAction'

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

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
32 changes: 24 additions & 8 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 All @@ -26,7 +27,13 @@ import { userLogic } from 'scenes/userLogic'
import { dashboardsModel } from '~/models/dashboardsModel'
import { notebooksModel } from '~/models/notebooksModel'
import { tagsModel } from '~/models/tagsModel'
import { DashboardMode, DashboardType, ExporterFormat, QueryBasedInsightModel } from '~/types'
import {
AccessControlResourceType,
DashboardMode,
DashboardType,
ExporterFormat,
QueryBasedInsightModel,
} from '~/types'

import { AddInsightToDashboardModal } from './AddInsightToDashboardModal'
import { addInsightToDashboardLogic } from './addInsightToDashboardModalLogic'
Expand Down Expand Up @@ -260,16 +267,20 @@ export function DashboardHeader(): JSX.Element | null {
>
Create notebook from dashboard
</LemonButton>

{canEditDashboard && (
<LemonButton
<AccessControlledLemonButton
userAccessLevel={dashboard.user_access_level}
minAccessLevel="editor"
resourceType={AccessControlResourceType.Dashboard}
onClick={() => {
showDeleteDashboardModal(dashboard.id)
}}
status="danger"
fullWidth
>
Delete dashboard
</LemonButton>
</AccessControlledLemonButton>
)}
</>
) : undefined
Expand All @@ -292,25 +303,30 @@ export function DashboardHeader(): JSX.Element | null {
</>
)}
{dashboard ? (
<LemonButton
<AccessControlledLemonButton
userAccessLevel={dashboard.user_access_level}
minAccessLevel="editor"
resourceType={AccessControlResourceType.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={AccessControlResourceType.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 +335,7 @@ export function DashboardHeader(): JSX.Element | null {
}}
>
Add insight
</LemonButton>
</AccessControlledLemonButton>
) : null}
</>
)
Expand Down
43 changes: 29 additions & 14 deletions frontend/src/scenes/dashboard/dashboardLogic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ 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 { accessLevelSatisfied } from 'lib/components/AccessControlAction'
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 @@ -46,6 +47,7 @@ import {
RefreshType,
} from '~/queries/schema/schema-general'
import {
AccessControlResourceType,
ActivityScope,
AnyPropertyFilter,
Breadcrumb,
Expand Down Expand Up @@ -945,8 +947,19 @@ 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]) {
return dashboard?.user_access_level
? accessLevelSatisfied(
AccessControlResourceType.Dashboard,
dashboard.user_access_level,
'editor'
)
: true
}
return !!dashboard && dashboard.effective_privilege_level >= DashboardPrivilegeLevel.CanEdit
},
],
canRestrictDashboard: [
// Sync conditions with backend can_user_restrict
Expand Down Expand Up @@ -991,8 +1004,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 +1020,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
24 changes: 18 additions & 6 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 All @@ -23,7 +24,7 @@ import { urls } from 'scenes/urls'
import { userLogic } from 'scenes/userLogic'

import { dashboardsModel, nameCompareFunction } from '~/models/dashboardsModel'
import { AvailableFeature, DashboardBasicType, DashboardMode, DashboardType } from '~/types'
import { AccessControlResourceType, AvailableFeature, DashboardBasicType, DashboardMode, DashboardType } from '~/types'

import { DASHBOARD_CANNOT_EDIT_MESSAGE } from '../DashboardHeader'

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={AccessControlResourceType.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={AccessControlResourceType.Dashboard}
onClick={() => {
showDeleteDashboardModal(id)
}}
fullWidth
status="danger"
>
Delete dashboard
</LemonButton>
</AccessControlledLemonButton>
</>
}
/>
Expand Down
Loading

0 comments on commit 0233fbb

Please sign in to comment.