mirror of
https://github.com/PostHog/posthog.git
synced 2024-11-22 08:40:03 +01:00
Project-based permissioning framework (#5976)
* Refactor `AvailableFeature` from strings to an enum everywhere * Fix circular dependency and type * Add "Per-project access" feature flag, premium feature, and organization switch * Rename `OrganizationMembershipLevel` to `OrganizationAccessLevel` * Create `ExplicitTeamMembership` model * Show whether projects are restricted in the project switcher * Update organizations API code * Fix migrations * Move organization tests that require EE to `ee` * Revert `OrganizationMembershipLevel` rename * Fix organization tests * Update migration * Fix schema and add Members to Project Settings * Build out test memberships API with security tests * Update `TeamMembers` and `teamMembersLogic` * Move "Per-project access" description to tooltip * Add moar tests * Fix Project Members list logic * Add additional membership checks * Update migrations * Fix typing * Adjust explicit team memberships API similarly * Fix typo * Unify `ExplicitTeamMemberSerializer` * Remove old changes to `membersLogic` usage * Use `effective_membership_level` on `TeamBasicSerializer` * Clean up organization update tests * Explicitly disallow enabling per-project access for free * Fix circular import * Remove `id` from `UserSerializer` * Fix typing * Try to fix import * Fix fatal typing * Add more tests * Update permissioning.ts * Add clarifying comment to migration * Fix import * minor clarifications * Revert `TopNavigation` changes * Make new access control entirely project-based * Update migrations * Add `project_based_permissioning` to `TeamBasicSerializer` * Update test_team.py * Fix Access Control restriction tooltip * adjust copy & UI a bit * Address feedback on field comment * "Privacy settings" to "Access Control" * Ignore mypy * Rename `Team` field `project_based_permissioning` to `access_control` * Update migrations Co-authored-by: Paolo D'Amico <paolodamico@users.noreply.github.com>
This commit is contained in:
parent
152dfae591
commit
bc3e223265
168
ee/api/explicit_team_member.py
Normal file
168
ee/api/explicit_team_member.py
Normal file
@ -0,0 +1,168 @@
|
||||
from typing import Any, Dict, Optional, cast
|
||||
|
||||
from django.db.utils import IntegrityError
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import exceptions, serializers, viewsets
|
||||
from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated
|
||||
|
||||
from ee.models.explicit_team_membership import ExplicitTeamMembership
|
||||
from posthog.api.routing import StructuredViewSetMixin
|
||||
from posthog.api.shared import UserBasicSerializer
|
||||
from posthog.models.organization import OrganizationMembership
|
||||
from posthog.models.team import Team
|
||||
from posthog.models.user import User
|
||||
|
||||
|
||||
def get_ephemeral_requesting_team_membership(team: Team, user: User) -> Optional[ExplicitTeamMembership]:
|
||||
"""Return an ExplicitTeamMembership instance only for permission checking.
|
||||
None returned if the user has no explicit membership and organization access is too low for implicit membership."""
|
||||
requesting_parent_membership: OrganizationMembership = OrganizationMembership.objects.get(
|
||||
organization_id=team.organization_id, user=user
|
||||
)
|
||||
try:
|
||||
return ExplicitTeamMembership.objects.select_related(
|
||||
"team", "parent_membership", "parent_membership__user"
|
||||
).get(team=team, parent_membership=requesting_parent_membership)
|
||||
except ExplicitTeamMembership.DoesNotExist:
|
||||
# If there's no explicit team membership, we instantiate an ephemeral one just for validation
|
||||
if requesting_parent_membership.level < OrganizationMembership.Level.ADMIN:
|
||||
# Only organizations admins and above get implicit project membership
|
||||
return None
|
||||
return ExplicitTeamMembership(
|
||||
team=team, parent_membership=requesting_parent_membership, level=requesting_parent_membership.level
|
||||
)
|
||||
|
||||
|
||||
class TeamMemberObjectPermissions(BasePermission):
|
||||
"""
|
||||
Require effective project membership for any access at all,
|
||||
and at least admin effective project access level for write/delete.
|
||||
"""
|
||||
|
||||
message = "You don't have sufficient permissions in this project."
|
||||
|
||||
def has_permission(self, request, view) -> bool:
|
||||
try:
|
||||
team = Team.objects.get(id=view.get_parents_query_dict()["team_id"])
|
||||
except Team.DoesNotExist:
|
||||
return True # This will be handled as a 404 in the viewset
|
||||
try:
|
||||
requesting_team_membership = get_ephemeral_requesting_team_membership(team, cast(User, request.user))
|
||||
except OrganizationMembership.DoesNotExist:
|
||||
return True # This will be handled as a 404 too
|
||||
if requesting_team_membership is None:
|
||||
return False
|
||||
minimum_level = (
|
||||
ExplicitTeamMembership.Level.MEMBER
|
||||
if request.method in SAFE_METHODS
|
||||
else ExplicitTeamMembership.Level.ADMIN
|
||||
)
|
||||
return requesting_team_membership.effective_level >= minimum_level
|
||||
|
||||
|
||||
class ExplicitTeamMemberSerializer(serializers.ModelSerializer):
|
||||
user = UserBasicSerializer(source="parent_membership.user", read_only=True)
|
||||
parent_level = serializers.IntegerField(source="parent_membership.level", read_only=True)
|
||||
|
||||
user_uuid = serializers.UUIDField(required=True, write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ExplicitTeamMembership
|
||||
fields = [
|
||||
"id",
|
||||
"level",
|
||||
"parent_level",
|
||||
"parent_membership_id",
|
||||
"joined_at",
|
||||
"updated_at",
|
||||
"user",
|
||||
"user_uuid", # write_only (see above)
|
||||
"effective_level", # read_only (calculated)
|
||||
]
|
||||
read_only_fields = ["id", "parent_membership_id", "joined_at", "updated_at", "user", "effective_level"]
|
||||
|
||||
def create(self, validated_data):
|
||||
team: Team = self.context["team"]
|
||||
user_uuid = validated_data.pop("user_uuid")
|
||||
validated_data["team"] = team
|
||||
try:
|
||||
requesting_parent_membership: OrganizationMembership = OrganizationMembership.objects.get(
|
||||
organization_id=team.organization_id, user__uuid=user_uuid
|
||||
)
|
||||
except OrganizationMembership.DoesNotExist:
|
||||
raise exceptions.PermissionDenied("You both need to belong to the same organization.")
|
||||
validated_data["parent_membership"] = requesting_parent_membership
|
||||
try:
|
||||
return super().create(validated_data)
|
||||
except IntegrityError:
|
||||
raise exceptions.ValidationError("This user likely already is an explicit member of the project.")
|
||||
|
||||
def validate(self, attrs):
|
||||
team: Team = self.context["team"]
|
||||
if not team.access_control:
|
||||
raise exceptions.ValidationError(
|
||||
"Explicit members can only be accessed for projects with project-based permissioning enabled."
|
||||
)
|
||||
requesting_user: User = self.context["request"].user
|
||||
membership_being_accessed = cast(Optional[ExplicitTeamMembership], self.instance)
|
||||
try:
|
||||
requesting_membership = get_ephemeral_requesting_team_membership(self.context["team"], requesting_user)
|
||||
except OrganizationMembership.DoesNotExist:
|
||||
# Requesting user does not belong to the project's organization, so we spoof a 404 for enhanced security
|
||||
raise exceptions.NotFound("Project not found.")
|
||||
|
||||
new_level = attrs.get("level")
|
||||
|
||||
if requesting_membership is None:
|
||||
raise exceptions.PermissionDenied("You do not have the required access to this project.")
|
||||
|
||||
if attrs.get("user_uuid") == requesting_user.uuid:
|
||||
# Create-only check
|
||||
raise exceptions.PermissionDenied("You can't explicitly add yourself to projects.")
|
||||
|
||||
if new_level is not None and new_level > requesting_membership.effective_level:
|
||||
raise exceptions.PermissionDenied("You can only set access level to lower or equal to your current one.")
|
||||
|
||||
if membership_being_accessed is not None:
|
||||
# Update-only checks
|
||||
if membership_being_accessed.parent_membership.user_id != requesting_membership.parent_membership.user_id:
|
||||
# Requesting user updating someone else
|
||||
if membership_being_accessed.team.organization_id != requesting_membership.team.organization_id:
|
||||
raise exceptions.PermissionDenied("You both need to belong to the same organization.")
|
||||
if membership_being_accessed.level > requesting_membership.effective_level:
|
||||
raise exceptions.PermissionDenied("You can only edit others with level lower or equal to you.")
|
||||
else:
|
||||
# Requesting user updating themselves
|
||||
if new_level is not None:
|
||||
raise exceptions.PermissionDenied("You can't set your own access level.")
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class ExplicitTeamMemberViewSet(
|
||||
StructuredViewSetMixin, viewsets.ModelViewSet,
|
||||
):
|
||||
permission_classes = [IsAuthenticated, TeamMemberObjectPermissions]
|
||||
pagination_class = None
|
||||
queryset = ExplicitTeamMembership.objects.select_related("team", "parent_membership", "parent_membership__user")
|
||||
lookup_field = "parent_membership__user__uuid"
|
||||
ordering = ["level", "-joined_at"]
|
||||
serializer_class = ExplicitTeamMemberSerializer
|
||||
|
||||
def get_serializer_context(self) -> Dict[str, Any]:
|
||||
serializer_context = super().get_serializer_context()
|
||||
try:
|
||||
serializer_context["team"] = Team.objects.get(id=serializer_context["team_id"])
|
||||
except Team.DoesNotExist:
|
||||
raise exceptions.NotFound("Project not found.")
|
||||
return serializer_context
|
||||
|
||||
def get_object(self) -> ExplicitTeamMembership:
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
lookup_value = self.kwargs[self.lookup_field]
|
||||
if lookup_value == "@me":
|
||||
return queryset.get(user=self.request.user)
|
||||
filter_kwargs = {self.lookup_field: lookup_value}
|
||||
obj = get_object_or_404(queryset, **filter_kwargs)
|
||||
self.check_object_permissions(self.request, obj)
|
||||
return obj
|
@ -1,6 +1,12 @@
|
||||
import datetime as dt
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from freezegun.api import freeze_time
|
||||
from rest_framework import status
|
||||
|
||||
from ee.api.test.base import APILicensedTest
|
||||
from ee.models.license import License
|
||||
from posthog.celery import sync_all_organization_available_features
|
||||
from posthog.models import Team, User
|
||||
from posthog.models.organization import Organization, OrganizationMembership
|
||||
|
||||
@ -141,3 +147,34 @@ class TestOrganizationEnterpriseAPI(APILicensedTest):
|
||||
self.assertEqual(response.status_code, 404, potential_err_message)
|
||||
organization.refresh_from_db()
|
||||
self.assertTrue(organization.name, "Meow")
|
||||
|
||||
@patch("posthog.models.organization.License.PLANS", {"enterprise": ["whatever"]})
|
||||
@patch("ee.models.license.requests.post")
|
||||
def test_feature_available_self_hosted_has_license(self, patch_post):
|
||||
with self.settings(MULTI_TENANCY=False):
|
||||
mock = Mock()
|
||||
mock.json.return_value = {"plan": "enterprise", "valid_until": dt.datetime.now() + dt.timedelta(days=1)}
|
||||
patch_post.return_value = mock
|
||||
License.objects.create(key="key")
|
||||
|
||||
# Still only old, empty available_features field value known
|
||||
self.assertFalse(self.organization.is_feature_available("whatever"))
|
||||
self.assertFalse(self.organization.is_feature_available("feature-doesnt-exist"))
|
||||
|
||||
# New available_features field value that was updated in DB on license creation is known after refresh
|
||||
self.organization.refresh_from_db()
|
||||
self.assertTrue(self.organization.is_feature_available("whatever"))
|
||||
self.assertFalse(self.organization.is_feature_available("feature-doesnt-exist"))
|
||||
|
||||
@patch("posthog.models.organization.License.PLANS", {"enterprise": ["whatever"]})
|
||||
def test_feature_available_self_hosted_no_license(self):
|
||||
self.assertFalse(self.organization.is_feature_available("whatever"))
|
||||
self.assertFalse(self.organization.is_feature_available("feature-doesnt-exist"))
|
||||
|
||||
@patch("posthog.models.organization.License.PLANS", {"enterprise": ["whatever"]})
|
||||
@patch("ee.models.license.requests.post")
|
||||
def test_feature_available_self_hosted_license_expired(self, patch_post):
|
||||
with freeze_time("2070-01-01T12:00:00.000Z"): # LicensedTestMixin enterprise license expires in 2038
|
||||
sync_all_organization_available_features() # This is normally ran every hour
|
||||
self.organization.refresh_from_db()
|
||||
self.assertFalse(self.organization.is_feature_available("whatever"))
|
||||
|
@ -7,7 +7,6 @@ from posthog.models.user import User
|
||||
|
||||
|
||||
class TestProjectEnterpriseAPI(APILicensedTest):
|
||||
|
||||
# Creating Projects
|
||||
def test_create_project(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.ADMIN
|
||||
@ -16,7 +15,14 @@ class TestProjectEnterpriseAPI(APILicensedTest):
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(Team.objects.count(), 2)
|
||||
response_data = response.json()
|
||||
self.assertEqual(response_data.get("name"), "Test")
|
||||
self.assertDictContainsSubset(
|
||||
{
|
||||
"name": "Test",
|
||||
"access_control": False,
|
||||
"effective_membership_level": OrganizationMembership.Level.ADMIN,
|
||||
},
|
||||
response_data,
|
||||
)
|
||||
self.assertEqual(self.organization.teams.count(), 2)
|
||||
|
||||
def test_non_admin_cannot_create_project(self):
|
||||
|
434
ee/api/test/test_team_memberships.py
Normal file
434
ee/api/test/test_team_memberships.py
Normal file
@ -0,0 +1,434 @@
|
||||
from rest_framework import status
|
||||
|
||||
from ee.api.test.base import APILicensedTest
|
||||
from ee.models.explicit_team_membership import ExplicitTeamMembership
|
||||
from posthog.models import OrganizationMembership, Team, User
|
||||
|
||||
|
||||
class TestTeamMembershipsAPI(APILicensedTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.team.access_control = True
|
||||
self.team.save()
|
||||
|
||||
def test_add_member_as_org_owner_allowed(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.OWNER
|
||||
self.organization_membership.save()
|
||||
|
||||
new_user: User = User.objects.create_and_join(self.organization, "rookie@posthog.com", None)
|
||||
|
||||
self.assertEqual(self.team.explicit_memberships.count(), 0)
|
||||
|
||||
response = self.client.post("/api/projects/@current/explicit_members/", {"user_uuid": new_user.uuid})
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictContainsSubset(
|
||||
{"effective_level": ExplicitTeamMembership.Level.MEMBER, "level": ExplicitTeamMembership.Level.MEMBER,},
|
||||
response_data,
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
self.assertEqual(self.team.explicit_memberships.count(), 1)
|
||||
|
||||
def test_add_member_as_org_admin_allowed(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.ADMIN
|
||||
self.organization_membership.save()
|
||||
|
||||
new_user: User = User.objects.create_and_join(self.organization, "rookie@posthog.com", None)
|
||||
|
||||
self.assertEqual(self.team.explicit_memberships.count(), 0)
|
||||
|
||||
response = self.client.post("/api/projects/@current/explicit_members/", {"user_uuid": new_user.uuid})
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictContainsSubset(
|
||||
{"effective_level": ExplicitTeamMembership.Level.MEMBER, "level": ExplicitTeamMembership.Level.MEMBER,},
|
||||
response_data,
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
self.assertEqual(self.team.explicit_memberships.count(), 1)
|
||||
|
||||
def test_add_member_as_org_member_forbidden(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.MEMBER
|
||||
self.organization_membership.save()
|
||||
|
||||
new_user: User = User.objects.create_and_join(self.organization, "rookie@posthog.com", None)
|
||||
|
||||
self.assertEqual(self.team.explicit_memberships.count(), 0)
|
||||
|
||||
response = self.client.post("/api/projects/@current/explicit_members/", {"user_uuid": new_user.uuid})
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictEqual(
|
||||
self.permission_denied_response("You don't have sufficient permissions in this project."), response_data
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
self.assertEqual(self.team.explicit_memberships.count(), 0)
|
||||
|
||||
def test_add_yourself_as_org_member_forbidden(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.MEMBER
|
||||
self.organization_membership.save()
|
||||
|
||||
self.assertEqual(self.team.explicit_memberships.count(), 0)
|
||||
|
||||
response = self.client.post("/api/projects/@current/explicit_members/", {"user_uuid": self.user.uuid})
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictEqual(
|
||||
self.permission_denied_response("You don't have sufficient permissions in this project."), response_data
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
self.assertEqual(self.team.explicit_memberships.count(), 0)
|
||||
|
||||
def test_add_yourself_as_org_admin_forbidden(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.ADMIN
|
||||
self.organization_membership.save()
|
||||
|
||||
self.assertEqual(self.team.explicit_memberships.count(), 0)
|
||||
|
||||
response = self.client.post("/api/projects/@current/explicit_members/", {"user_uuid": self.user.uuid})
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictEqual(
|
||||
self.permission_denied_response("You can't explicitly add yourself to projects."), response_data
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
self.assertEqual(self.team.explicit_memberships.count(), 0)
|
||||
|
||||
def test_add_member_as_org_member_and_project_member_forbidden(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.MEMBER
|
||||
self.organization_membership.save()
|
||||
ExplicitTeamMembership.objects.create(
|
||||
team=self.team, parent_membership=self.organization_membership, level=ExplicitTeamMembership.Level.MEMBER
|
||||
)
|
||||
|
||||
new_user: User = User.objects.create_and_join(self.organization, "rookie@posthog.com", None)
|
||||
|
||||
self.assertEqual(self.team.explicit_memberships.count(), 1)
|
||||
|
||||
response = self.client.post("/api/projects/@current/explicit_members/", {"user_uuid": new_user.uuid})
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictEqual(
|
||||
self.permission_denied_response("You don't have sufficient permissions in this project."), response_data
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
self.assertEqual(self.team.explicit_memberships.count(), 1)
|
||||
|
||||
def test_add_member_as_org_member_but_project_admin_allowed(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.MEMBER
|
||||
self.organization_membership.save()
|
||||
ExplicitTeamMembership.objects.create(
|
||||
team=self.team, parent_membership=self.organization_membership, level=ExplicitTeamMembership.Level.ADMIN
|
||||
)
|
||||
|
||||
self.assertEqual(self.team.explicit_memberships.count(), 1)
|
||||
|
||||
new_user: User = User.objects.create_and_join(self.organization, "rookie@posthog.com", None)
|
||||
|
||||
response = self.client.post("/api/projects/@current/explicit_members/", {"user_uuid": new_user.uuid})
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictContainsSubset(
|
||||
{"effective_level": ExplicitTeamMembership.Level.MEMBER, "level": ExplicitTeamMembership.Level.MEMBER,},
|
||||
response_data,
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
self.assertEqual(self.team.explicit_memberships.count(), 2)
|
||||
|
||||
def test_add_member_as_org_admin_and_project_member_allowed(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.ADMIN
|
||||
self.organization_membership.save()
|
||||
ExplicitTeamMembership.objects.create(
|
||||
team=self.team, parent_membership=self.organization_membership, level=ExplicitTeamMembership.Level.MEMBER
|
||||
)
|
||||
|
||||
new_user: User = User.objects.create_and_join(self.organization, "rookie@posthog.com", None)
|
||||
|
||||
response = self.client.post("/api/projects/@current/explicit_members/", {"user_uuid": new_user.uuid})
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictContainsSubset(
|
||||
{"effective_level": ExplicitTeamMembership.Level.MEMBER, "level": ExplicitTeamMembership.Level.MEMBER,},
|
||||
response_data,
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
def test_add_admin_as_org_admin_allowed(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.ADMIN
|
||||
self.organization_membership.save()
|
||||
|
||||
new_user: User = User.objects.create_and_join(self.organization, "rookie@posthog.com", None)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/projects/@current/explicit_members/",
|
||||
{"user_uuid": new_user.uuid, "level": ExplicitTeamMembership.Level.ADMIN},
|
||||
)
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictContainsSubset(
|
||||
{"effective_level": ExplicitTeamMembership.Level.ADMIN, "level": ExplicitTeamMembership.Level.ADMIN,},
|
||||
response_data,
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
def test_add_admin_as_project_member_forbidden(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.MEMBER
|
||||
self.organization_membership.save()
|
||||
ExplicitTeamMembership.objects.create(
|
||||
team=self.team, parent_membership=self.organization_membership, level=ExplicitTeamMembership.Level.MEMBER
|
||||
)
|
||||
|
||||
new_user: User = User.objects.create_and_join(self.organization, "rookie@posthog.com", None)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/projects/@current/explicit_members/",
|
||||
{"user_uuid": new_user.uuid, "level": ExplicitTeamMembership.Level.ADMIN},
|
||||
)
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictEqual(
|
||||
self.permission_denied_response("You don't have sufficient permissions in this project."), response_data
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_add_admin_as_project_admin_allowed(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.ADMIN
|
||||
self.organization_membership.save()
|
||||
ExplicitTeamMembership.objects.create(
|
||||
team=self.team, parent_membership=self.organization_membership, level=ExplicitTeamMembership.Level.ADMIN
|
||||
)
|
||||
|
||||
new_user: User = User.objects.create_and_join(self.organization, "rookie@posthog.com", None)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/projects/@current/explicit_members/",
|
||||
{"user_uuid": new_user.uuid, "level": ExplicitTeamMembership.Level.ADMIN},
|
||||
)
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictContainsSubset(
|
||||
{"effective_level": ExplicitTeamMembership.Level.ADMIN, "level": ExplicitTeamMembership.Level.ADMIN,},
|
||||
response_data,
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
def test_add_member_to_non_current_project_allowed(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.ADMIN
|
||||
self.organization_membership.save()
|
||||
another_team = Team.objects.create(organization=self.organization, access_control=True)
|
||||
|
||||
new_user: User = User.objects.create_and_join(
|
||||
self.organization, "rookie@posthog.com", None,
|
||||
)
|
||||
|
||||
response = self.client.post(f"/api/projects/{another_team.id}/explicit_members/", {"user_uuid": new_user.uuid})
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictContainsSubset(
|
||||
{"effective_level": ExplicitTeamMembership.Level.MEMBER, "level": ExplicitTeamMembership.Level.MEMBER,},
|
||||
response_data,
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
def test_add_member_to_project_in_outside_organization_forbidden(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.ADMIN
|
||||
self.organization_membership.save()
|
||||
_, new_team, new_user = User.objects.bootstrap(
|
||||
"Acme", "mallory@acme.com", None, team_fields={"access_control": True}
|
||||
)
|
||||
|
||||
response = self.client.post(f"/api/projects/{new_team.id}/explicit_members/", {"user_uuid": new_user.uuid,})
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictEqual(self.not_found_response("Project not found."), response_data)
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_add_member_to_project_that_is_not_organization_member_forbidden(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.ADMIN
|
||||
self.organization_membership.save()
|
||||
_, new_team, new_user = User.objects.bootstrap("Acme", "mallory@acme.com", None)
|
||||
|
||||
response = self.client.post(f"/api/projects/@current/explicit_members/", {"user_uuid": new_user.uuid,})
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictEqual(
|
||||
self.permission_denied_response("You both need to belong to the same organization."), response_data
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_add_member_to_nonexistent_project_forbidden(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.ADMIN
|
||||
self.organization_membership.save()
|
||||
new_user: User = User.objects.create_and_join(self.organization, "rookie@posthog.com", None)
|
||||
|
||||
response = self.client.post(f"/api/projects/2137/explicit_members/", {"user_uuid": new_user.uuid,})
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictEqual(self.not_found_response("Project not found."), response_data)
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_set_level_of_member_to_admin_as_org_owner_allowed(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.OWNER
|
||||
self.organization_membership.save()
|
||||
|
||||
new_user: User = User.objects.create_and_join(self.organization, "rookie@posthog.com", None)
|
||||
new_org_membership: OrganizationMembership = OrganizationMembership.objects.get(
|
||||
user=new_user, organization=self.organization
|
||||
)
|
||||
new_team_membership = ExplicitTeamMembership.objects.create(
|
||||
team=self.team, parent_membership=new_org_membership
|
||||
)
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/projects/@current/explicit_members/{new_user.uuid}", {"level": ExplicitTeamMembership.Level.ADMIN}
|
||||
)
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictContainsSubset(
|
||||
{"effective_level": ExplicitTeamMembership.Level.ADMIN, "level": ExplicitTeamMembership.Level.ADMIN,},
|
||||
response_data,
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_set_level_of_member_to_admin_as_org_member_forbidden(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.MEMBER
|
||||
self.organization_membership.save()
|
||||
|
||||
new_user: User = User.objects.create_and_join(self.organization, "rookie@posthog.com", None)
|
||||
new_org_membership: OrganizationMembership = OrganizationMembership.objects.get(
|
||||
user=new_user, organization=self.organization
|
||||
)
|
||||
new_team_membership = ExplicitTeamMembership.objects.create(
|
||||
team=self.team, parent_membership=new_org_membership
|
||||
)
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/projects/@current/explicit_members/{new_user.uuid}", {"level": ExplicitTeamMembership.Level.ADMIN}
|
||||
)
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictEqual(
|
||||
self.permission_denied_response("You don't have sufficient permissions in this project."), response_data,
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_demote_yourself_as_org_member_and_project_admin_forbidden(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.MEMBER
|
||||
self.organization_membership.save()
|
||||
self_team_membership = ExplicitTeamMembership.objects.create(
|
||||
team=self.team, parent_membership=self.organization_membership, level=ExplicitTeamMembership.Level.ADMIN
|
||||
)
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/projects/@current/explicit_members/{self.user.uuid}", {"level": ExplicitTeamMembership.Level.MEMBER}
|
||||
)
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictEqual(
|
||||
self.permission_denied_response("You can't set your own access level."), response_data,
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_set_level_of_member_to_admin_as_org_member_but_project_admin_allowed(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.MEMBER
|
||||
self.organization_membership.save()
|
||||
self_team_membership = ExplicitTeamMembership.objects.create(
|
||||
team=self.team, parent_membership=self.organization_membership, level=ExplicitTeamMembership.Level.ADMIN
|
||||
)
|
||||
|
||||
new_user: User = User.objects.create_and_join(self.organization, "rookie@posthog.com", None)
|
||||
new_org_membership: OrganizationMembership = OrganizationMembership.objects.get(
|
||||
user=new_user, organization=self.organization
|
||||
)
|
||||
new_team_membership = ExplicitTeamMembership.objects.create(
|
||||
team=self.team, parent_membership=new_org_membership
|
||||
)
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/projects/@current/explicit_members/{new_user.uuid}", {"level": ExplicitTeamMembership.Level.ADMIN}
|
||||
)
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictContainsSubset(
|
||||
{"effective_level": ExplicitTeamMembership.Level.ADMIN, "level": ExplicitTeamMembership.Level.ADMIN,},
|
||||
response_data,
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_remove_member_as_org_admin_allowed(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.ADMIN
|
||||
self.organization_membership.save()
|
||||
|
||||
new_user: User = User.objects.create_and_join(self.organization, "rookie@posthog.com", None)
|
||||
new_org_membership: OrganizationMembership = OrganizationMembership.objects.get(
|
||||
user=new_user, organization=self.organization
|
||||
)
|
||||
new_team_membership = ExplicitTeamMembership.objects.create(
|
||||
team=self.team, parent_membership=new_org_membership
|
||||
)
|
||||
|
||||
response = self.client.delete(f"/api/projects/@current/explicit_members/{new_user.uuid}")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def test_remove_member_as_org_member_allowed(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.MEMBER
|
||||
self.organization_membership.save()
|
||||
|
||||
new_user: User = User.objects.create_and_join(self.organization, "rookie@posthog.com", None)
|
||||
new_org_membership: OrganizationMembership = OrganizationMembership.objects.get(
|
||||
user=new_user, organization=self.organization
|
||||
)
|
||||
new_team_membership = ExplicitTeamMembership.objects.create(
|
||||
team=self.team, parent_membership=new_org_membership
|
||||
)
|
||||
|
||||
response = self.client.delete(f"/api/projects/@current/explicit_members/{new_user.uuid}")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_remove_member_as_org_member_but_project_admin_allowed(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.MEMBER
|
||||
self.organization_membership.save()
|
||||
self_team_membership = ExplicitTeamMembership.objects.create(
|
||||
team=self.team, parent_membership=self.organization_membership, level=ExplicitTeamMembership.Level.ADMIN
|
||||
)
|
||||
|
||||
new_user: User = User.objects.create_and_join(self.organization, "rookie@posthog.com", None)
|
||||
new_org_membership: OrganizationMembership = OrganizationMembership.objects.get(
|
||||
user=new_user, organization=self.organization
|
||||
)
|
||||
new_team_membership = ExplicitTeamMembership.objects.create(
|
||||
team=self.team, parent_membership=new_org_membership
|
||||
)
|
||||
|
||||
response = self.client.delete(f"/api/projects/@current/explicit_members/{new_user.uuid}")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def test_add_member_to_non_private_project_forbidden(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.OWNER
|
||||
self.organization_membership.save()
|
||||
self.team.access_control = False
|
||||
self.team.save()
|
||||
|
||||
new_user: User = User.objects.create_and_join(self.organization, "rookie@posthog.com", None)
|
||||
|
||||
response = self.client.post("/api/projects/@current/explicit_members/", {"user_uuid": new_user.uuid})
|
||||
response_data = response.json()
|
||||
|
||||
self.assertDictEqual(
|
||||
self.validation_error_response(
|
||||
"Explicit members can only be accessed for projects with project-based permissioning enabled.",
|
||||
attr="non_field_errors",
|
||||
),
|
||||
response_data,
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
57
ee/migrations/0005_project_based_permissioning.py
Normal file
57
ee/migrations/0005_project_based_permissioning.py
Normal file
@ -0,0 +1,57 @@
|
||||
# Generated by Django 3.2.5 on 2021-09-10 11:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import posthog.models.utils
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("posthog", "0170_project_based_permissioning"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("ee", "0004_enterpriseeventdefinition_enterprisepropertydefinition"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ExplicitTeamMembership",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("level", models.PositiveSmallIntegerField(choices=[(1, "member"), (8, "administrator")], default=1)),
|
||||
("joined_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"team",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="explicit_memberships",
|
||||
related_query_name="explicit_membership",
|
||||
to="posthog.team",
|
||||
),
|
||||
),
|
||||
(
|
||||
"parent_membership",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="explicit_team_memberships",
|
||||
related_query_name="explicit_team_membership",
|
||||
to="posthog.organizationmembership",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="explicitteammembership",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("team", "parent_membership"), name="unique_explicit_team_membership"
|
||||
),
|
||||
),
|
||||
]
|
@ -1,4 +1,5 @@
|
||||
from .event_definition import EventDefinition
|
||||
from .explicit_team_membership import ExplicitTeamMembership
|
||||
from .hook import Hook
|
||||
from .license import License
|
||||
from .property_definition import PropertyDefinition
|
||||
|
50
ee/models/explicit_team_membership.py
Normal file
50
ee/models/explicit_team_membership.py
Normal file
@ -0,0 +1,50 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.db import models
|
||||
|
||||
from posthog.models.utils import UUIDModel, sane_repr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from posthog.models.organization import OrganizationMembership
|
||||
|
||||
|
||||
class ExplicitTeamMembership(UUIDModel):
|
||||
class Level(models.IntegerChoices):
|
||||
"""Keep in sync with OrganizationMembership.Level (only difference being organizations having an Owner)."""
|
||||
|
||||
MEMBER = 1, "member"
|
||||
ADMIN = 8, "administrator"
|
||||
|
||||
team: models.ForeignKey = models.ForeignKey(
|
||||
"posthog.Team",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="explicit_memberships",
|
||||
related_query_name="explicit_membership",
|
||||
)
|
||||
parent_membership: models.ForeignKey = models.ForeignKey(
|
||||
"posthog.OrganizationMembership",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="explicit_team_memberships",
|
||||
related_query_name="explicit_team_membership",
|
||||
)
|
||||
level: models.PositiveSmallIntegerField = models.PositiveSmallIntegerField(
|
||||
default=Level.MEMBER, choices=Level.choices
|
||||
)
|
||||
joined_at: models.DateTimeField = models.DateTimeField(auto_now_add=True)
|
||||
updated_at: models.DateTimeField = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["team", "parent_membership"], name="unique_explicit_team_membership"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return str(self.Level(self.level))
|
||||
|
||||
@property
|
||||
def effective_level(self) -> "OrganizationMembership.Level":
|
||||
"""If organization level is higher than project level, then that takes precedence over explicit project level.
|
||||
"""
|
||||
return max(self.level, self.parent_membership.level)
|
||||
|
||||
__repr__ = sane_repr("team", "parent_membership", "level")
|
@ -56,6 +56,7 @@ class License(models.Model):
|
||||
ENTERPRISE_FEATURES = [
|
||||
AvailableFeature.ZAPIER,
|
||||
AvailableFeature.ORGANIZATIONS_PROJECTS,
|
||||
AvailableFeature.PROJECT_BASED_PERMISSIONING,
|
||||
AvailableFeature.GOOGLE_LOGIN,
|
||||
AvailableFeature.SAML,
|
||||
AvailableFeature.DASHBOARD_COLLABORATION,
|
||||
|
@ -1,18 +1,20 @@
|
||||
from typing import Any, List
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls.conf import path
|
||||
from rest_framework_extensions.routers import NestedRegistryItem
|
||||
|
||||
from posthog.api.routing import DefaultRouterPlusPlus
|
||||
|
||||
from .api import authentication, debug_ch_queries, hooks, license
|
||||
from .api import authentication, debug_ch_queries, explicit_team_member, hooks, license
|
||||
|
||||
|
||||
def extend_api_router(root_router: DefaultRouterPlusPlus, *, projects_router: NestedRegistryItem):
|
||||
root_router.register(r"license", license.LicenseViewSet)
|
||||
root_router.register(r"debug_ch_queries", debug_ch_queries.DebugCHQueries, "debug_ch_queries")
|
||||
projects_router.register(r"hooks", hooks.HookViewSet, "project_hooks", ["team_id"])
|
||||
projects_router.register(
|
||||
r"explicit_members", explicit_team_member.ExplicitTeamMemberViewSet, "project_explicit_members", ["team_id"]
|
||||
)
|
||||
|
||||
|
||||
urlpatterns: List[Any] = [
|
||||
|
@ -36,7 +36,7 @@ export function RestrictedArea({ Component, minimumAccessLevel }: RestrictedArea
|
||||
}, [currentOrganization])
|
||||
|
||||
return restrictionReason ? (
|
||||
<Tooltip title={restrictionReason} placement="topLeft">
|
||||
<Tooltip title={restrictionReason} placement="topLeft" delayMs={0}>
|
||||
<span>
|
||||
<Component isRestricted={true} restrictionReason={restrictionReason} />
|
||||
</span>
|
||||
|
@ -18,6 +18,11 @@ export enum OrganizationMembershipLevel {
|
||||
Owner = 15,
|
||||
}
|
||||
|
||||
export enum TeamMembershipLevel {
|
||||
Member = 1,
|
||||
Admin = 8,
|
||||
}
|
||||
|
||||
/** See posthog/api/organization.py for details. */
|
||||
export enum PluginsAccessLevel {
|
||||
None = 0,
|
||||
@ -242,6 +247,7 @@ export const FEATURE_FLAGS = {
|
||||
FUNNEL_HORIZONTAL_UI: '5730-funnel-horizontal-ui',
|
||||
PLUGINS_UI_JOBS: '5720-plugins-ui-jobs',
|
||||
DIVE_DASHBOARDS: 'hackathon-dive-dashboards',
|
||||
PROJECT_BASED_PERMISSIONING: 'project-based-permissioning',
|
||||
SPLIT_PERSON: '5898-split-persons',
|
||||
TOOLBAR_FEATURE_FLAGS: 'posthog-toolbar-feature-flags',
|
||||
FUNNEL_VERTICAL_BREAKDOWN: '5733-funnel-vertical-breakdown',
|
||||
|
@ -1,19 +1,29 @@
|
||||
import { OrganizationMemberType, UserType } from '../../types'
|
||||
import { OrganizationMembershipLevel } from '../constants'
|
||||
import { ExplicitTeamMemberType, OrganizationMemberType, UserType } from '../../types'
|
||||
import { OrganizationMembershipLevel, TeamMembershipLevel } from '../constants'
|
||||
|
||||
export type EitherMembershipLevel = OrganizationMembershipLevel | TeamMembershipLevel
|
||||
export type EitherMemberType = OrganizationMemberType | ExplicitTeamMemberType
|
||||
|
||||
/** If access level change is disallowed given the circumstances, returns a reason why so. Otherwise returns null. */
|
||||
export function getReasonForAccessLevelChangeProhibition(
|
||||
currentMembershipLevel: OrganizationMembershipLevel | null,
|
||||
currentUser: UserType,
|
||||
memberChanged: OrganizationMemberType,
|
||||
newLevelOrAllowedLevels: OrganizationMembershipLevel | OrganizationMembershipLevel[]
|
||||
memberToBeUpdated: EitherMemberType,
|
||||
newLevelOrAllowedLevels: EitherMembershipLevel | EitherMembershipLevel[]
|
||||
): null | string {
|
||||
if (memberChanged.user.uuid === currentUser.uuid) {
|
||||
if (memberToBeUpdated.user.uuid === currentUser.uuid) {
|
||||
return "You can't change your own access level."
|
||||
}
|
||||
if (!currentMembershipLevel) {
|
||||
return 'Your membership level is unknown.'
|
||||
}
|
||||
let effectiveLevelToBeUpdated: OrganizationMembershipLevel
|
||||
if ('effectiveLevel' in (memberToBeUpdated as ExplicitTeamMemberType)) {
|
||||
// In EitherMemberType only ExplicitTeamMemberType has effectiveLevel
|
||||
effectiveLevelToBeUpdated = (memberToBeUpdated as ExplicitTeamMemberType).effective_level
|
||||
} else {
|
||||
effectiveLevelToBeUpdated = (memberToBeUpdated as OrganizationMemberType).level
|
||||
}
|
||||
if (Array.isArray(newLevelOrAllowedLevels)) {
|
||||
if (currentMembershipLevel === OrganizationMembershipLevel.Owner) {
|
||||
return null
|
||||
@ -22,7 +32,7 @@ export function getReasonForAccessLevelChangeProhibition(
|
||||
return "You don't have permission to change this member's access level."
|
||||
}
|
||||
} else {
|
||||
if (newLevelOrAllowedLevels === memberChanged.level) {
|
||||
if (newLevelOrAllowedLevels === effectiveLevelToBeUpdated) {
|
||||
return "It doesn't make sense to set the same level as before."
|
||||
}
|
||||
if (currentMembershipLevel === OrganizationMembershipLevel.Owner) {
|
||||
@ -35,7 +45,7 @@ export function getReasonForAccessLevelChangeProhibition(
|
||||
if (currentMembershipLevel < OrganizationMembershipLevel.Admin) {
|
||||
return "You don't have permission to change access levels."
|
||||
}
|
||||
if (currentMembershipLevel < memberChanged.level) {
|
||||
if (currentMembershipLevel < effectiveLevelToBeUpdated) {
|
||||
return 'You can only change access level of members with level lower or equal to you.'
|
||||
}
|
||||
return null
|
||||
|
@ -109,7 +109,7 @@ function EmailPreferences({ isRestricted }: RestrictedComponentProps): JSX.Eleme
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 id="name" className="subtitle">
|
||||
<h2 id="notification-preferences" className="subtitle">
|
||||
Notification Preferences
|
||||
</h2>
|
||||
<div>
|
||||
@ -139,6 +139,7 @@ function EmailPreferences({ isRestricted }: RestrictedComponentProps): JSX.Eleme
|
||||
|
||||
export function OrganizationSettings({ user }: { user: UserType }): JSX.Element {
|
||||
const { preflight } = useValues(preflightLogic)
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
|
73
frontend/src/scenes/project/Settings/AccessControl.tsx
Normal file
73
frontend/src/scenes/project/Settings/AccessControl.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import React from 'react'
|
||||
import { Switch } from 'antd'
|
||||
import { AvailableFeature } from '~/types'
|
||||
import { organizationLogic } from '../../organizationLogic'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { RestrictedComponentProps } from '../../../lib/components/RestrictedArea'
|
||||
import { sceneLogic } from '../../sceneLogic'
|
||||
import { teamLogic } from '../../teamLogic'
|
||||
import { LockOutlined, UnlockOutlined } from '@ant-design/icons'
|
||||
|
||||
export function AccessControl({ isRestricted }: RestrictedComponentProps): JSX.Element {
|
||||
const { currentOrganization, currentOrganizationLoading } = useValues(organizationLogic)
|
||||
const { currentTeam, currentTeamLoading } = useValues(teamLogic)
|
||||
const { updateCurrentTeam } = useActions(teamLogic)
|
||||
const { guardAvailableFeature } = useActions(sceneLogic)
|
||||
|
||||
const projectPermissioningEnabled =
|
||||
currentOrganization?.available_features.includes(AvailableFeature.PROJECT_BASED_PERMISSIONING) &&
|
||||
currentTeam?.access_control
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="subtitle" id="access-control">
|
||||
Access Control
|
||||
</h2>
|
||||
<p>
|
||||
{projectPermissioningEnabled ? (
|
||||
<>
|
||||
This project is{' '}
|
||||
<b>
|
||||
<LockOutlined style={{ color: 'var(--warning)', marginRight: 5 }} />
|
||||
private
|
||||
</b>
|
||||
. Only members listed below are allowed to access it.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This project is{' '}
|
||||
<b>
|
||||
<UnlockOutlined style={{ marginRight: 5 }} />
|
||||
open
|
||||
</b>
|
||||
. Any member of the organization can access it. To enable granular access control, make it
|
||||
private.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<Switch
|
||||
// @ts-expect-error - id works just fine despite not being in CompoundedComponent
|
||||
id="project-based-permissioning-switch"
|
||||
onChange={(checked) => {
|
||||
guardAvailableFeature(
|
||||
AvailableFeature.PROJECT_BASED_PERMISSIONING,
|
||||
'project-based permissioning',
|
||||
'Set permissions granularly for each project. Make sure only the right people have access to protected data.',
|
||||
() => updateCurrentTeam({ access_control: checked })
|
||||
)
|
||||
}}
|
||||
checked={projectPermissioningEnabled}
|
||||
loading={currentOrganizationLoading || currentTeamLoading}
|
||||
disabled={isRestricted || !currentOrganization || !currentTeam}
|
||||
/>
|
||||
<label
|
||||
style={{
|
||||
marginLeft: '10px',
|
||||
}}
|
||||
htmlFor="project-based-permissioning-switch"
|
||||
>
|
||||
Make project private
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
160
frontend/src/scenes/project/Settings/TeamMembers.tsx
Normal file
160
frontend/src/scenes/project/Settings/TeamMembers.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import React from 'react'
|
||||
import { Table, Button, Dropdown, Menu, Tooltip } from 'antd'
|
||||
import { useValues, useActions } from 'kea'
|
||||
import { teamMembersLogic } from './teamMembersLogic'
|
||||
import { DownOutlined, CrownFilled, UpOutlined } from '@ant-design/icons'
|
||||
import { humanFriendlyDetailedTime } from 'lib/utils'
|
||||
import { OrganizationMembershipLevel, organizationMembershipLevelToName, TeamMembershipLevel } from 'lib/constants'
|
||||
import { TeamType, UserType, FusedTeamMemberType } from '~/types'
|
||||
import { ColumnsType } from 'antd/lib/table'
|
||||
import { userLogic } from 'scenes/userLogic'
|
||||
import { ProfilePicture } from 'lib/components/ProfilePicture'
|
||||
import { teamLogic } from '../../teamLogic'
|
||||
import { getReasonForAccessLevelChangeProhibition } from '../../../lib/utils/permissioning'
|
||||
|
||||
const membershipLevelIntegers = Object.values(TeamMembershipLevel).filter(
|
||||
(value) => typeof value === 'number'
|
||||
) as TeamMembershipLevel[]
|
||||
|
||||
export function LevelComponent(member: FusedTeamMemberType): JSX.Element | null {
|
||||
const { user } = useValues(userLogic)
|
||||
const { currentTeam } = useValues(teamLogic)
|
||||
const { changeUserAccessLevel } = useActions(teamMembersLogic)
|
||||
|
||||
const myMembershipLevel = currentTeam ? currentTeam.effective_membership_level : null
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
function generateHandleClick(listLevel: TeamMembershipLevel): (event: React.MouseEvent) => void {
|
||||
return function handleClick(event: React.MouseEvent) {
|
||||
event.preventDefault()
|
||||
changeUserAccessLevel(member.user, listLevel)
|
||||
}
|
||||
}
|
||||
|
||||
const isImplicit = member.organization_level >= OrganizationMembershipLevel.Admin
|
||||
const levelName = organizationMembershipLevelToName.get(member.level) ?? `unknown (${member.level})`
|
||||
|
||||
const levelButton = (
|
||||
<Button
|
||||
data-attr="change-membership-level"
|
||||
icon={member.level === OrganizationMembershipLevel.Owner ? <CrownFilled /> : undefined}
|
||||
// Org admins have implicit access anyway, so it doesn't make sense to edit them
|
||||
disabled={isImplicit}
|
||||
>
|
||||
{levelName}
|
||||
</Button>
|
||||
)
|
||||
|
||||
const allowedLevels = membershipLevelIntegers.filter(
|
||||
(listLevel) => !getReasonForAccessLevelChangeProhibition(myMembershipLevel, user, member, listLevel)
|
||||
)
|
||||
const disallowedReason = isImplicit
|
||||
? `This user is a member of the project implicitly due to being an organization ${levelName}.`
|
||||
: getReasonForAccessLevelChangeProhibition(myMembershipLevel, user, member, allowedLevels)
|
||||
|
||||
return disallowedReason ? (
|
||||
<Tooltip title={disallowedReason}>{levelButton}</Tooltip>
|
||||
) : (
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
{allowedLevels.map((listLevel) => (
|
||||
<Menu.Item key={`${member.user.uuid}-level-${listLevel}`}>
|
||||
<a href="#" onClick={generateHandleClick(listLevel)} data-test-level={listLevel}>
|
||||
{listLevel > member.level ? (
|
||||
<>
|
||||
<UpOutlined style={{ marginRight: '0.5rem' }} />
|
||||
Upgrade to {organizationMembershipLevelToName.get(listLevel)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DownOutlined style={{ marginRight: '0.5rem' }} />
|
||||
Downgrade to {organizationMembershipLevelToName.get(listLevel)}
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
{levelButton}
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
export interface MembersProps {
|
||||
user: UserType
|
||||
team: TeamType
|
||||
}
|
||||
|
||||
export function TeamMembers({ user }: MembersProps): JSX.Element {
|
||||
const { allMembers, allMembersLoading } = useValues(teamMembersLogic)
|
||||
|
||||
const columns: ColumnsType<FusedTeamMemberType> = [
|
||||
{
|
||||
key: 'user_profile_picture',
|
||||
render: function ProfilePictureRender(_, member) {
|
||||
return <ProfilePicture name={member.user.first_name} email={member.user.email} />
|
||||
},
|
||||
width: 32,
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
key: 'user_first_name',
|
||||
render: (_, member) =>
|
||||
member.user.uuid == user.uuid ? `${member.user.first_name} (me)` : member.user.first_name,
|
||||
sorter: (a, b) => a.user.first_name.localeCompare(b.user.first_name),
|
||||
},
|
||||
{
|
||||
title: 'Email',
|
||||
key: 'user_email',
|
||||
render: (_, member) => member.user.email,
|
||||
sorter: (a, b) => a.user.email.localeCompare(b.user.email),
|
||||
},
|
||||
{
|
||||
title: 'Level',
|
||||
key: 'level',
|
||||
render: function LevelRender(_, member) {
|
||||
return LevelComponent(member)
|
||||
},
|
||||
sorter: (a, b) => a.level - b.level,
|
||||
defaultSortOrder: 'descend',
|
||||
},
|
||||
{
|
||||
title: 'Joined At',
|
||||
dataIndex: 'joined_at',
|
||||
key: 'joined_at',
|
||||
render: (joinedAt: string) => humanFriendlyDetailedTime(joinedAt),
|
||||
sorter: (a, b) => a.joined_at.localeCompare(b.joined_at),
|
||||
defaultSortOrder: 'ascend',
|
||||
},
|
||||
/*{
|
||||
key: 'actions',
|
||||
align: 'center',
|
||||
render: function ActionsRender(_, member) {
|
||||
return ActionsComponent(member)
|
||||
},
|
||||
},*/
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="subtitle" id="members-with-project-access">
|
||||
Members with Project Access
|
||||
</h2>
|
||||
<Table
|
||||
dataSource={allMembers}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
style={{ marginTop: '1rem' }}
|
||||
loading={allMembersLoading}
|
||||
data-attr="team-members-table"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { BindLogic, useActions, useValues } from 'kea'
|
||||
import { Button, Card, Divider, Input, Skeleton, Tag } from 'antd'
|
||||
import { IPCapture } from './IPCapture'
|
||||
import { JSSnippet } from 'lib/components/JSSnippet'
|
||||
@ -18,10 +18,16 @@ import { PageHeader } from 'lib/components/PageHeader'
|
||||
import { Link } from 'lib/components/Link'
|
||||
import { JSBookmarklet } from 'lib/components/JSBookmarklet'
|
||||
import { RestrictedArea } from '../../../lib/components/RestrictedArea'
|
||||
import { OrganizationMembershipLevel } from '../../../lib/constants'
|
||||
import { FEATURE_FLAGS, OrganizationMembershipLevel } from '../../../lib/constants'
|
||||
import { TestAccountFiltersConfig } from './TestAccountFiltersConfig'
|
||||
import { TimezoneConfig } from './TimezoneConfig'
|
||||
import { DataAttributes } from 'scenes/project/Settings/DataAttributes'
|
||||
import { organizationLogic } from '../../organizationLogic'
|
||||
import { featureFlagLogic } from '../../../lib/logic/featureFlagLogic'
|
||||
import { AvailableFeature, UserType } from '../../../types'
|
||||
import { TeamMembers } from './TeamMembers'
|
||||
import { teamMembersLogic } from './teamMembersLogic'
|
||||
import { AccessControl } from './AccessControl'
|
||||
|
||||
function DisplayName(): JSX.Element {
|
||||
const { currentTeam, currentTeamLoading } = useValues(teamLogic)
|
||||
@ -62,10 +68,12 @@ function DisplayName(): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectSettings(): JSX.Element {
|
||||
export function ProjectSettings({ user }: { user: UserType }): JSX.Element {
|
||||
const { currentTeam, currentTeamLoading } = useValues(teamLogic)
|
||||
const { currentOrganization } = useValues(organizationLogic)
|
||||
const { resetToken } = useActions(teamLogic)
|
||||
const { location } = useValues(router)
|
||||
const { featureFlags } = useValues(featureFlagLogic)
|
||||
|
||||
useAnchor(location.hash)
|
||||
|
||||
@ -223,6 +231,24 @@ export function ProjectSettings(): JSX.Element {
|
||||
</p>
|
||||
<SessionRecording />
|
||||
<Divider />
|
||||
{featureFlags[FEATURE_FLAGS.PROJECT_BASED_PERMISSIONING] && (
|
||||
<>
|
||||
<RestrictedArea
|
||||
Component={AccessControl}
|
||||
minimumAccessLevel={OrganizationMembershipLevel.Admin}
|
||||
/>
|
||||
<Divider />
|
||||
{currentTeam?.access_control &&
|
||||
currentOrganization?.available_features.includes(
|
||||
AvailableFeature.PROJECT_BASED_PERMISSIONING
|
||||
) && (
|
||||
<BindLogic logic={teamMembersLogic} props={{ team: currentTeam }}>
|
||||
<TeamMembers user={user} team={currentTeam} />
|
||||
<Divider />
|
||||
</BindLogic>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<RestrictedArea Component={DangerZone} minimumAccessLevel={OrganizationMembershipLevel.Admin} />
|
||||
</Card>
|
||||
</div>
|
||||
|
117
frontend/src/scenes/project/Settings/teamMembersLogic.tsx
Normal file
117
frontend/src/scenes/project/Settings/teamMembersLogic.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import React from 'react'
|
||||
import { kea } from 'kea'
|
||||
import api from 'lib/api'
|
||||
import { toast } from 'react-toastify'
|
||||
import { CheckCircleOutlined } from '@ant-design/icons'
|
||||
import { OrganizationMembershipLevel, organizationMembershipLevelToName, TeamMembershipLevel } from 'lib/constants'
|
||||
import {
|
||||
ExplicitTeamMemberType,
|
||||
FusedTeamMemberType,
|
||||
OrganizationMemberType,
|
||||
TeamType,
|
||||
UserBasicType,
|
||||
UserType,
|
||||
} from '~/types'
|
||||
import { teamMembersLogicType } from './teamMembersLogicType'
|
||||
import { membersLogic } from '../../organization/Settings/membersLogic'
|
||||
|
||||
export const teamMembersLogic = kea<teamMembersLogicType>({
|
||||
props: {} as {
|
||||
team: TeamType
|
||||
},
|
||||
key: (props) => props.team.id,
|
||||
actions: {
|
||||
changeUserAccessLevel: (user: UserBasicType, newLevel: TeamMembershipLevel) => ({
|
||||
user,
|
||||
newLevel,
|
||||
}),
|
||||
},
|
||||
loaders: ({ values }) => ({
|
||||
explicitMembers: {
|
||||
__default: [] as ExplicitTeamMemberType[],
|
||||
loadMembers: async () => {
|
||||
return await api.get('api/projects/@current/explicit_members/')
|
||||
},
|
||||
addMember: async (user: UserType) => {
|
||||
const newMember: ExplicitTeamMemberType = await api.create(`api/projects/@current/explicit_members/`, {
|
||||
user_id: user.id,
|
||||
})
|
||||
toast(
|
||||
<div>
|
||||
<h1 className="text-success">
|
||||
<CheckCircleOutlined /> Removed <b>{user.first_name}</b> from project.
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
return [...values.explicitMembers, newMember]
|
||||
},
|
||||
removeMember: async (member: ExplicitTeamMemberType) => {
|
||||
await api.delete(`api/projects/@current/explicit_members/${member.user.id}/`)
|
||||
toast(
|
||||
<div>
|
||||
<h1 className="text-success">
|
||||
<CheckCircleOutlined /> Removed <b>{member.user.first_name}</b> from project.
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
return values.explicitMembers.filter((thisMember) => thisMember.user.id !== member.user.id)
|
||||
},
|
||||
},
|
||||
}),
|
||||
selectors: ({ selectors }) => ({
|
||||
allMembers: [
|
||||
() => [selectors.explicitMembers, membersLogic.selectors.members],
|
||||
// Explicit project members joined with organization admins and owner (who get project access by default)
|
||||
(
|
||||
explicitMembers: ExplicitTeamMemberType[],
|
||||
organizationMembers: OrganizationMemberType[]
|
||||
): FusedTeamMemberType[] =>
|
||||
organizationMembers
|
||||
.filter(({ level }) => level >= OrganizationMembershipLevel.Admin)
|
||||
.map(
|
||||
(member) =>
|
||||
({
|
||||
...member,
|
||||
explicit_team_level: null,
|
||||
organization_level: member.level,
|
||||
} as FusedTeamMemberType)
|
||||
)
|
||||
.concat(
|
||||
explicitMembers
|
||||
.filter(({ parent_level }) => parent_level < OrganizationMembershipLevel.Admin)
|
||||
.map(
|
||||
(member) =>
|
||||
({
|
||||
...member,
|
||||
level: member.effective_level,
|
||||
explicit_team_level: member.level,
|
||||
organization_level: member.parent_level,
|
||||
} as FusedTeamMemberType)
|
||||
)
|
||||
),
|
||||
],
|
||||
allMembersLoading: [
|
||||
() => [selectors.explicitMembersLoading, membersLogic.selectors.membersLoading],
|
||||
// Explicit project members joined with organization admins and owner (who get project access by default)
|
||||
(explicitMembersLoading, organizationMembersLoading) =>
|
||||
explicitMembersLoading || organizationMembersLoading,
|
||||
],
|
||||
}),
|
||||
listeners: ({ actions }) => ({
|
||||
changeUserAccessLevel: async ({ user, newLevel }) => {
|
||||
await api.update(`api/projects/@current/explicit_members/${user.uuid}/`, { level: newLevel })
|
||||
toast(
|
||||
<div>
|
||||
<h1 className="text-success">
|
||||
<CheckCircleOutlined /> Made <b>{user.first_name}</b> project{' '}
|
||||
{organizationMembershipLevelToName.get(newLevel)}.
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
actions.loadMembers()
|
||||
},
|
||||
}),
|
||||
events: ({ actions }) => ({
|
||||
afterMount: actions.loadMembers,
|
||||
}),
|
||||
})
|
@ -33,6 +33,7 @@ import { DateFilter } from 'lib/components/DateFilter/DateFilter'
|
||||
import '../insights/InsightHistoryPanel/InsightHistoryPanel.scss'
|
||||
import dayjs from 'dayjs'
|
||||
import { PageHeader } from 'lib/components/PageHeader'
|
||||
|
||||
const { TabPane } = Tabs
|
||||
|
||||
interface InsightType {
|
||||
|
@ -21,6 +21,7 @@ Font weights: only `normal` (400), `medium` (500) or `bold` (700) should be used
|
||||
--bg-mid: #{$bg_mid};
|
||||
--bg-charcoal: #{$bg_charcoal};
|
||||
--text-default: #{$text_default};
|
||||
--text-muted: #{$text_muted};
|
||||
--text-light: #{$text_light};
|
||||
--muted: #{$text_muted};
|
||||
--muted-alt: #{$text_muted_alt};
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
COHORT_DYNAMIC,
|
||||
COHORT_STATIC,
|
||||
BinCountAuto,
|
||||
TeamMembershipLevel,
|
||||
} from 'lib/constants'
|
||||
import { PluginConfigSchema } from '@posthog/plugin-scaffold'
|
||||
import { PluginInstallationType } from 'scenes/plugins/types'
|
||||
@ -23,6 +24,7 @@ export type Optional<T, K extends string | number | symbol> = Omit<T, K> & { [K
|
||||
export enum AvailableFeature {
|
||||
ZAPIER = 'zapier',
|
||||
ORGANIZATIONS_PROJECTS = 'organizations_projects',
|
||||
PROJECT_BASED_PERMISSIONING = 'project_based_permissioning',
|
||||
GOOGLE_LOGIN = 'google_login',
|
||||
SAML = 'saml',
|
||||
DASHBOARD_COLLABORATION = 'dashboard_collaboration',
|
||||
@ -33,15 +35,21 @@ export interface ColumnConfig {
|
||||
active: string[] | 'DEFAULT'
|
||||
}
|
||||
|
||||
export interface UserType {
|
||||
/* Type for User objects in nested serializers (e.g. created_by) */
|
||||
export interface UserBasicType {
|
||||
id: number
|
||||
uuid: string
|
||||
date_joined: string
|
||||
distinct_id: string
|
||||
first_name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
/** Full User model. */
|
||||
export interface UserType extends UserBasicType {
|
||||
date_joined: string
|
||||
email_opt_in: boolean
|
||||
events_column_config: ColumnConfig
|
||||
anonymize_data: boolean
|
||||
distinct_id: string
|
||||
toolbar_mode: 'disabled' | 'toolbar'
|
||||
has_password: boolean
|
||||
is_staff: boolean
|
||||
@ -53,15 +61,6 @@ export interface UserType {
|
||||
posthog_version?: string
|
||||
}
|
||||
|
||||
/* Type for User objects in nested serializers (e.g. created_by) */
|
||||
export interface UserBasicType {
|
||||
id: number
|
||||
uuid: string
|
||||
distinct_id: string
|
||||
first_name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface PluginAccess {
|
||||
view: boolean
|
||||
install: boolean
|
||||
@ -86,7 +85,6 @@ export interface OrganizationBasicType {
|
||||
export interface OrganizationType extends OrganizationBasicType {
|
||||
created_at: string
|
||||
updated_at: string
|
||||
membership_level: OrganizationMembershipLevel | null
|
||||
personalization: PersonalizationData
|
||||
setup: SetupState
|
||||
setup_section_2_completed: boolean
|
||||
@ -95,16 +93,43 @@ export interface OrganizationType extends OrganizationBasicType {
|
||||
available_features: AvailableFeature[]
|
||||
domain_whitelist: string[]
|
||||
is_member_join_email_enabled: boolean
|
||||
membership_level: OrganizationMembershipLevel | null
|
||||
}
|
||||
|
||||
export interface OrganizationMemberType {
|
||||
interface BaseMemberType {
|
||||
id: string
|
||||
user: UserBasicType
|
||||
level: OrganizationMembershipLevel
|
||||
joined_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface OrganizationMemberType extends BaseMemberType {
|
||||
/** Level at which the user is in the organization. */
|
||||
level: OrganizationMembershipLevel
|
||||
}
|
||||
|
||||
export interface ExplicitTeamMemberType extends BaseMemberType {
|
||||
/** Level at which the user explicitly is in the project. */
|
||||
level: TeamMembershipLevel
|
||||
/** Level at which the user is in the organization. */
|
||||
parent_level: OrganizationMembershipLevel
|
||||
/** Effective level of the user within the project, which may be higher than parent level, but not lower. */
|
||||
effective_level: OrganizationMembershipLevel
|
||||
}
|
||||
|
||||
/**
|
||||
* While OrganizationMemberType and ExplicitTeamMemberType refer to actual Django models,
|
||||
* this interface is only used in the frontend for fusing the data from these models together.
|
||||
*/
|
||||
export interface FusedTeamMemberType extends BaseMemberType {
|
||||
/** Level at which the user explicitly is in the project (unset if membership is implicit). */
|
||||
explicit_team_level: TeamMembershipLevel | null
|
||||
/** Level at which the user is in the organization. */
|
||||
organization_level: OrganizationMembershipLevel
|
||||
/** Effective level of the user within the project. */
|
||||
level: OrganizationMembershipLevel
|
||||
}
|
||||
|
||||
export interface APIErrorType {
|
||||
type: 'authentication_error' | 'invalid_request' | 'server_error' | 'throttled_error' | 'validation_error'
|
||||
code: string
|
||||
@ -134,6 +159,10 @@ export interface TeamBasicType {
|
||||
ingested_event: boolean
|
||||
is_demo: boolean
|
||||
timezone: string
|
||||
/** Whether the project is private. */
|
||||
access_control: boolean
|
||||
/** Effective access level of the user in this specific team. Null if user has no access. */
|
||||
effective_membership_level: OrganizationMembershipLevel | null
|
||||
}
|
||||
|
||||
export interface TeamType extends TeamBasicType {
|
||||
|
@ -2,8 +2,8 @@ admin: 0003_logentry_add_action_flag_choices
|
||||
auth: 0012_alter_user_first_name_max_length
|
||||
axes: 0006_remove_accesslog_trusted
|
||||
contenttypes: 0002_remove_content_type_name
|
||||
ee: 0004_enterpriseeventdefinition_enterprisepropertydefinition
|
||||
posthog: 0169_person_properties_last_updated_at
|
||||
ee: 0005_project_based_permissioning
|
||||
posthog: 0170_project_based_permissioning
|
||||
rest_hooks: 0002_swappable_hook_model
|
||||
sessions: 0001_initial
|
||||
social_django: 0010_uid_db_index
|
||||
|
@ -1,4 +1,4 @@
|
||||
from typing import Any, Dict, Optional, Union, cast
|
||||
from typing import Any, Dict, List, Optional, Union, cast
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Model, QuerySet
|
||||
@ -10,6 +10,7 @@ from posthog.api.routing import StructuredViewSetMixin
|
||||
from posthog.api.shared import TeamBasicSerializer
|
||||
from posthog.constants import AvailableFeature
|
||||
from posthog.event_usage import report_onboarding_completed
|
||||
from posthog.exceptions import EnterpriseFeatureException
|
||||
from posthog.mixins import AnalyticsDestroyModelMixin
|
||||
from posthog.models import Organization, User
|
||||
from posthog.models.organization import OrganizationMembership
|
||||
@ -100,7 +101,6 @@ class OrganizationSerializer(serializers.ModelSerializer):
|
||||
return membership.level if membership is not None else None
|
||||
|
||||
def get_setup(self, instance: Organization) -> Dict[str, Union[bool, int, str, None]]:
|
||||
|
||||
if not instance.is_onboarding_active:
|
||||
# As Section 2 is the last one of the setup process (as of today),
|
||||
# if it's completed it means the setup process is done
|
||||
|
@ -8,6 +8,7 @@ from rest_framework_extensions.settings import extensions_api_settings
|
||||
from posthog.api.utils import get_token
|
||||
from posthog.models.organization import Organization
|
||||
from posthog.models.team import Team
|
||||
from posthog.models.user import User
|
||||
|
||||
|
||||
class DefaultRouterPlusPlus(ExtendedDefaultRouter):
|
||||
@ -47,11 +48,15 @@ class StructuredViewSetMixin(NestedViewSetMixin):
|
||||
if team_from_token:
|
||||
return team_from_token
|
||||
|
||||
user = cast(User, self.request.user)
|
||||
if self.legacy_team_compatibility:
|
||||
team = self.request.user.team
|
||||
team = user.team
|
||||
assert team is not None
|
||||
return team
|
||||
return Team.objects.get(id=self.team_id)
|
||||
try:
|
||||
return Team.objects.get(id=self.team_id)
|
||||
except Team.DoesNotExist:
|
||||
raise NotFound(detail="Project not found.")
|
||||
|
||||
@property
|
||||
def organization_id(self) -> str:
|
||||
@ -62,7 +67,10 @@ class StructuredViewSetMixin(NestedViewSetMixin):
|
||||
|
||||
@property
|
||||
def organization(self) -> Organization:
|
||||
return Organization.objects.get(id=self.organization_id)
|
||||
try:
|
||||
return Organization.objects.get(id=self.organization_id)
|
||||
except Organization.DoesNotExist:
|
||||
raise NotFound(detail="Organization not found.")
|
||||
|
||||
def filter_queryset_by_parents_lookups(self, queryset):
|
||||
parents_query_dict = self.get_parents_query_dict()
|
||||
|
@ -2,9 +2,12 @@
|
||||
This module contains serializers that are used across other serializers for nested representations.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from posthog.models import Organization, Team, User
|
||||
from posthog.models.organization import OrganizationMembership
|
||||
|
||||
|
||||
class UserBasicSerializer(serializers.ModelSerializer):
|
||||
@ -19,6 +22,8 @@ class TeamBasicSerializer(serializers.ModelSerializer):
|
||||
Also used for nested serializers.
|
||||
"""
|
||||
|
||||
effective_membership_level = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = (
|
||||
@ -31,8 +36,13 @@ class TeamBasicSerializer(serializers.ModelSerializer):
|
||||
"ingested_event",
|
||||
"is_demo",
|
||||
"timezone",
|
||||
"access_control",
|
||||
"effective_membership_level",
|
||||
)
|
||||
|
||||
def get_effective_membership_level(self, team: Team) -> Optional[OrganizationMembership.Level]:
|
||||
return team.get_effective_membership_level(self.context["request"].user)
|
||||
|
||||
|
||||
class OrganizationBasicSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
|
@ -1,5 +1,6 @@
|
||||
from typing import Any, Dict, Optional, Type, cast
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import exceptions, permissions, request, response, serializers, viewsets
|
||||
@ -9,6 +10,7 @@ from posthog.api.shared import TeamBasicSerializer
|
||||
from posthog.constants import AvailableFeature
|
||||
from posthog.mixins import AnalyticsDestroyModelMixin
|
||||
from posthog.models import Organization, Team
|
||||
from posthog.models.organization import OrganizationMembership
|
||||
from posthog.models.user import User
|
||||
from posthog.models.utils import generate_random_token, generate_random_token_project
|
||||
from posthog.permissions import CREATE_METHODS, OrganizationAdminWritePermissions, ProjectMembershipNecessaryPermissions
|
||||
@ -33,6 +35,8 @@ class PremiumMultiprojectPermissions(permissions.BasePermission):
|
||||
|
||||
|
||||
class TeamSerializer(serializers.ModelSerializer):
|
||||
effective_membership_level = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = (
|
||||
@ -54,6 +58,8 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"data_attributes",
|
||||
"session_recording_opt_in",
|
||||
"session_recording_retention_period_days",
|
||||
"effective_membership_level",
|
||||
"access_control",
|
||||
)
|
||||
read_only_fields = (
|
||||
"id",
|
||||
@ -64,8 +70,12 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"ingested_event",
|
||||
"effective_membership_level",
|
||||
)
|
||||
|
||||
def get_effective_membership_level(self, team: Team) -> Optional[OrganizationMembership.Level]:
|
||||
return team.get_effective_membership_level(self.context["request"].user)
|
||||
|
||||
def create(self, validated_data: Dict[str, Any], **kwargs) -> Team:
|
||||
serializers.raise_errors_on_nested_writes("create", self, validated_data)
|
||||
request = self.context["request"]
|
||||
@ -79,7 +89,7 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
|
||||
class TeamViewSet(AnalyticsDestroyModelMixin, viewsets.ModelViewSet):
|
||||
serializer_class = TeamSerializer
|
||||
queryset = Team.objects.all()
|
||||
queryset = Team.objects.all().select_related("organization")
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
ProjectMembershipNecessaryPermissions,
|
||||
|
@ -72,14 +72,40 @@ class TestOrganizationAPI(APIBaseTest):
|
||||
def test_update_organization_if_admin(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.ADMIN
|
||||
self.organization_membership.save()
|
||||
self.organization.name = self.CONFIG_ORGANIZATION_NAME
|
||||
self.organization.is_member_join_email_enabled = True
|
||||
self.organization.save()
|
||||
|
||||
response_rename = self.client.patch(f"/api/organizations/{self.organization.id}", {"name": "QWERTY"})
|
||||
response_email = self.client.patch(
|
||||
f"/api/organizations/{self.organization.id}", {"is_member_join_email_enabled": False}
|
||||
)
|
||||
|
||||
self.assertEqual(response_rename.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response_email.status_code, status.HTTP_200_OK)
|
||||
|
||||
self.organization.refresh_from_db()
|
||||
self.assertEqual(self.organization.name, "QWERTY")
|
||||
self.assertEqual(self.organization.is_member_join_email_enabled, False)
|
||||
|
||||
def test_update_organization_if_owner(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.OWNER
|
||||
self.organization_membership.save()
|
||||
self.organization.name = self.CONFIG_ORGANIZATION_NAME
|
||||
self.organization.is_member_join_email_enabled = True
|
||||
self.organization.save()
|
||||
|
||||
response_rename = self.client.patch(f"/api/organizations/{self.organization.id}", {"name": "QWERTY"})
|
||||
response_email = self.client.patch(
|
||||
f"/api/organizations/{self.organization.id}", {"is_member_join_email_enabled": False}
|
||||
)
|
||||
|
||||
self.assertEqual(response_rename.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response_email.status_code, status.HTTP_200_OK)
|
||||
|
||||
self.organization.refresh_from_db()
|
||||
self.assertEqual(self.organization.name, "QWERTY")
|
||||
self.assertEqual(self.organization.is_member_join_email_enabled, False)
|
||||
|
||||
def test_update_domain_whitelist_if_admin(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.ADMIN
|
||||
@ -92,6 +118,8 @@ class TestOrganizationAPI(APIBaseTest):
|
||||
self.assertEqual(self.organization.domain_whitelist, ["posthog.com", "movies.posthog.com"])
|
||||
|
||||
def test_cannot_update_organization_if_not_owner_or_admin(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.MEMBER
|
||||
self.organization_membership.save()
|
||||
response_rename = self.client.patch(f"/api/organizations/{self.organization.id}", {"name": "ASDFG"})
|
||||
response_email = self.client.patch(
|
||||
f"/api/organizations/{self.organization.id}", {"is_member_join_email_enabled": False}
|
||||
@ -101,7 +129,9 @@ class TestOrganizationAPI(APIBaseTest):
|
||||
self.organization.refresh_from_db()
|
||||
self.assertNotEqual(self.organization.name, "ASDFG")
|
||||
|
||||
def test_cannot_update_domain_whitelist_if_non_admin_or_higher(self):
|
||||
def test_cannot_update_domain_whitelist_if_not_owner_or_admin(self):
|
||||
self.organization_membership.level = OrganizationMembership.Level.MEMBER
|
||||
self.organization_membership.save()
|
||||
response = self.client.patch(
|
||||
f"/api/organizations/@current", {"domain_whitelist": ["posthog.com", "movies.posthog.com"]}
|
||||
)
|
||||
|
@ -6,6 +6,7 @@ INTERNAL_BOT_EMAIL_SUFFIX = "@posthogbot.user"
|
||||
class AvailableFeature(str, Enum):
|
||||
ZAPIER = "zapier"
|
||||
ORGANIZATIONS_PROJECTS = "organizations_projects"
|
||||
PROJECT_BASED_PERMISSIONING = "project_based_permissioning"
|
||||
GOOGLE_LOGIN = "google_login"
|
||||
SAML = "saml"
|
||||
DASHBOARD_COLLABORATION = "dashboard_collaboration"
|
||||
|
@ -16,10 +16,10 @@ class RequestParsingError(Exception):
|
||||
class EnterpriseFeatureException(APIException):
|
||||
status_code = status.HTTP_402_PAYMENT_REQUIRED
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, feature: Optional[str] = None) -> None:
|
||||
super().__init__(
|
||||
detail=(
|
||||
"This feature is part of the premium PostHog offering. "
|
||||
f"{feature.capitalize() if feature else 'This feature'} is part of the premium PostHog offering. "
|
||||
+ (
|
||||
"To use it, subscribe to PostHog Cloud with a generous free tier: https://app.posthog.com/organization/billing"
|
||||
if settings.MULTI_TENANCY
|
||||
|
20
posthog/migrations/0170_project_based_permissioning.py
Normal file
20
posthog/migrations/0170_project_based_permissioning.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.2.5 on 2021-09-08 13:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("posthog", "0169_person_properties_last_updated_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Normally we do not remove old and unused fields because deleting a column causes a temporary outage
|
||||
# on migration stemming from Django ORM explicitly selecting all columns and not yet knowing that not all
|
||||
# still exist.
|
||||
# This field however is a many-to-many one, meaning it's not stored as a column, but as a table.
|
||||
# It's also not referenced anywhere in code, so removing it should have no impact on any queries at all.
|
||||
migrations.RemoveField(model_name="team", name="users",),
|
||||
migrations.AddField(model_name="team", name="access_control", field=models.BooleanField(default=False),),
|
||||
]
|
@ -79,7 +79,7 @@ class Organization(UUIDModel):
|
||||
updated_at: models.DateTimeField = models.DateTimeField(auto_now=True)
|
||||
domain_whitelist: ArrayField = ArrayField(
|
||||
models.CharField(max_length=256, blank=False), blank=True, default=list
|
||||
) # used to allow self-serve account creation based on social login (#5111)
|
||||
) # Used to allow self-serve account creation based on social login (#5111)
|
||||
setup_section_2_completed: models.BooleanField = models.BooleanField(default=True) # Onboarding (#2822)
|
||||
personalization: models.JSONField = models.JSONField(default=dict, null=False, blank=True)
|
||||
plugins_access_level: models.PositiveSmallIntegerField = models.PositiveSmallIntegerField(
|
||||
@ -171,6 +171,8 @@ def organization_about_to_be_deleted(sender, instance, **kwargs):
|
||||
|
||||
class OrganizationMembership(UUIDModel):
|
||||
class Level(models.IntegerChoices):
|
||||
"""Keep in sync with TeamMembership.Level (only difference being projects not having an Owner)."""
|
||||
|
||||
MEMBER = 1, "member"
|
||||
ADMIN = 8, "administrator"
|
||||
OWNER = 15, "owner"
|
||||
|
@ -1,18 +1,25 @@
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.dispatch.dispatcher import receiver
|
||||
|
||||
from posthog.constants import AvailableFeature
|
||||
from posthog.helpers.dashboard_templates import create_dashboard_from_template
|
||||
from posthog.utils import GenericEmails
|
||||
|
||||
from .dashboard import Dashboard
|
||||
from .utils import UUIDClassicModel, generate_random_token_project, sane_repr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from posthog.models.organization import OrganizationMembership
|
||||
from posthog.models.user import User
|
||||
|
||||
TEAM_CACHE: Dict[str, "Team"] = {}
|
||||
|
||||
TIMEZONES = [(tz, tz) for tz in pytz.common_timezones]
|
||||
@ -21,7 +28,6 @@ TIMEZONES = [(tz, tz) for tz in pytz.common_timezones]
|
||||
DEPRECATED_ATTRS = (
|
||||
"plugins_opt_in",
|
||||
"opt_out_capture",
|
||||
"users",
|
||||
"event_names",
|
||||
"event_names_with_usage",
|
||||
"event_properties",
|
||||
@ -106,6 +112,7 @@ class Team(UUIDClassicModel):
|
||||
)
|
||||
signup_token: models.CharField = models.CharField(max_length=200, null=True, blank=True)
|
||||
is_demo: models.BooleanField = models.BooleanField(default=False)
|
||||
access_control: models.BooleanField = models.BooleanField(default=False)
|
||||
test_account_filters: models.JSONField = models.JSONField(default=list)
|
||||
timezone: models.CharField = models.CharField(max_length=240, choices=TIMEZONES, default="UTC")
|
||||
data_attributes: models.JSONField = models.JSONField(default=get_default_data_attributes)
|
||||
@ -114,10 +121,6 @@ class Team(UUIDClassicModel):
|
||||
plugins_opt_in: models.BooleanField = models.BooleanField(default=False)
|
||||
# DEPRECATED, DISUSED: replaced with env variable OPT_OUT_CAPTURE and User.anonymized_data
|
||||
opt_out_capture: models.BooleanField = models.BooleanField(default=False)
|
||||
# DEPRECATED, DISUSED: now managing access in an Organization-centric way
|
||||
users: models.ManyToManyField = models.ManyToManyField(
|
||||
"User", blank=True, related_name="teams_deprecated_relationship"
|
||||
)
|
||||
# DEPRECATED: in favor of `EventDefinition` model
|
||||
event_names: models.JSONField = models.JSONField(default=list)
|
||||
event_names_with_usage: models.JSONField = models.JSONField(default=list)
|
||||
@ -128,6 +131,33 @@ class Team(UUIDClassicModel):
|
||||
|
||||
objects: TeamManager = TeamManager()
|
||||
|
||||
def get_effective_membership_level(self, user: "User") -> Optional["OrganizationMembership.Level"]:
|
||||
from posthog.models.organization import OrganizationMembership
|
||||
|
||||
parent_membership: "OrganizationMembership" = user.organization_memberships.only("id", "level").get(
|
||||
organization_id=self.organization_id
|
||||
)
|
||||
if (
|
||||
settings.EE_AVAILABLE
|
||||
and self.access_control
|
||||
and self.organization.is_feature_available(AvailableFeature.PROJECT_BASED_PERMISSIONING)
|
||||
):
|
||||
# Checking for project-specific level
|
||||
try:
|
||||
return (
|
||||
parent_membership.explicit_team_memberships.only("parent_membership", "level")
|
||||
.get(team=self)
|
||||
.effective_level
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
if parent_membership.level < OrganizationMembership.Level.ADMIN:
|
||||
# Only organization admins and above get implicit project membership
|
||||
return None
|
||||
return parent_membership.level
|
||||
else:
|
||||
# Project-based permissioning unavailable or disabled, simply returning organization-wide level
|
||||
return parent_membership.level
|
||||
|
||||
def __str__(self):
|
||||
if self.name:
|
||||
return self.name
|
||||
|
@ -1,13 +1,7 @@
|
||||
from unittest import mock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import now
|
||||
from freezegun import freeze_time
|
||||
|
||||
from posthog.celery import sync_all_organization_available_features
|
||||
from posthog.models import Organization, OrganizationInvite, Plugin
|
||||
from posthog.plugins.test.mock import mocked_plugin_requests_get
|
||||
from posthog.plugins.test.plugin_archives import HELLO_WORLD_PLUGIN_GITHUB_ZIP
|
||||
@ -61,46 +55,3 @@ class TestOrganization(BaseTest):
|
||||
|
||||
self.assertEqual(Plugin.objects.filter(organization=new_org, is_preinstalled=True).count(), 0)
|
||||
self.assertEqual(mock_get.call_count, 0)
|
||||
|
||||
@pytest.mark.ee
|
||||
@patch("posthog.models.organization.License.PLANS", {"enterprise": ["whatever"]})
|
||||
@patch("ee.models.license.requests.post")
|
||||
def test_feature_available_self_hosted_has_license(self, patch_post):
|
||||
with self.settings(MULTI_TENANCY=False):
|
||||
from ee.models.license import License
|
||||
|
||||
mock = Mock()
|
||||
mock.json.return_value = {"plan": "enterprise", "valid_until": now() + relativedelta(days=1)}
|
||||
patch_post.return_value = mock
|
||||
License.objects.create(key="key")
|
||||
|
||||
# Still only old, empty available_features field value known
|
||||
self.assertFalse(self.organization.is_feature_available("whatever"))
|
||||
self.assertFalse(self.organization.is_feature_available("feature-doesnt-exist"))
|
||||
|
||||
# New available_features field value that was updated in DB on license creation is known after refresh
|
||||
self.organization.refresh_from_db()
|
||||
self.assertTrue(self.organization.is_feature_available("whatever"))
|
||||
self.assertFalse(self.organization.is_feature_available("feature-doesnt-exist"))
|
||||
|
||||
@pytest.mark.ee
|
||||
@patch("posthog.models.organization.License.PLANS", {"enterprise": ["whatever"]})
|
||||
def test_feature_available_self_hosted_no_license(self):
|
||||
self.assertFalse(self.organization.is_feature_available("whatever"))
|
||||
self.assertFalse(self.organization.is_feature_available("feature-doesnt-exist"))
|
||||
|
||||
@pytest.mark.ee
|
||||
@patch("posthog.models.organization.License.PLANS", {"enterprise": ["whatever"]})
|
||||
@patch("ee.models.license.requests.post")
|
||||
def test_feature_available_self_hosted_license_expired(self, patch_post):
|
||||
from ee.models.license import License
|
||||
|
||||
mock = Mock()
|
||||
mock.json.return_value = {"plan": "enterprise", "valid_until": "2012-01-14T12:00:00.000Z"}
|
||||
patch_post.return_value = mock
|
||||
License.objects.create(key="key")
|
||||
|
||||
with freeze_time("2012-01-19T12:00:00.000Z"):
|
||||
sync_all_organization_available_features() # This is normally ran every hour
|
||||
self.organization.refresh_from_db()
|
||||
self.assertFalse(self.organization.is_feature_available("whatever"))
|
||||
|
Loading…
Reference in New Issue
Block a user