From 23db9545fc259eeed806a8d3e609d4d44a0b136b Mon Sep 17 00:00:00 2001 From: Zach Waterfield Date: Thu, 7 Nov 2024 19:45:21 -0500 Subject: [PATCH] feat: add access control model (#26076) --- ee/billing/billing_manager.py | 4 +- ee/migrations/0017_accesscontrol_and_more.py | 75 ++++++++++++++++++++ ee/migrations/max_migration.txt | 2 +- ee/models/__init__.py | 10 +-- ee/models/property_definition.py | 1 + ee/models/rbac/access_control.py | 53 ++++++++++++++ ee/models/rbac/role.py | 21 +++--- mypy-baseline.txt | 8 +-- 8 files changed, 153 insertions(+), 21 deletions(-) create mode 100644 ee/migrations/0017_accesscontrol_and_more.py create mode 100644 ee/models/rbac/access_control.py diff --git a/ee/billing/billing_manager.py b/ee/billing/billing_manager.py index 18d3d3fbbee..5363053487c 100644 --- a/ee/billing/billing_manager.py +++ b/ee/billing/billing_manager.py @@ -138,7 +138,7 @@ class BillingManager: def update_billing_organization_users(self, organization: Organization) -> None: try: - distinct_ids = list(organization.members.values_list("distinct_id", flat=True)) + distinct_ids = list(organization.members.values_list("distinct_id", flat=True)) # type: ignore first_owner_membership = ( OrganizationMembership.objects.filter(organization=organization, level=15) @@ -157,7 +157,7 @@ class BillingManager: ) org_users = list( - organization.members.values( + organization.members.values( # type: ignore "email", "distinct_id", "organization_membership__level", diff --git a/ee/migrations/0017_accesscontrol_and_more.py b/ee/migrations/0017_accesscontrol_and_more.py new file mode 100644 index 00000000000..1c870d33895 --- /dev/null +++ b/ee/migrations/0017_accesscontrol_and_more.py @@ -0,0 +1,75 @@ +# Generated by Django 4.2.15 on 2024-11-07 17:05 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import posthog.models.utils + + +class Migration(migrations.Migration): + dependencies = [ + ("posthog", "0512_errortrackingissue_errortrackingissuefingerprintv2_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("ee", "0016_rolemembership_organization_member"), + ] + + operations = [ + migrations.CreateModel( + name="AccessControl", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False + ), + ), + ("access_level", models.CharField(max_length=32)), + ("resource", models.CharField(max_length=32)), + ("resource_id", models.CharField(max_length=36, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL + ), + ), + ( + "organization_member", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="access_controls", + related_query_name="access_controls", + to="posthog.organizationmembership", + ), + ), + ( + "role", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="access_controls", + related_query_name="access_controls", + to="ee.role", + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="access_controls", + related_query_name="access_controls", + to="posthog.team", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="accesscontrol", + constraint=models.UniqueConstraint( + fields=("resource", "resource_id", "team", "organization_member", "role"), + name="unique resource per target", + ), + ), + ] diff --git a/ee/migrations/max_migration.txt b/ee/migrations/max_migration.txt index 21d256f058a..449d87290c3 100644 --- a/ee/migrations/max_migration.txt +++ b/ee/migrations/max_migration.txt @@ -1 +1 @@ -0016_rolemembership_organization_member +0017_accesscontrol_and_more diff --git a/ee/models/__init__.py b/ee/models/__init__.py index ff5a8fe2dff..df7cfcba704 100644 --- a/ee/models/__init__.py +++ b/ee/models/__init__.py @@ -5,16 +5,18 @@ from .feature_flag_role_access import FeatureFlagRoleAccess from .hook import Hook from .license import License from .property_definition import EnterprisePropertyDefinition +from .rbac.access_control import AccessControl from .rbac.role import Role, RoleMembership __all__ = [ - "EnterpriseEventDefinition", - "ExplicitTeamMembership", + "AccessControl", "DashboardPrivilege", + "EnterpriseEventDefinition", + "EnterprisePropertyDefinition", + "ExplicitTeamMembership", + "FeatureFlagRoleAccess", "Hook", "License", "Role", "RoleMembership", - "EnterprisePropertyDefinition", - "FeatureFlagRoleAccess", ] diff --git a/ee/models/property_definition.py b/ee/models/property_definition.py index bb9b34fa406..3354afacb41 100644 --- a/ee/models/property_definition.py +++ b/ee/models/property_definition.py @@ -11,6 +11,7 @@ class EnterprisePropertyDefinition(PropertyDefinition): verified = models.BooleanField(default=False, blank=True) verified_at = models.DateTimeField(null=True, blank=True) + verified_by = models.ForeignKey( "posthog.User", null=True, diff --git a/ee/models/rbac/access_control.py b/ee/models/rbac/access_control.py new file mode 100644 index 00000000000..9566b4adab4 --- /dev/null +++ b/ee/models/rbac/access_control.py @@ -0,0 +1,53 @@ +from django.db import models + +from posthog.models.utils import UUIDModel + + +class AccessControl(UUIDModel): + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["resource", "resource_id", "team", "organization_member", "role"], + name="unique resource per target", + ) + ] + + team = models.ForeignKey( + "posthog.Team", + on_delete=models.CASCADE, + related_name="access_controls", + related_query_name="access_controls", + ) + + # Configuration of what we are accessing + access_level: models.CharField = models.CharField(max_length=32) + resource: models.CharField = models.CharField(max_length=32) + resource_id: models.CharField = models.CharField(max_length=36, null=True) + + # Optional scope it to a specific member + organization_member = models.ForeignKey( + "posthog.OrganizationMembership", + on_delete=models.CASCADE, + related_name="access_controls", + related_query_name="access_controls", + null=True, + ) + + # Optional scope it to a specific role + role = models.ForeignKey( + "Role", + on_delete=models.CASCADE, + related_name="access_controls", + related_query_name="access_controls", + null=True, + ) + + created_by = models.ForeignKey( + "posthog.User", + on_delete=models.SET_NULL, + null=True, + ) + created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) + updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) + + # TODO: add model validation for access_level and resource diff --git a/ee/models/rbac/role.py b/ee/models/rbac/role.py index 97201835adb..cb35294da2d 100644 --- a/ee/models/rbac/role.py +++ b/ee/models/rbac/role.py @@ -5,6 +5,9 @@ from posthog.models.utils import UUIDModel class Role(UUIDModel): + class Meta: + constraints = [models.UniqueConstraint(fields=["organization", "name"], name="unique_role_name")] + name = models.CharField(max_length=200) organization = models.ForeignKey( "posthog.Organization", @@ -12,10 +15,7 @@ class Role(UUIDModel): related_name="roles", related_query_name="role", ) - feature_flags_access_level = models.PositiveSmallIntegerField( - default=OrganizationResourceAccess.AccessLevel.CAN_ALWAYS_EDIT, - choices=OrganizationResourceAccess.AccessLevel.choices, - ) + created_at = models.DateTimeField(auto_now_add=True) created_by = models.ForeignKey( "posthog.User", @@ -25,11 +25,17 @@ class Role(UUIDModel): null=True, ) - class Meta: - constraints = [models.UniqueConstraint(fields=["organization", "name"], name="unique_role_name")] + # TODO: Deprecate this field + feature_flags_access_level = models.PositiveSmallIntegerField( + default=OrganizationResourceAccess.AccessLevel.CAN_ALWAYS_EDIT, + choices=OrganizationResourceAccess.AccessLevel.choices, + ) class RoleMembership(UUIDModel): + class Meta: + constraints = [models.UniqueConstraint(fields=["role", "user"], name="unique_user_and_role")] + role = models.ForeignKey( "Role", on_delete=models.CASCADE, @@ -53,6 +59,3 @@ class RoleMembership(UUIDModel): ) joined_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - - class Meta: - constraints = [models.UniqueConstraint(fields=["role", "user"], name="unique_user_and_role")] diff --git a/mypy-baseline.txt b/mypy-baseline.txt index a2ab36ff3af..c181c04e2dd 100644 --- a/mypy-baseline.txt +++ b/mypy-baseline.txt @@ -327,8 +327,6 @@ ee/tasks/subscriptions/email_subscriptions.py:0: error: Item "None" of "datetime ee/tasks/subscriptions/email_subscriptions.py:0: error: Item "None" of "datetime | None" has no attribute "strftime" [union-attr] ee/tasks/subscriptions/email_subscriptions.py:0: error: Item "None" of "User | None" has no attribute "first_name" [union-attr] ee/billing/billing_manager.py:0: error: Module has no attribute "utc" [attr-defined] -ee/billing/billing_manager.py:0: error: Cannot resolve keyword 'distinct_id' into field. Choices are: explicit_team_membership, id, joined_at, level, organization, organization_id, role_membership, updated_at, user, user_id [misc] -ee/billing/billing_manager.py:0: error: Cannot resolve keyword 'email' into field. Choices are: explicit_team_membership, id, joined_at, level, organization, organization_id, role_membership, updated_at, user, user_id [misc] ee/billing/billing_manager.py:0: error: Incompatible types in assignment (expression has type "object", variable has type "bool | Combinable | None") [assignment] posthog/models/property/util.py:0: error: Incompatible type for lookup 'pk': (got "str | int | list[str]", expected "str | int") [misc] posthog/models/property/util.py:0: error: Argument 3 to "format_filter_query" has incompatible type "HogQLContext | None"; expected "HogQLContext" [arg-type] @@ -642,6 +640,9 @@ posthog/tasks/exports/test/test_csv_exporter.py:0: error: Argument 1 to "read" h posthog/tasks/exports/test/test_csv_exporter.py:0: error: Argument 1 to "read" has incompatible type "str | None"; expected "str" [arg-type] posthog/tasks/exports/test/test_csv_exporter.py:0: error: Argument 1 to "read" has incompatible type "str | None"; expected "str" [arg-type] posthog/tasks/exports/test/test_csv_exporter.py:0: error: Argument 1 to "read" has incompatible type "str | None"; expected "str" [arg-type] +posthog/session_recordings/session_recording_api.py:0: error: Argument "team_id" to "get_realtime_snapshots" has incompatible type "int"; expected "str" [arg-type] +posthog/session_recordings/session_recording_api.py:0: error: Value of type variable "SupportsRichComparisonT" of "sorted" cannot be "str | None" [type-var] +posthog/session_recordings/session_recording_api.py:0: error: Argument 1 to "get" of "dict" has incompatible type "str | None"; expected "str" [arg-type] posthog/queries/trends/test/test_person.py:0: error: "str" has no attribute "get" [attr-defined] posthog/queries/trends/test/test_person.py:0: error: Invalid index type "int" for "_MonkeyPatchedResponse"; expected type "str" [index] posthog/queries/trends/test/test_person.py:0: error: "str" has no attribute "get" [attr-defined] @@ -800,9 +801,6 @@ posthog/temporal/data_imports/pipelines/pipeline_sync.py:0: error: "type[Filesys posthog/temporal/data_imports/pipelines/pipeline_sync.py:0: error: Incompatible types in assignment (expression has type "object", variable has type "DataWarehouseCredential | Combinable | None") [assignment] posthog/temporal/data_imports/pipelines/pipeline_sync.py:0: error: Incompatible types in assignment (expression has type "object", variable has type "str | int | Combinable") [assignment] posthog/temporal/data_imports/pipelines/pipeline_sync.py:0: error: Incompatible types in assignment (expression has type "dict[str, dict[str, str | bool]] | dict[str, str]", variable has type "dict[str, dict[str, str]]") [assignment] -posthog/session_recordings/session_recording_api.py:0: error: Argument "team_id" to "get_realtime_snapshots" has incompatible type "int"; expected "str" [arg-type] -posthog/session_recordings/session_recording_api.py:0: error: Value of type variable "SupportsRichComparisonT" of "sorted" cannot be "str | None" [type-var] -posthog/session_recordings/session_recording_api.py:0: error: Argument 1 to "get" of "dict" has incompatible type "str | None"; expected "str" [arg-type] posthog/queries/app_metrics/test/test_app_metrics.py:0: error: Argument 3 to "AppMetricsErrorDetailsQuery" has incompatible type "AppMetricsRequestSerializer"; expected "AppMetricsErrorsRequestSerializer" [arg-type] posthog/queries/app_metrics/test/test_app_metrics.py:0: error: Argument 3 to "AppMetricsErrorDetailsQuery" has incompatible type "AppMetricsRequestSerializer"; expected "AppMetricsErrorsRequestSerializer" [arg-type] posthog/queries/app_metrics/test/test_app_metrics.py:0: error: Argument 3 to "AppMetricsErrorDetailsQuery" has incompatible type "AppMetricsRequestSerializer"; expected "AppMetricsErrorsRequestSerializer" [arg-type]