Skip to content

Commit

Permalink
rbac: rework API for terraform, add blueprint support (goauthentik#10698
Browse files Browse the repository at this point in the history
)

* rbac: rework API slightly to improve terraform compatibility

Signed-off-by: Jens Langhammer <[email protected]>

* sigh https://www.django-rest-framework.org/api-guide/filtering/#filtering-and-object-lookups

Signed-off-by: Jens Langhammer <[email protected]>

* add permission support for users global permissions

Signed-off-by: Jens Langhammer <[email protected]>

* add role support to blueprints

Signed-off-by: Jens Langhammer <[email protected]>

* fix yaml tags

Signed-off-by: Jens Langhammer <[email protected]>

* add generated read-only role

Signed-off-by: Jens Langhammer <[email protected]>

* fix web

Signed-off-by: Jens Langhammer <[email protected]>

* make permissions optional

Signed-off-by: Jens Langhammer <[email protected]>

* add docs

Signed-off-by: Jens Langhammer <[email protected]>

* add object permission support to blueprints

Signed-off-by: Jens Langhammer <[email protected]>

* fix tests kinda

Signed-off-by: Jens Langhammer <[email protected]>

* add more tests and fix bugs

Signed-off-by: Jens Langhammer <[email protected]>

---------

Signed-off-by: Jens Langhammer <[email protected]>
  • Loading branch information
BeryJu authored Aug 2, 2024
1 parent 3541ec4 commit d24e2ab
Show file tree
Hide file tree
Showing 31 changed files with 4,130 additions and 90 deletions.
15 changes: 8 additions & 7 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,16 @@
"todo-tree.tree.showCountsInTree": true,
"todo-tree.tree.showBadges": true,
"yaml.customTags": [
"!Find sequence",
"!KeyOf scalar",
"!Context scalar",
"!Context sequence",
"!Format sequence",
"!Condition sequence",
"!Env sequence",
"!Context scalar",
"!Enumerate sequence",
"!Env scalar",
"!If sequence"
"!Find sequence",
"!Format sequence",
"!If sequence",
"!Index scalar",
"!KeyOf scalar",
"!Value scalar"
],
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.importModuleSpecifierEnding": "index",
Expand Down
25 changes: 23 additions & 2 deletions authentik/blueprints/management/commands/make_blueprint_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,16 +113,19 @@ def build(self):
)
model_path = f"{model._meta.app_label}.{model._meta.model_name}"
self.schema["properties"]["entries"]["items"]["oneOf"].append(
self.template_entry(model_path, serializer)
self.template_entry(model_path, model, serializer)
)

def template_entry(self, model_path: str, serializer: Serializer) -> dict:
def template_entry(self, model_path: str, model: type[Model], serializer: Serializer) -> dict:
"""Template entry for a single model"""
model_schema = self.to_jsonschema(serializer)
model_schema["required"] = []
def_name = f"model_{model_path}"
def_path = f"#/$defs/{def_name}"
self.schema["$defs"][def_name] = model_schema
def_name_perm = f"model_{model_path}_permissions"
def_path_perm = f"#/$defs/{def_name_perm}"
self.schema["$defs"][def_name_perm] = self.model_permissions(model)
return {
"type": "object",
"required": ["model", "identifiers"],
Expand All @@ -135,6 +138,7 @@ def template_entry(self, model_path: str, serializer: Serializer) -> dict:
"default": "present",
},
"conditions": {"type": "array", "items": {"type": "boolean"}},
"permissions": {"$ref": def_path_perm},
"attrs": {"$ref": def_path},
"identifiers": {"$ref": def_path},
},
Expand Down Expand Up @@ -185,3 +189,20 @@ def to_jsonschema(self, serializer: Serializer) -> dict:
if required:
result["required"] = required
return result

def model_permissions(self, model: type[Model]) -> dict:
perms = [x[0] for x in model._meta.permissions]
for action in model._meta.default_permissions:
perms.append(f"{action}_{model._meta.model_name}")
return {
"type": "array",
"items": {
"type": "object",
"required": ["permission"],
"properties": {
"permission": {"type": "string", "enum": perms},
"user": {"type": "integer"},
"role": {"type": "string"},
},
},
}
24 changes: 24 additions & 0 deletions authentik/blueprints/tests/fixtures/rbac_object.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
version: 1
entries:
- model: authentik_core.user
id: user
identifiers:
username: "%(id)s"
attrs:
name: "%(id)s"
- model: authentik_rbac.role
id: role
identifiers:
name: "%(id)s"
- model: authentik_flows.flow
identifiers:
slug: "%(id)s"
attrs:
designation: authentication
name: foo
title: foo
permissions:
- permission: view_flow
user: !KeyOf user
- permission: view_flow
role: !KeyOf role
8 changes: 8 additions & 0 deletions authentik/blueprints/tests/fixtures/rbac_role.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version: 1
entries:
- model: authentik_rbac.role
identifiers:
name: "%(id)s"
attrs:
permissions:
- authentik_blueprints.view_blueprintinstance
9 changes: 9 additions & 0 deletions authentik/blueprints/tests/fixtures/rbac_user.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
version: 1
entries:
- model: authentik_core.user
identifiers:
username: "%(id)s"
attrs:
name: "%(id)s"
permissions:
- authentik_blueprints.view_blueprintinstance
57 changes: 57 additions & 0 deletions authentik/blueprints/tests/test_v1_rbac.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Test blueprints v1"""

from django.test import TransactionTestCase
from guardian.shortcuts import get_perms

from authentik.blueprints.v1.importer import Importer
from authentik.core.models import User
from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
from authentik.rbac.models import Role


class TestBlueprintsV1RBAC(TransactionTestCase):
"""Test Blueprints rbac attribute"""

def test_user_permission(self):
"""Test permissions"""
uid = generate_id()
import_yaml = load_fixture("fixtures/rbac_user.yaml", id=uid)

importer = Importer.from_string(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
user = User.objects.filter(username=uid).first()
self.assertIsNotNone(user)
self.assertTrue(user.has_perms(["authentik_blueprints.view_blueprintinstance"]))

def test_role_permission(self):
"""Test permissions"""
uid = generate_id()
import_yaml = load_fixture("fixtures/rbac_role.yaml", id=uid)

importer = Importer.from_string(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
role = Role.objects.filter(name=uid).first()
self.assertIsNotNone(role)
self.assertEqual(
list(role.group.permissions.all().values_list("codename", flat=True)),
["view_blueprintinstance"],
)

def test_object_permission(self):
"""Test permissions"""
uid = generate_id()
import_yaml = load_fixture("fixtures/rbac_object.yaml", id=uid)

importer = Importer.from_string(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
flow = Flow.objects.filter(slug=uid).first()
user = User.objects.filter(username=uid).first()
role = Role.objects.filter(name=uid).first()
self.assertIsNotNone(flow)
self.assertEqual(get_perms(user, flow), ["view_flow"])
self.assertEqual(get_perms(role.group, flow), ["view_flow"])
23 changes: 22 additions & 1 deletion authentik/blueprints/v1/common.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""transfer common classes"""

from collections import OrderedDict
from collections.abc import Iterable, Mapping
from collections.abc import Generator, Iterable, Mapping
from copy import copy
from dataclasses import asdict, dataclass, field, is_dataclass
from enum import Enum
Expand Down Expand Up @@ -58,6 +58,15 @@ class BlueprintEntryDesiredState(Enum):
MUST_CREATED = "must_created"


@dataclass
class BlueprintEntryPermission:
"""Describe object-level permissions"""

permission: Union[str, "YAMLTag"]
user: Union[int, "YAMLTag", None] = field(default=None)
role: Union[str, "YAMLTag", None] = field(default=None)


@dataclass
class BlueprintEntry:
"""Single entry of a blueprint"""
Expand All @@ -69,6 +78,7 @@ class BlueprintEntry:
conditions: list[Any] = field(default_factory=list)
identifiers: dict[str, Any] = field(default_factory=dict)
attrs: dict[str, Any] | None = field(default_factory=dict)
permissions: list[BlueprintEntryPermission] = field(default_factory=list)

id: str | None = None

Expand Down Expand Up @@ -150,6 +160,17 @@ def get_model(self, blueprint: "Blueprint") -> str:
"""Get the blueprint model, with yaml tags resolved if present"""
return str(self.tag_resolver(self.model, blueprint))

def get_permissions(
self, blueprint: "Blueprint"
) -> Generator[BlueprintEntryPermission, None, None]:
"""Get permissions of this entry, with all yaml tags resolved"""
for perm in self.permissions:
yield BlueprintEntryPermission(
permission=self.tag_resolver(perm.permission, blueprint),
user=self.tag_resolver(perm.user, blueprint),
role=self.tag_resolver(perm.role, blueprint),
)

def check_all_conditions_match(self, blueprint: "Blueprint") -> bool:
"""Check all conditions of this entry match (evaluate to True)"""
return all(self.tag_resolver(self.conditions, blueprint))
Expand Down
29 changes: 28 additions & 1 deletion authentik/blueprints/v1/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from django.db.transaction import atomic
from django.db.utils import IntegrityError
from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import BaseSerializer, Serializer
from structlog.stdlib import BoundLogger, get_logger
Expand All @@ -35,6 +36,7 @@
PropertyMapping,
Provider,
Source,
User,
UserSourceConnection,
)
from authentik.enterprise.license import LicenseKey
Expand All @@ -54,11 +56,13 @@
from authentik.flows.models import FlowToken, Stage
from authentik.lib.models import SerializerModel
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.reflection import get_apps
from authentik.outposts.models import OutpostServiceConnection
from authentik.policies.models import Policy, PolicyBindingModel
from authentik.policies.reputation.models import Reputation
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser
from authentik.rbac.models import Role
from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType
from authentik.tenants.models import Tenant
Expand Down Expand Up @@ -136,6 +140,16 @@ def transaction_rollback():
pass


def rbac_models() -> dict:
models = {}
for app in get_apps():
for model in app.get_models():
if not is_model_allowed(model):
continue
models[model._meta.model_name] = app.label
return models


class Importer:
"""Import Blueprint from raw dict or YAML/JSON"""

Expand All @@ -154,7 +168,10 @@ def __init__(self, blueprint: Blueprint, context: dict | None = None):

def default_context(self):
"""Default context"""
return {"goauthentik.io/enterprise/licensed": LicenseKey.get_total().is_valid()}
return {
"goauthentik.io/enterprise/licensed": LicenseKey.get_total().is_valid(),
"goauthentik.io/rbac/models": rbac_models(),
}

@staticmethod
def from_string(yaml_input: str, context: dict | None = None) -> "Importer":
Expand Down Expand Up @@ -320,6 +337,15 @@ def _validate_single(self, entry: BlueprintEntry) -> BaseSerializer | None:
) from exc
return serializer

def _apply_permissions(self, instance: Model, entry: BlueprintEntry):
"""Apply object-level permissions for an entry"""
for perm in entry.get_permissions(self._import):
if perm.user is not None:
assign_perm(perm.permission, User.objects.get(pk=perm.user), instance)
if perm.role is not None:
role = Role.objects.get(pk=perm.role)
role.assign_permission(perm.permission, obj=instance)

def apply(self) -> bool:
"""Apply (create/update) models yaml, in database transaction"""
try:
Expand Down Expand Up @@ -384,6 +410,7 @@ def _apply_models(self, raise_errors=False) -> bool:
if "pk" in entry.identifiers:
self.__pk_map[entry.identifiers["pk"]] = instance.pk
entry._state = BlueprintEntryState(instance)
self._apply_permissions(instance, entry)
elif state == BlueprintEntryDesiredState.ABSENT:
instance: Model | None = serializer.instance
if instance.pk:
Expand Down
27 changes: 23 additions & 4 deletions authentik/core/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Any

from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.models import Permission
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
from django.db.models.functions import ExtractHour
Expand Down Expand Up @@ -33,15 +34,21 @@
)
from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action
from rest_framework.fields import CharField, IntegerField, ListField, SerializerMethodField
from rest_framework.exceptions import ValidationError
from rest_framework.fields import (
BooleanField,
CharField,
ChoiceField,
DateTimeField,
IntegerField,
ListField,
SerializerMethodField,
)
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import (
BooleanField,
DateTimeField,
ListSerializer,
PrimaryKeyRelatedField,
ValidationError,
)
from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet
Expand Down Expand Up @@ -78,6 +85,7 @@
from authentik.flows.views.executor import QS_KEY_TOKEN
from authentik.lib.avatars import get_avatar
from authentik.rbac.decorators import permission_required
from authentik.rbac.models import get_permission_choices
from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage
Expand Down Expand Up @@ -141,12 +149,19 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
self.fields["password"] = CharField(required=False, allow_null=True)
self.fields["permissions"] = ListField(
required=False, child=ChoiceField(choices=get_permission_choices())
)

def create(self, validated_data: dict) -> User:
"""If this serializer is used in the blueprint context, we allow for
directly setting a password. However should be done via the `set_password`
method instead of directly setting it like rest_framework."""
password = validated_data.pop("password", None)
permissions = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
)
validated_data["user_permissions"] = permissions
instance: User = super().create(validated_data)
self._set_password(instance, password)
return instance
Expand All @@ -155,6 +170,10 @@ def update(self, instance: User, validated_data: dict) -> User:
"""Same as `create` above, set the password directly if we're in a blueprint
context"""
password = validated_data.pop("password", None)
permissions = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
)
validated_data["user_permissions"] = permissions
instance = super().update(instance, validated_data)
self._set_password(instance, password)
return instance
Expand Down
1 change: 1 addition & 0 deletions authentik/providers/saml/api/property_mappings.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class SAMLPropertyMappingFilter(PropertyMappingFilterSet):

class Meta(PropertyMappingFilterSet.Meta):
model = SAMLPropertyMapping
fields = PropertyMappingFilterSet.Meta.fields + ["saml_name", "friendly_name"]


class SAMLPropertyMappingViewSet(UsedByMixin, ModelViewSet):
Expand Down
Loading

0 comments on commit d24e2ab

Please sign in to comment.