0
0
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:
Michael Matloka 2021-09-22 18:29:59 +02:00 committed by GitHub
parent 152dfae591
commit bc3e223265
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1340 additions and 98 deletions

View 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

View File

@ -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"))

View File

@ -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):

View 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)

View 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"
),
),
]

View File

@ -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

View 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")

View File

@ -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,

View File

@ -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] = [

View File

@ -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>

View File

@ -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',

View File

@ -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

View File

@ -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

View 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>
)
}

View 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"
/>
</>
)
}

View File

@ -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>

View 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,
}),
})

View File

@ -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 {

View File

@ -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};

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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):
"""

View File

@ -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,

View File

@ -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"]}
)

View File

@ -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"

View File

@ -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

View 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),),
]

View File

@ -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"

View File

@ -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

View File

@ -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"))