<>
-
-
Access control
-
- Permissions have moved! We're rolling out our new access control system. Click below to
- open it.
-
- }
- onClick={() => {
- openSidePanel(SidePanelTab.AccessControl)
- closeShareModal()
- }}
- >
- Open access control
-
-
+ {
+ closeShareModal()
+ }}
+ />
>
diff --git a/frontend/src/scenes/saved-insights/SavedInsights.tsx b/frontend/src/scenes/saved-insights/SavedInsights.tsx
index 43a1dd4d58917..4d863a7369a4c 100644
--- a/frontend/src/scenes/saved-insights/SavedInsights.tsx
+++ b/frontend/src/scenes/saved-insights/SavedInsights.tsx
@@ -23,6 +23,7 @@ import {
} from '@posthog/icons'
import { LemonSelectOptions, LemonTag } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
+import { AccessControlAction } from 'lib/components/AccessControlAction'
import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog'
import { Alerts } from 'lib/components/Alerts/views/Alerts'
import { InsightCard } from 'lib/components/Cards/InsightCard'
@@ -540,38 +541,72 @@ export function SavedInsights(): JSX.Element {
View
-
- Edit
-
-
renameInsight(insight)}
- data-attr={`insight-item-${insight.short_id}-dropdown-rename`}
- fullWidth
+
- Rename
-
-
duplicateInsight(insight)}
- data-attr="duplicate-insight-from-list-view"
- fullWidth
+ {({ disabledReason: accessControlDisabledReason }) => (
+
+ Edit
+
+ )}
+
+
- Duplicate
-
-
-
- void deleteInsightWithUndo({
- object: insight,
- endpoint: `projects/${currentProjectId}/insights`,
- callback: loadInsights,
- })
- }
- data-attr={`insight-item-${insight.short_id}-dropdown-remove`}
- fullWidth
+ {({ disabledReason: accessControlDisabledReason }) => (
+ renameInsight(insight)}
+ data-attr={`insight-item-${insight.short_id}-dropdown-rename`}
+ fullWidth
+ disabledReason={accessControlDisabledReason}
+ >
+ Rename
+
+ )}
+
+
- Delete insight
-
+ {({ disabledReason: accessControlDisabledReason }) => (
+
duplicateInsight(insight)}
+ data-attr="duplicate-insight-from-list-view"
+ fullWidth
+ disabledReason={accessControlDisabledReason}
+ >
+ Duplicate
+
+ )}
+
+
+ {({ disabledReason: accessControlDisabledReason }) => (
+
+ void deleteInsightWithUndo({
+ object: insight,
+ endpoint: `projects/${currentProjectId}/insights`,
+ callback: loadInsights,
+ })
+ }
+ data-attr={`insight-item-${insight.short_id}-dropdown-remove`}
+ fullWidth
+ disabledReason={accessControlDisabledReason}
+ >
+ Delete insight
+
+ )}
+
>
}
/>
diff --git a/frontend/src/scenes/saved-insights/activityDescriptions.tsx b/frontend/src/scenes/saved-insights/activityDescriptions.tsx
index cd7668905e5ad..8f6f9f10dc2a6 100644
--- a/frontend/src/scenes/saved-insights/activityDescriptions.tsx
+++ b/frontend/src/scenes/saved-insights/activityDescriptions.tsx
@@ -231,6 +231,7 @@ const insightActionsMapping: Record<
disable_baseline: () => null,
dashboard_tiles: () => null,
query_status: () => null,
+ user_access_level: () => null,
}
function summarizeChanges(filtersAfter: Partial
): ChangeMapping | null {
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 9f93e72422fd5..74a7cbd113851 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -1812,7 +1812,7 @@ export interface TextModel {
last_modified_at: string
}
-export interface InsightModel extends Cacheable {
+export interface InsightModel extends Cacheable, WithAccessControl {
/** The unique key we use when communicating with the user, e.g. in URLs */
short_id: InsightShortId
/** The primary key in the database, used as well in API endpoints */
@@ -1851,7 +1851,7 @@ export interface QueryBasedInsightModel extends Omit {
query: Node | null
}
-export interface DashboardBasicType {
+export interface DashboardBasicType extends WithAccessControl {
id: number
name: string
description: string
diff --git a/posthog/api/dashboards/dashboard.py b/posthog/api/dashboards/dashboard.py
index 393df01a2d554..6c8046c3743cd 100644
--- a/posthog/api/dashboards/dashboard.py
+++ b/posthog/api/dashboards/dashboard.py
@@ -3,7 +3,6 @@
import structlog
from django.db.models import Prefetch
-from django.shortcuts import get_object_or_404
from django.utils.timezone import now
from rest_framework import exceptions, serializers, viewsets
from rest_framework.permissions import SAFE_METHODS, BasePermission
@@ -516,13 +515,14 @@ def dangerously_get_queryset(self):
),
)
+ # Add access level filtering for list actions
+ queryset = self._filter_queryset_by_access_level(queryset)
+
return queryset
@monitor(feature=Feature.DASHBOARD, endpoint="dashboard", method="GET")
def retrieve(self, request: Request, *args: Any, **kwargs: Any) -> Response:
- pk = kwargs["pk"]
- queryset = self.get_queryset()
- dashboard = get_object_or_404(queryset, pk=pk)
+ dashboard = self.get_object()
dashboard.last_accessed_at = now()
dashboard.save(update_fields=["last_accessed_at"])
serializer = DashboardSerializer(dashboard, context=self.get_serializer_context())
diff --git a/posthog/api/feature_flag.py b/posthog/api/feature_flag.py
index f20c1a4a6105a..92764327eccaa 100644
--- a/posthog/api/feature_flag.py
+++ b/posthog/api/feature_flag.py
@@ -446,7 +446,7 @@ def _create_usage_dashboard(feature_flag: FeatureFlag, user):
return usage_dashboard
-class MinimalFeatureFlagSerializer(serializers.ModelSerializer):
+class MinimalFeatureFlagSerializer(serializers.ModelSerializer, UserAccessControlSerializerMixin):
filters = serializers.DictField(source="get_filters", required=False)
class Meta:
@@ -460,6 +460,7 @@ class Meta:
"deleted",
"active",
"ensure_experience_continuity",
+ "user_access_level",
]
diff --git a/posthog/api/insight.py b/posthog/api/insight.py
index 924850d51f07b..30c13d847ebc3 100644
--- a/posthog/api/insight.py
+++ b/posthog/api/insight.py
@@ -216,7 +216,7 @@ class Meta:
fields = ["id", "dashboard_id", "deleted"]
-class InsightBasicSerializer(TaggedItemSerializerMixin, serializers.ModelSerializer):
+class InsightBasicSerializer(TaggedItemSerializerMixin, serializers.ModelSerializer, UserAccessControlSerializerMixin):
"""
Simplified serializer to speed response times when loading large amounts of objects.
"""
@@ -245,6 +245,7 @@ class Meta:
"created_at",
"last_modified_at",
"favorited",
+ "user_access_level",
]
read_only_fields = ("short_id", "updated_at", "last_refresh", "refreshing")
@@ -272,7 +273,7 @@ def _dashboard_tiles(self, instance):
return [tile.dashboard_id for tile in instance.dashboard_tiles.all()]
-class InsightSerializer(InsightBasicSerializer, UserPermissionsSerializerMixin, UserAccessControlSerializerMixin):
+class InsightSerializer(InsightBasicSerializer, UserPermissionsSerializerMixin):
result = serializers.SerializerMethodField()
hasMore = serializers.SerializerMethodField()
columns = serializers.SerializerMethodField()
@@ -805,6 +806,9 @@ def dangerously_get_queryset(self):
queryset = queryset.prefetch_related("tagged_items__tag")
queryset = self._filter_request(self.request, queryset)
+ # Add access level filtering for list actions
+ queryset = self._filter_queryset_by_access_level(queryset)
+
order = self.request.GET.get("order", None)
if order:
queryset = queryset.order_by(order)
@@ -1188,7 +1192,7 @@ def calculate_path(self, request: request.Request) -> dict[str, Any]:
# /projects/:id/insights/:short_id/viewed
# Creates or updates an InsightViewed object for the user/insight combo
# ******************************************
- @action(methods=["POST"], detail=True)
+ @action(methods=["POST"], detail=True, required_scopes=["insight:read"])
def viewed(self, request: request.Request, *args: Any, **kwargs: Any) -> Response:
InsightViewed.objects.update_or_create(
team=self.team,
diff --git a/posthog/api/notebook.py b/posthog/api/notebook.py
index f68d2ddb0b26a..09eb8b1c769d1 100644
--- a/posthog/api/notebook.py
+++ b/posthog/api/notebook.py
@@ -78,7 +78,7 @@ def log_notebook_activity(
)
-class NotebookMinimalSerializer(serializers.ModelSerializer):
+class NotebookMinimalSerializer(serializers.ModelSerializer, UserAccessControlSerializerMixin):
created_by = UserBasicSerializer(read_only=True)
last_modified_by = UserBasicSerializer(read_only=True)
@@ -93,11 +93,12 @@ class Meta:
"created_by",
"last_modified_at",
"last_modified_by",
+ "user_access_level",
]
read_only_fields = fields
-class NotebookSerializer(NotebookMinimalSerializer, UserAccessControlSerializerMixin):
+class NotebookSerializer(NotebookMinimalSerializer):
class Meta:
model = Notebook
fields = [
diff --git a/posthog/api/routing.py b/posthog/api/routing.py
index 7c59d73de662a..e538c8322bf60 100644
--- a/posthog/api/routing.py
+++ b/posthog/api/routing.py
@@ -168,27 +168,29 @@ def get_queryset(self) -> QuerySet:
queryset = self._filter_queryset_by_parents_lookups(queryset)
- if self.action != "list":
- # NOTE: If we are getting an individual object then we don't filter it out here - this is handled by the permission logic
- # The reason being, that if we filter out here already, we can't load the object which is required for checking access controls for it
- return queryset
-
- # NOTE: Half implemented - for admins, they may want to include listing of results that are not accessible (like private resources)
- include_all_if_admin = self.request.GET.get("admin_include_all") == "true"
-
- # Additionally "projects" is a special one where we always want to include all projects if you're an org admin
- if self.scope_object == "project":
- include_all_if_admin = True
-
- # Only apply access control filter if we're not already in a recursive call
- queryset = self.user_access_control.filter_queryset_by_access_level(
- queryset, include_all_if_admin=include_all_if_admin
- )
+ queryset = self._filter_queryset_by_access_level(queryset)
return queryset
finally:
self._in_get_queryset = False
+ def _filter_queryset_by_access_level(self, queryset: QuerySet) -> QuerySet:
+ if self.action != "list":
+ # NOTE: If we are getting an individual object then we don't filter it out here - this is handled by the permission logic
+ # The reason being, that if we filter out here already, we can't load the object which is required for checking access controls for it
+ return queryset
+
+ # NOTE: Half implemented - for admins, they may want to include listing of results that are not accessible (like private resources)
+ include_all_if_admin = self.request.GET.get("admin_include_all") == "true"
+
+ # Additionally "projects" is a special one where we always want to include all projects if you're an org admin
+ if self.scope_object == "project":
+ include_all_if_admin = True
+
+ return self.user_access_control.filter_queryset_by_access_level(
+ queryset, include_all_if_admin=include_all_if_admin
+ )
+
def dangerously_get_object(self) -> Any:
"""
WARNING: This should be used very carefully. It bypasses common security access control checks.
diff --git a/posthog/rbac/user_access_control.py b/posthog/rbac/user_access_control.py
index f3b5f5b2b0d9c..abc0ac609dfca 100644
--- a/posthog/rbac/user_access_control.py
+++ b/posthog/rbac/user_access_control.py
@@ -411,7 +411,6 @@ def check_access_level_for_resource(self, resource: APIScopeObject, required_lev
def filter_queryset_by_access_level(self, queryset: QuerySet, include_all_if_admin=False) -> QuerySet:
# Find all items related to the queryset model that have access controls such that the effective level for the user is "none"
# and exclude them from the queryset
-
model = cast(Model, queryset.model)
resource = model_to_resource(model)
@@ -474,7 +473,7 @@ def user_access_control(self) -> Optional[UserAccessControl]:
elif hasattr(self.context.get("view", None), "user_access_control"):
# Otherwise from the view (the default case)
return self.context["view"].user_access_control
- else:
+ elif "request" in self.context:
user = cast(User | AnonymousUser, self.context["request"].user)
# The user could be anonymous - if so there is no access control to be used
@@ -484,6 +483,7 @@ def user_access_control(self) -> Optional[UserAccessControl]:
user = cast(User, user)
return UserAccessControl(user, organization_id=str(user.current_organization_id))
+ return None
def get_user_access_level(self, obj: Model) -> Optional[str]:
if not self.user_access_control: