policies: add ability to directly assign groups in bindings
This commit is contained in:
		| @ -50,12 +50,12 @@ class PolicySerializer(ModelSerializer): | |||||||
|  |  | ||||||
|     _resolve_inheritance: bool |     _resolve_inheritance: bool | ||||||
|  |  | ||||||
|  |     object_type = SerializerMethodField() | ||||||
|  |  | ||||||
|     def __init__(self, *args, resolve_inheritance: bool = True, **kwargs): |     def __init__(self, *args, resolve_inheritance: bool = True, **kwargs): | ||||||
|         super().__init__(*args, **kwargs) |         super().__init__(*args, **kwargs) | ||||||
|         self._resolve_inheritance = resolve_inheritance |         self._resolve_inheritance = resolve_inheritance | ||||||
|  |  | ||||||
|     object_type = SerializerMethodField() |  | ||||||
|  |  | ||||||
|     def get_object_type(self, obj): |     def get_object_type(self, obj): | ||||||
|         """Get object type so that we know which API Endpoint to use to get the full object""" |         """Get object type so that we know which API Endpoint to use to get the full object""" | ||||||
|         return obj._meta.object_name.lower().replace("provider", "") |         return obj._meta.object_name.lower().replace("provider", "") | ||||||
| @ -64,7 +64,9 @@ class PolicySerializer(ModelSerializer): | |||||||
|         # pyright: reportGeneralTypeIssues=false |         # pyright: reportGeneralTypeIssues=false | ||||||
|         if instance.__class__ == Policy or not self._resolve_inheritance: |         if instance.__class__ == Policy or not self._resolve_inheritance: | ||||||
|             return super().to_representation(instance) |             return super().to_representation(instance) | ||||||
|         return instance.serializer(instance=instance, resolve_inheritance=False).data |         return dict( | ||||||
|  |             instance.serializer(instance=instance, resolve_inheritance=False).data | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
| @ -102,7 +104,17 @@ class PolicyBindingSerializer(ModelSerializer): | |||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = PolicyBinding |         model = PolicyBinding | ||||||
|         fields = ["pk", "policy", "policy_obj", "target", "enabled", "order", "timeout"] |         fields = [ | ||||||
|  |             "pk", | ||||||
|  |             "policy", | ||||||
|  |             "policy_obj", | ||||||
|  |             "group", | ||||||
|  |             "user", | ||||||
|  |             "target", | ||||||
|  |             "enabled", | ||||||
|  |             "order", | ||||||
|  |             "timeout", | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class PolicyBindingViewSet(ModelViewSet): | class PolicyBindingViewSet(ModelViewSet): | ||||||
|  | |||||||
| @ -14,10 +14,10 @@ class PolicyBindingForm(forms.ModelForm): | |||||||
|         to_field_name="pbm_uuid", |         to_field_name="pbm_uuid", | ||||||
|     ) |     ) | ||||||
|     policy = GroupedModelChoiceField( |     policy = GroupedModelChoiceField( | ||||||
|         queryset=Policy.objects.all().select_subclasses(), |         queryset=Policy.objects.all().select_subclasses(), required=False | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     def __init__(self, *args, **kwargs): |     def __init__(self, *args, **kwargs):  # pragma: no cover | ||||||
|         super().__init__(*args, **kwargs) |         super().__init__(*args, **kwargs) | ||||||
|         if "target" in self.initial: |         if "target" in self.initial: | ||||||
|             self.fields["target"].widget = forms.HiddenInput() |             self.fields["target"].widget = forms.HiddenInput() | ||||||
| @ -25,7 +25,7 @@ class PolicyBindingForm(forms.ModelForm): | |||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = PolicyBinding |         model = PolicyBinding | ||||||
|         fields = ["enabled", "policy", "target", "order", "timeout"] |         fields = ["enabled", "policy", "group", "user", "target", "order", "timeout"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class PolicyForm(forms.ModelForm): | class PolicyForm(forms.ModelForm): | ||||||
|  | |||||||
| @ -0,0 +1,20 @@ | |||||||
|  | # Generated by Django 3.1.6 on 2021-02-11 19:24 | ||||||
|  |  | ||||||
|  | from django.db import migrations | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_policies_group_membership", "0001_initial"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterModelOptions( | ||||||
|  |             name="groupmembershippolicy", | ||||||
|  |             options={ | ||||||
|  |                 "verbose_name": "Group Membership Policy (deprecated)", | ||||||
|  |                 "verbose_name_plural": "Group Membership Policies", | ||||||
|  |             }, | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -12,7 +12,8 @@ from authentik.policies.types import PolicyRequest, PolicyResult | |||||||
|  |  | ||||||
|  |  | ||||||
| class GroupMembershipPolicy(Policy): | class GroupMembershipPolicy(Policy): | ||||||
|     """Check that the user is member of the selected group.""" |     """Check that the user is member of the selected group. **DEPRECATED** | ||||||
|  |     Assign the group directly in a binding instead of using this policy.""" | ||||||
|  |  | ||||||
|     group = models.ForeignKey(Group, null=True, blank=True, on_delete=models.SET_NULL) |     group = models.ForeignKey(Group, null=True, blank=True, on_delete=models.SET_NULL) | ||||||
|  |  | ||||||
| @ -35,5 +36,5 @@ class GroupMembershipPolicy(Policy): | |||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         verbose_name = _("Group Membership Policy") |         verbose_name = _("Group Membership Policy (deprecated)") | ||||||
|         verbose_name_plural = _("Group Membership Policies") |         verbose_name_plural = _("Group Membership Policies") | ||||||
|  | |||||||
							
								
								
									
										76
									
								
								authentik/policies/migrations/0005_binding_group.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								authentik/policies/migrations/0005_binding_group.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,76 @@ | |||||||
|  | # Generated by Django 3.1.6 on 2021-02-08 18:36 | ||||||
|  |  | ||||||
|  | import django.db.models.deletion | ||||||
|  | from django.apps.registry import Apps | ||||||
|  | from django.conf import settings | ||||||
|  | from django.db import migrations, models | ||||||
|  | from django.db.backends.base.schema import BaseDatabaseSchemaEditor | ||||||
|  |  | ||||||
|  | import authentik.lib.models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def migrate_from_groupmembership(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|  |     try: | ||||||
|  |         GroupMembershipPolicy = apps.get_model( | ||||||
|  |             "authentik_policies_group_membership", "GroupMembershipPolicy" | ||||||
|  |         ) | ||||||
|  |     except LookupError: | ||||||
|  |         # GroupMembership app isn't installed, ignore migration | ||||||
|  |         return | ||||||
|  |     PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding") | ||||||
|  |  | ||||||
|  |     db_alias = schema_editor.connection.alias | ||||||
|  |  | ||||||
|  |     for membership in GroupMembershipPolicy.objects.using(db_alias).all(): | ||||||
|  |         for binding in PolicyBinding.objects.using(db_alias).filter(policy=membership): | ||||||
|  |             binding.group = membership.group | ||||||
|  |             binding.policy = None | ||||||
|  |             binding.save() | ||||||
|  |         membership.delete() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_core", "0017_managed"), | ||||||
|  |         migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||||||
|  |         ("authentik_policies", "0004_policy_execution_logging"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="policybinding", | ||||||
|  |             name="group", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 default=None, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 to="authentik_core.group", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="policybinding", | ||||||
|  |             name="user", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 default=None, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 to=settings.AUTH_USER_MODEL, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="policybinding", | ||||||
|  |             name="policy", | ||||||
|  |             field=authentik.lib.models.InheritanceForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 default=None, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 related_name="+", | ||||||
|  |                 to="authentik_policies.policy", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.RunPython(migrate_from_groupmembership), | ||||||
|  |     ] | ||||||
| @ -43,7 +43,32 @@ class PolicyBinding(SerializerModel): | |||||||
|  |  | ||||||
|     enabled = models.BooleanField(default=True) |     enabled = models.BooleanField(default=True) | ||||||
|  |  | ||||||
|     policy = InheritanceForeignKey("Policy", on_delete=models.CASCADE, related_name="+") |     policy = InheritanceForeignKey( | ||||||
|  |         "Policy", | ||||||
|  |         on_delete=models.CASCADE, | ||||||
|  |         related_name="+", | ||||||
|  |         default=None, | ||||||
|  |         null=True, | ||||||
|  |         blank=True, | ||||||
|  |     ) | ||||||
|  |     group = models.ForeignKey( | ||||||
|  |         # This is quite an ugly hack to prevent pylint from trying | ||||||
|  |         # to resolve authentik_core.models.Group | ||||||
|  |         # as python import path | ||||||
|  |         "authentik_core." + "Group", | ||||||
|  |         on_delete=models.CASCADE, | ||||||
|  |         default=None, | ||||||
|  |         null=True, | ||||||
|  |         blank=True, | ||||||
|  |     ) | ||||||
|  |     user = models.ForeignKey( | ||||||
|  |         "authentik_core." + "User", | ||||||
|  |         on_delete=models.CASCADE, | ||||||
|  |         default=None, | ||||||
|  |         null=True, | ||||||
|  |         blank=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     target = InheritanceForeignKey( |     target = InheritanceForeignKey( | ||||||
|         PolicyBindingModel, on_delete=models.CASCADE, related_name="+" |         PolicyBindingModel, on_delete=models.CASCADE, related_name="+" | ||||||
|     ) |     ) | ||||||
| @ -57,6 +82,17 @@ class PolicyBinding(SerializerModel): | |||||||
|  |  | ||||||
|     order = models.IntegerField() |     order = models.IntegerField() | ||||||
|  |  | ||||||
|  |     def passes(self, request: PolicyRequest) -> PolicyResult: | ||||||
|  |         """Check if request passes this PolicyBinding, check policy, group or user""" | ||||||
|  |         if self.policy: | ||||||
|  |             self.policy: Policy | ||||||
|  |             return self.policy.passes(request) | ||||||
|  |         if self.group: | ||||||
|  |             return PolicyResult(self.group.users.filter(pk=request.user.pk).exists()) | ||||||
|  |         if self.user: | ||||||
|  |             return PolicyResult(request.user == self.user) | ||||||
|  |         return PolicyResult(False) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> BaseSerializer: |     def serializer(self) -> BaseSerializer: | ||||||
|         from authentik.policies.api import PolicyBindingSerializer |         from authentik.policies.api import PolicyBindingSerializer | ||||||
| @ -105,7 +141,7 @@ class Policy(SerializerModel, CreatedUpdatedModel): | |||||||
|         return f"{self.__class__.__name__} {self.name}" |         return f"{self.__class__.__name__} {self.name}" | ||||||
|  |  | ||||||
|     def passes(self, request: PolicyRequest) -> PolicyResult:  # pragma: no cover |     def passes(self, request: PolicyRequest) -> PolicyResult:  # pragma: no cover | ||||||
|         """Check if user instance passes this policy""" |         """Check if request passes this policy""" | ||||||
|         raise PolicyException() |         raise PolicyException() | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  | |||||||
| @ -23,7 +23,7 @@ PROCESS_CLASS = FORK_CTX.Process | |||||||
|  |  | ||||||
| def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str: | def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str: | ||||||
|     """Generate Cache key for policy""" |     """Generate Cache key for policy""" | ||||||
|     prefix = f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}" |     prefix = f"policy_{binding.policy_binding_uuid.hex}_" | ||||||
|     if request.http_request and hasattr(request.http_request, "session"): |     if request.http_request and hasattr(request.http_request, "session"): | ||||||
|         prefix += f"_{request.http_request.session.session_key}" |         prefix += f"_{request.http_request.session.session_key}" | ||||||
|     if request.user: |     if request.user: | ||||||
| @ -79,8 +79,9 @@ class PolicyProcess(PROCESS_CLASS): | |||||||
|             process="PolicyProcess", |             process="PolicyProcess", | ||||||
|         ) |         ) | ||||||
|         try: |         try: | ||||||
|             policy_result = self.binding.policy.passes(self.request) |             policy_result = self.binding.passes(self.request) | ||||||
|             if self.binding.policy.execution_logging and not self.request.debug: |             if self.binding.policy and not self.request.debug: | ||||||
|  |                 if self.binding.policy.execution_logging: | ||||||
|                     self.create_event( |                     self.create_event( | ||||||
|                         EventAction.POLICY_EXECUTION, |                         EventAction.POLICY_EXECUTION, | ||||||
|                         message="Policy Execution", |                         message="Policy Execution", | ||||||
|  | |||||||
| @ -1,8 +1,9 @@ | |||||||
| """policy process tests""" | """policy process tests""" | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.test import RequestFactory, TestCase | from django.test import RequestFactory, TestCase | ||||||
|  | from guardian.shortcuts import get_anonymous_user | ||||||
|  |  | ||||||
| from authentik.core.models import Application, User | from authentik.core.models import Application, Group, User | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.policies.dummy.models import DummyPolicy | from authentik.policies.dummy.models import DummyPolicy | ||||||
| from authentik.policies.expression.models import ExpressionPolicy | from authentik.policies.expression.models import ExpressionPolicy | ||||||
| @ -25,6 +26,51 @@ class TestPolicyProcess(TestCase): | |||||||
|         self.factory = RequestFactory() |         self.factory = RequestFactory() | ||||||
|         self.user = User.objects.create_user(username="policyuser") |         self.user = User.objects.create_user(username="policyuser") | ||||||
|  |  | ||||||
|  |     def test_group_passing(self): | ||||||
|  |         """Test binding to group""" | ||||||
|  |         group = Group.objects.create(name="test-group") | ||||||
|  |         group.users.add(self.user) | ||||||
|  |         group.save() | ||||||
|  |         binding = PolicyBinding(group=group) | ||||||
|  |  | ||||||
|  |         request = PolicyRequest(self.user) | ||||||
|  |         response = PolicyProcess(binding, request, None).execute() | ||||||
|  |         self.assertEqual(response.passing, True) | ||||||
|  |  | ||||||
|  |     def test_group_negative(self): | ||||||
|  |         """Test binding to group""" | ||||||
|  |         group = Group.objects.create(name="test-group") | ||||||
|  |         group.save() | ||||||
|  |         binding = PolicyBinding(group=group) | ||||||
|  |  | ||||||
|  |         request = PolicyRequest(self.user) | ||||||
|  |         response = PolicyProcess(binding, request, None).execute() | ||||||
|  |         self.assertEqual(response.passing, False) | ||||||
|  |  | ||||||
|  |     def test_user_passing(self): | ||||||
|  |         """Test binding to user""" | ||||||
|  |         binding = PolicyBinding(user=self.user) | ||||||
|  |  | ||||||
|  |         request = PolicyRequest(self.user) | ||||||
|  |         response = PolicyProcess(binding, request, None).execute() | ||||||
|  |         self.assertEqual(response.passing, True) | ||||||
|  |  | ||||||
|  |     def test_user_negative(self): | ||||||
|  |         """Test binding to user""" | ||||||
|  |         binding = PolicyBinding(user=get_anonymous_user()) | ||||||
|  |  | ||||||
|  |         request = PolicyRequest(self.user) | ||||||
|  |         response = PolicyProcess(binding, request, None).execute() | ||||||
|  |         self.assertEqual(response.passing, False) | ||||||
|  |  | ||||||
|  |     def test_empty(self): | ||||||
|  |         """Test binding to user""" | ||||||
|  |         binding = PolicyBinding() | ||||||
|  |  | ||||||
|  |         request = PolicyRequest(self.user) | ||||||
|  |         response = PolicyProcess(binding, request, None).execute() | ||||||
|  |         self.assertEqual(response.passing, False) | ||||||
|  |  | ||||||
|     def test_invalid(self): |     def test_invalid(self): | ||||||
|         """Test Process with invalid arguments""" |         """Test Process with invalid arguments""" | ||||||
|         policy = DummyPolicy.objects.create(result=True, wait_min=0, wait_max=1) |         policy = DummyPolicy.objects.create(result=True, wait_min=0, wait_max=1) | ||||||
|  | |||||||
							
								
								
									
										13
									
								
								swagger.yaml
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								swagger.yaml
									
									
									
									
									
								
							| @ -3272,7 +3272,7 @@ paths: | |||||||
|     parameters: |     parameters: | ||||||
|       - name: policy_uuid |       - name: policy_uuid | ||||||
|         in: path |         in: path | ||||||
|         description: A UUID string identifying this Group Membership Policy. |         description: A UUID string identifying this Group Membership Policy (deprecated). | ||||||
|         required: true |         required: true | ||||||
|         type: string |         type: string | ||||||
|         format: uuid |         format: uuid | ||||||
| @ -8633,7 +8633,6 @@ definitions: | |||||||
|   PolicyBinding: |   PolicyBinding: | ||||||
|     description: PolicyBinding Serializer |     description: PolicyBinding Serializer | ||||||
|     required: |     required: | ||||||
|       - policy |  | ||||||
|       - target |       - target | ||||||
|       - order |       - order | ||||||
|     type: object |     type: object | ||||||
| @ -8647,8 +8646,18 @@ definitions: | |||||||
|         title: Policy |         title: Policy | ||||||
|         type: string |         type: string | ||||||
|         format: uuid |         format: uuid | ||||||
|  |         x-nullable: true | ||||||
|       policy_obj: |       policy_obj: | ||||||
|         $ref: '#/definitions/Policy' |         $ref: '#/definitions/Policy' | ||||||
|  |       group: | ||||||
|  |         title: Group | ||||||
|  |         type: string | ||||||
|  |         format: uuid | ||||||
|  |         x-nullable: true | ||||||
|  |       user: | ||||||
|  |         title: User | ||||||
|  |         type: integer | ||||||
|  |         x-nullable: true | ||||||
|       target: |       target: | ||||||
|         title: Target |         title: Target | ||||||
|         type: string |         type: string | ||||||
|  | |||||||
| @ -21,6 +21,15 @@ title: Release 2021.1.2 | |||||||
|  |  | ||||||
| - Add test view to debug property-mappings. | - Add test view to debug property-mappings. | ||||||
|  |  | ||||||
|  | - Simplify role-based access | ||||||
|  |  | ||||||
|  |     Instead of having to create a Group Membership policy for every group you want to use, you can now select a Group and even a User directly in a binding. | ||||||
|  |  | ||||||
|  |     When a group is selected, the binding behaves the same as if a Group Membership policy exists. | ||||||
|  |  | ||||||
|  |     When a user is selected, the binding checks the user of the request, and denies the request when the user doesn't match. | ||||||
|  |  | ||||||
|  |  | ||||||
| ## Fixes | ## Fixes | ||||||
|  |  | ||||||
| - admin: add test view for property mappings | - admin: add test view for property mappings | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user
	 Jens Langhammer
					Jens Langhammer