feat: Add access control to activity logs (#40801)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Yasen
2025-11-13 16:04:14 +02:00
committed by GitHub
parent 52f8bc5c7b
commit d451e64e7f
28 changed files with 158 additions and 12 deletions

View File

@@ -79,6 +79,12 @@ class AccessControlSerializer(serializers.ModelSerializer):
f"Access level cannot be set below the minimum '{min_level}' for {resource}."
)
max_level = highest_access_level(resource)
if levels.index(access_level) > levels.index(max_level):
raise serializers.ValidationError(
f"Access level cannot be set above the maximum '{max_level}' for {resource}."
)
return access_level
def validate(self, data):
@@ -219,6 +225,7 @@ class AccessControlViewSetMixin(_GenericViewSet):
else ordered_access_levels(resource),
"default_access_level": "editor" if is_resource_level else default_access_level(resource),
"minimum_access_level": minimum_access_level(resource) if not is_resource_level else "none",
"maximum_access_level": highest_access_level(resource) if not is_resource_level else "manager",
"user_access_level": user_access_level,
"user_can_edit_access_levels": user_access_control.check_can_modify_access_levels_for_object(obj),
}

View File

@@ -146,6 +146,42 @@ class TestAccessControlMinimumLevelValidation(BaseAccessControlTest):
)
assert res.status_code == status.HTTP_200_OK, f"Failed for level {level}: {res.json()}"
def test_activity_log_access_level_cannot_be_above_viewer(self):
"""Test that activity_log access level cannot be set above maximum 'viewer'"""
self._org_membership(OrganizationMembership.Level.ADMIN)
for level in ["editor", "manager"]:
res = self.client.put(
"/api/projects/@current/resource_access_controls",
{"resource": "activity_log", "access_level": level},
)
assert res.status_code == status.HTTP_400_BAD_REQUEST, f"Failed for level {level}: {res.json()}"
assert "cannot be set above the maximum 'viewer'" in res.json()["detail"]
def test_activity_log_access_restricted_for_users_without_access(self):
"""Test that users without access to activity_log cannot access activity log endpoints"""
self._org_membership(OrganizationMembership.Level.ADMIN)
res = self.client.put(
"/api/projects/@current/resource_access_controls",
{"resource": "activity_log", "access_level": "none"},
)
assert res.status_code == status.HTTP_200_OK, f"Failed to set access control: {res.json()}"
from ee.models.rbac.access_control import AccessControl
ac = AccessControl.objects.filter(team=self.team, resource="activity_log", resource_id=None).first()
assert ac is not None, "Access control was not created"
assert ac.access_level == "none", f"Access level is {ac.access_level}, expected 'none'"
self._org_membership(OrganizationMembership.Level.MEMBER)
res = self.client.get("/api/projects/@current/activity_log/")
assert res.status_code == status.HTTP_403_FORBIDDEN, f"Expected 403, got {res.status_code}: {res.json()}"
res = self.client.get("/api/projects/@current/advanced_activity_logs/")
assert res.status_code == status.HTTP_403_FORBIDDEN, f"Expected 403, got {res.status_code}: {res.json()}"
class TestAccessControlResourceLevelAPI(BaseAccessControlTest):
def setUp(self):
@@ -186,6 +222,7 @@ class TestAccessControlResourceLevelAPI(BaseAccessControlTest):
"default_access_level": "editor",
"user_can_edit_access_levels": True,
"minimum_access_level": "none",
"maximum_access_level": "manager",
}
def test_change_rejected_if_not_org_admin(self):

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -18,7 +18,7 @@ import {
import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini'
import { UserSelectItem } from 'lib/components/UserSelectItem'
import { fullName } from 'lib/utils'
import { getMinimumAccessLevel, pluralizeResource } from 'lib/utils/accessControlUtils'
import { getMaximumAccessLevel, getMinimumAccessLevel, pluralizeResource } from 'lib/utils/accessControlUtils'
import { APIScopeObject, AccessControlLevel, AvailableFeature } from '~/types'
@@ -514,10 +514,12 @@ function ResourceAccessControlModal(props: {
resource: APIScopeObject
): { value: AccessControlLevel | null; label: string; disabledReason?: string }[] => {
const minimumLevel = getMinimumAccessLevel(resource)
const maxLevel = getMaximumAccessLevel(resource)
const options: { value: AccessControlLevel | null; label: string; disabledReason?: string }[] =
availableLevels.map((level) => {
const isDisabled =
minimumLevel && availableLevels.indexOf(level) < availableLevels.indexOf(minimumLevel)
(minimumLevel && availableLevels.indexOf(level) < availableLevels.indexOf(minimumLevel)) ||
(maxLevel && availableLevels.indexOf(level) > availableLevels.indexOf(maxLevel))
return {
value: level as AccessControlLevel,
label: capitalizeFirstLetter(level ?? ''),
@@ -679,10 +681,12 @@ function DefaultResourceAccessControlModal(props: {
resource: APIScopeObject
): { value: AccessControlLevel | null; label: string; disabledReason?: string }[] => {
const minimumLevel = getMinimumAccessLevel(resource)
const maxLevel = getMaximumAccessLevel(resource)
const options: { value: AccessControlLevel | null; label: string; disabledReason?: string }[] =
availableLevels.map((level) => {
const isDisabled =
minimumLevel && availableLevels.indexOf(level) < availableLevels.indexOf(minimumLevel)
(minimumLevel && availableLevels.indexOf(level) < availableLevels.indexOf(minimumLevel)) ||
(maxLevel && availableLevels.indexOf(level) > availableLevels.indexOf(maxLevel))
return {
value: level as AccessControlLevel,
label: capitalizeFirstLetter(level ?? ''),

View File

@@ -7,6 +7,7 @@ import api from 'lib/api'
import { OrganizationMembershipLevel } from 'lib/constants'
import { membersLogic } from 'scenes/organization/membersLogic'
import { teamLogic } from 'scenes/teamLogic'
import { userLogic } from 'scenes/userLogic'
import {
APIScopeObject,
@@ -16,6 +17,7 @@ import {
AccessControlType,
AccessControlTypeRole,
AccessControlUpdateType,
AvailableFeature,
OrganizationMemberType,
RoleType,
} from '~/types'
@@ -33,10 +35,23 @@ export type RoleResourceAccessControls = DefaultResourceAccessControls & {
role?: RoleType
}
const RESOURCE_FEATURE_REQUIREMENTS: Partial<Record<AccessControlResourceType, AvailableFeature>> = {
[AccessControlResourceType.ActivityLog]: AvailableFeature.AUDIT_LOGS,
}
export const resourcesAccessControlLogic = kea<resourcesAccessControlLogicType>([
path(['scenes', 'accessControl', 'resourcesAccessControlLogic']),
connect(() => ({
values: [roleAccessControlLogic, ['roles'], teamLogic, ['currentTeam'], membersLogic, ['sortedMembers']],
values: [
roleAccessControlLogic,
['roles'],
teamLogic,
['currentTeam'],
membersLogic,
['sortedMembers'],
userLogic,
['hasAvailableFeature'],
],
})),
actions({
updateResourceAccessControls: (
@@ -262,10 +277,11 @@ export const resourcesAccessControlLogic = kea<resourcesAccessControlLogicType>(
],
resources: [
() => [],
(): AccessControlType['resource'][] => {
return [
(s) => [s.hasAvailableFeature],
(hasAvailableFeature): AccessControlType['resource'][] => {
const allResources = [
AccessControlResourceType.Action,
AccessControlResourceType.ActivityLog,
AccessControlResourceType.Dashboard,
AccessControlResourceType.Experiment,
AccessControlResourceType.FeatureFlag,
@@ -276,6 +292,14 @@ export const resourcesAccessControlLogic = kea<resourcesAccessControlLogicType>(
AccessControlResourceType.Survey,
AccessControlResourceType.WebAnalytics,
]
return allResources.filter((resource) => {
const requiredFeature = RESOURCE_FEATURE_REQUIREMENTS[resource]
if (!requiredFeature) {
return true
}
return hasAvailableFeature(requiredFeature)
})
},
],

View File

@@ -15,6 +15,7 @@ import { useOnMountEffect } from 'lib/hooks/useOnMountEffect'
import { LemonMenuItems } from 'lib/lemon-ui/LemonMenu/LemonMenu'
import { IconWithCount } from 'lib/lemon-ui/icons'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { userHasAccess } from 'lib/utils/accessControlUtils'
import { HOG_FUNCTION_SUB_TEMPLATES } from 'scenes/hog-functions/sub-templates/sub-templates'
import { urls } from 'scenes/urls'
import { userLogic } from 'scenes/userLogic'
@@ -25,7 +26,14 @@ import {
} from '~/layout/navigation-3000/sidepanel/panels/activity/sidePanelActivityLogic'
import { sidePanelNotificationsLogic } from '~/layout/navigation-3000/sidepanel/panels/activity/sidePanelNotificationsLogic'
import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic'
import { AvailableFeature, CyclotronJobFilterPropertyFilter, PropertyFilterType, PropertyOperator } from '~/types'
import {
AccessControlLevel,
AccessControlResourceType,
AvailableFeature,
CyclotronJobFilterPropertyFilter,
PropertyFilterType,
PropertyOperator,
} from '~/types'
import { SidePanelPaneHeader } from '../../components/SidePanelPaneHeader'
import { SidePanelActivityMetalytics } from './SidePanelActivityMetalytics'
@@ -56,6 +64,8 @@ export const SidePanelActivity = (): JSX.Element => {
const { user } = useValues(userLogic)
const { featureFlags } = useValues(featureFlagLogic)
const hasAccess = userHasAccess(AccessControlResourceType.ActivityLog, AccessControlLevel.Viewer)
useOnMountEffect(() => {
loadImportantChanges(false)
@@ -83,6 +93,24 @@ export const SidePanelActivity = (): JSX.Element => {
const hasListContext = Boolean(contextFromPage?.scope && !contextFromPage?.item_id)
const hasAnyContext = hasItemContext || hasListContext
if (!hasAccess) {
return (
<>
<SidePanelPaneHeader title="Team activity" />
<div className="flex flex-col items-center justify-center gap-3 p-6 text-center h-full">
<IconNotification className="text-5xl text-muted" />
<div>
<div className="font-semibold mb-1">Access denied</div>
<div className="text-xs text-muted-alt">
You don't have sufficient permissions to view activity logs. Please contact your project
administrator.
</div>
</div>
</div>
</>
)
}
return (
<>
<SidePanelPaneHeader title="Team activity" />

View File

@@ -16,11 +16,13 @@ import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton'
import { PaginationControl, usePagination } from 'lib/lemon-ui/PaginationControl'
import { ProfilePicture } from 'lib/lemon-ui/ProfilePicture'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { userHasAccess } from 'lib/utils/accessControlUtils'
import { billingLogic } from 'scenes/billing/billingLogic'
import { userLogic } from 'scenes/userLogic'
import { AvailableFeature, ProductKey } from '~/types'
import { AccessControlLevel, AccessControlResourceType, AvailableFeature, ProductKey } from '~/types'
import { AccessDenied } from '../AccessDenied'
import MonacoDiffEditor from '../MonacoDiffEditor'
import { PayGateMini } from '../PayGateMini/PayGateMini'
import { ProductIntroduction } from '../ProductIntroduction/ProductIntroduction'
@@ -211,8 +213,14 @@ export const ActivityLog = ({ scope, id, caption, startingPage = 1 }: ActivityLo
const { featureFlags } = useValues(featureFlagLogic)
const { billingLoading } = useValues(billingLogic)
const hasAccess = userHasAccess(AccessControlResourceType.ActivityLog, AccessControlLevel.Viewer)
const paginationState = usePagination(humanizedActivity || [], pagination)
if (!hasAccess) {
return <AccessDenied object="activity logs" />
}
return (
<div className="ActivityLog">
{caption && <div className="page-caption">{caption}</div>}

View File

@@ -16,6 +16,20 @@ export const getMinimumAccessLevel = (resource: APIScopeObject): AccessControlLe
return null
}
/**
* Returns the maximum allowed access level for a resource.
* Matches the backend maximum_access_level function in user_access_control.py
*
* @param resource - The API scope object to check maximum access for
* @returns The maximum access level required, or null if no maximum is set
*/
export const getMaximumAccessLevel = (resource: APIScopeObject): AccessControlLevel | null => {
if (resource === AccessControlResourceType.ActivityLog) {
return AccessControlLevel.Viewer
}
return null
}
/**
* Converts a resource name to its plural form for display purposes.
* Handles special cases for specific resources that have custom plural forms.
@@ -28,6 +42,8 @@ export const pluralizeResource = (resource: APIScopeObject): string => {
return 'revenue analytics'
} else if (resource === AccessControlResourceType.WebAnalytics) {
return 'web analytics'
} else if (resource === AccessControlResourceType.ActivityLog) {
return 'activity logs'
}
return resource.replace(/_/g, ' ') + 's'
@@ -44,6 +60,9 @@ export const orderedAccessLevels = (resourceType: AccessControlResourceType): Ac
if (resourceType === AccessControlResourceType.Project || resourceType === AccessControlResourceType.Organization) {
return [AccessControlLevel.None, AccessControlLevel.Member, AccessControlLevel.Admin]
}
if (resourceType === AccessControlResourceType.ActivityLog) {
return [AccessControlLevel.None, AccessControlLevel.Viewer]
}
return [AccessControlLevel.None, AccessControlLevel.Viewer, AccessControlLevel.Editor, AccessControlLevel.Manager]
}

View File

@@ -3,12 +3,14 @@ import { useActions, useValues } from 'kea'
import { IconNotification } from '@posthog/icons'
import { LemonTabs } from '@posthog/lemon-ui'
import { AccessDenied } from 'lib/components/AccessDenied'
import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini'
import { userHasAccess } from 'lib/utils/accessControlUtils'
import { SceneExport } from 'scenes/sceneTypes'
import { SceneContent } from '~/layout/scenes/components/SceneContent'
import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection'
import { AvailableFeature } from '~/types'
import { AccessControlLevel, AccessControlResourceType, AvailableFeature } from '~/types'
import { AdvancedActivityLogFiltersPanel } from './AdvancedActivityLogFiltersPanel'
import { AdvancedActivityLogsList } from './AdvancedActivityLogsList'
@@ -24,6 +26,8 @@ export function AdvancedActivityLogsScene(): JSX.Element | null {
const { activeTab } = useValues(advancedActivityLogsLogic)
const { setActiveTab } = useActions(advancedActivityLogsLogic)
const hasAccess = userHasAccess(AccessControlResourceType.ActivityLog, AccessControlLevel.Viewer)
const tabs = [
{
key: 'logs',
@@ -42,6 +46,14 @@ export function AdvancedActivityLogsScene(): JSX.Element | null {
},
]
if (!hasAccess) {
return (
<SceneContent>
<AccessDenied object="activity logs" />
</SceneContent>
)
}
return (
<SceneContent>
<SceneTitleSection

View File

@@ -314,6 +314,7 @@ export enum AccessControlResourceType {
Survey = 'survey',
Experiment = 'experiment',
WebAnalytics = 'web_analytics',
ActivityLog = 'activity_log',
}
interface UserBaseType {

View File

@@ -181,7 +181,7 @@ class AdvancedActivityLogsViewSet(TeamAndOrgViewSetMixin, viewsets.GenericViewSe
pagination_class = ActivityLogPagination
logger = logging.getLogger(__name__)
filter_rewrite_rules = {"project_id": "team_id"}
scope_object = "INTERNAL"
scope_object = "activity_log"
queryset = ActivityLog.objects.all()
def _should_skip_parents_filter(self) -> bool:

View File

@@ -20,6 +20,7 @@
'/home/runner/work/posthog/posthog/ee/clickhouse/views/person.py: Warning [EnterprisePersonViewSet]: could not derive type of path parameter "project_id" because model "posthog.models.person.person.Person" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',
'/home/runner/work/posthog/posthog/posthog/api/action.py: Warning [ActionViewSet > ActionSerializer]: unable to resolve type hint for function "get_creation_context". Consider using a type hint or @extend_schema_field. Defaulting to string.',
'/home/runner/work/posthog/posthog/posthog/api/advanced_activity_logs/viewset.py: Warning [ActivityLogViewSet]: could not derive type of path parameter "project_id" because model "posthog.models.activity_logging.activity_log.ActivityLog" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',
'/home/runner/work/posthog/posthog/posthog/api/advanced_activity_logs/viewset.py: Warning [AdvancedActivityLogsViewSet]: could not derive type of path parameter "project_id" because model "posthog.models.activity_logging.activity_log.ActivityLog" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',
'/home/runner/work/posthog/posthog/posthog/api/annotation.py: Warning [AnnotationsViewSet]: could not derive type of path parameter "project_id" because model "posthog.models.annotation.Annotation" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',
"/home/runner/work/posthog/posthog/posthog/api/app_metrics.py: Error [AppMetricsViewSet]: exception raised while getting serializer. Hint: Is get_serializer_class() returning None or is get_queryset() not working without a request? Ignoring the view for now. (Exception: 'AppMetricsViewSet' should either include a `serializer_class` attribute, or override the `get_serializer_class()` method.)",
'/home/runner/work/posthog/posthog/posthog/api/app_metrics.py: Error [HistoricalExportsAppMetricsViewSet]: unable to guess serializer. This is graceful fallback handling for APIViews. Consider using GenericAPIView as view base class, if view is under your control. Either way you may want to add a serializer_class (or method). Ignoring view for now.',

View File

@@ -60,6 +60,7 @@ ACCESS_CONTROL_RESOURCES: tuple[APIScopeObject, ...] = (
"survey",
"experiment",
"web_analytics",
"activity_log",
)
# Resource inheritance mapping - child resources inherit access from parent resources
@@ -111,7 +112,6 @@ def resource_to_display_name(resource: APIScopeObject) -> str:
def ordered_access_levels(resource: APIScopeObject) -> list[AccessControlLevel]:
if resource in ["project", "organization"]:
return list(ACCESS_CONTROL_LEVELS_MEMBER)
return list(ACCESS_CONTROL_LEVELS_RESOURCE)
@@ -120,6 +120,8 @@ def default_access_level(resource: APIScopeObject) -> AccessControlLevel:
return "admin"
if resource in ["organization"]:
return "member"
if resource in ["activity_log"]:
return "viewer"
return "editor"
@@ -131,6 +133,9 @@ def minimum_access_level(resource: APIScopeObject) -> AccessControlLevel:
def highest_access_level(resource: APIScopeObject) -> AccessControlLevel:
"""Returns the highest allowed access level for a resource."""
if resource in ["activity_log"]:
return "viewer"
return ordered_access_levels(resource)[-1]