policies: add ability to directly assign groups in bindings

This commit is contained in:
Jens Langhammer 2021-02-11 20:36:48 +01:00
parent 391eb9d469
commit 1afb4a7a76
10 changed files with 232 additions and 22 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -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,13 +79,14 @@ 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:
self.create_event( if self.binding.policy.execution_logging:
EventAction.POLICY_EXECUTION, self.create_event(
message="Policy Execution", EventAction.POLICY_EXECUTION,
result=policy_result, message="Policy Execution",
) result=policy_result,
)
except PolicyException as exc: except PolicyException as exc:
# Either use passed original exception or whatever we have # Either use passed original exception or whatever we have
src_exc = exc.src_exc if exc.src_exc else exc src_exc = exc.src_exc if exc.src_exc else exc

View File

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

View File

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

View File

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