tenants: add tenant-level attributes, applied to users based on request

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2022-04-06 10:41:35 +02:00
parent fcd9c58a73
commit 5861d41ad3
12 changed files with 85 additions and 23 deletions

View file

@ -1,7 +1,7 @@
"""User API Views""" """User API Views"""
from datetime import timedelta from datetime import timedelta
from json import loads from json import loads
from typing import Optional from typing import Any, Optional
from django.contrib.auth import update_session_auth_hash from django.contrib.auth import update_session_auth_hash
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
@ -23,7 +23,7 @@ from drf_spectacular.utils import (
) )
from guardian.shortcuts import get_anonymous_user, get_objects_for_user from guardian.shortcuts import get_anonymous_user, get_objects_for_user
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import CharField, DictField, JSONField, SerializerMethodField from rest_framework.fields import CharField, JSONField, SerializerMethodField
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ( from rest_framework.serializers import (
@ -96,14 +96,13 @@ class UserSerializer(ModelSerializer):
class UserSelfSerializer(ModelSerializer): class UserSelfSerializer(ModelSerializer):
"""User Serializer for information a user can retrieve about themselves and """User Serializer for information a user can retrieve about themselves"""
update about themselves"""
is_superuser = BooleanField(read_only=True) is_superuser = BooleanField(read_only=True)
avatar = CharField(read_only=True) avatar = CharField(read_only=True)
groups = SerializerMethodField() groups = SerializerMethodField()
uid = CharField(read_only=True) uid = CharField(read_only=True)
settings = DictField(source="attributes.settings", default=dict) settings = SerializerMethodField()
@extend_schema_field( @extend_schema_field(
ListSerializer( ListSerializer(
@ -121,6 +120,10 @@ class UserSelfSerializer(ModelSerializer):
"pk": group.pk, "pk": group.pk,
} }
def get_settings(self, user: User) -> dict[str, Any]:
"""Get user settings with tenant and group settings applied"""
return user.group_attributes(self._context["request"]).get("settings", {})
class Meta: class Meta:
model = User model = User
@ -328,12 +331,14 @@ class UserViewSet(UsedByMixin, ModelViewSet):
# pylint: disable=invalid-name # pylint: disable=invalid-name
def me(self, request: Request) -> Response: def me(self, request: Request) -> Response:
"""Get information about current user""" """Get information about current user"""
context = {"request": request}
serializer = SessionUserSerializer( serializer = SessionUserSerializer(
data={"user": UserSelfSerializer(instance=request.user).data} data={"user": UserSelfSerializer(instance=request.user, context=context).data}
) )
if SESSION_IMPERSONATE_USER in request._request.session: if SESSION_IMPERSONATE_USER in request._request.session:
serializer.initial_data["original"] = UserSelfSerializer( serializer.initial_data["original"] = UserSelfSerializer(
instance=request._request.session[SESSION_IMPERSONATE_ORIGINAL_USER] instance=request._request.session[SESSION_IMPERSONATE_ORIGINAL_USER],
context=context,
).data ).data
return Response(serializer.initial_data) return Response(serializer.initial_data)

View file

@ -147,10 +147,12 @@ class User(GuardianUserMixin, AbstractUser):
objects = UserManager() objects = UserManager()
def group_attributes(self) -> dict[str, Any]: def group_attributes(self, request: Optional[HttpRequest] = None) -> dict[str, Any]:
"""Get a dictionary containing the attributes from all groups the user belongs to, """Get a dictionary containing the attributes from all groups the user belongs to,
including the users attributes""" including the users attributes"""
final_attributes = {} final_attributes = {}
if request and hasattr(request, "tenant"):
always_merger.merge(final_attributes, request.tenant.attributes)
for group in self.ak_groups.all().order_by("name"): for group in self.ak_groups.all().order_by("name"):
always_merger.merge(final_attributes, group.attributes) always_merger.merge(final_attributes, group.attributes)
always_merger.merge(final_attributes, self.attributes) always_merger.merge(final_attributes, self.attributes)

View file

@ -442,9 +442,9 @@ class FlowErrorResponse(TemplateResponse):
context = {} context = {}
context["error"] = self.error context["error"] = self.error
if self._request.user and self._request.user.is_authenticated: if self._request.user and self._request.user.is_authenticated:
if self._request.user.is_superuser or self._request.user.group_attributes().get( if self._request.user.is_superuser or self._request.user.group_attributes(
USER_ATTRIBUTE_DEBUG, False self._request
): ).get(USER_ATTRIBUTE_DEBUG, False):
context["tb"] = "".join(format_tb(self.error.__traceback__)) context["tb"] = "".join(format_tb(self.error.__traceback__))
return context return context

View file

@ -45,7 +45,7 @@ def _get_outpost_override_ip(request: HttpRequest) -> Optional[str]:
LOGGER.warning("Attempted remote-ip override without token", fake_ip=fake_ip) LOGGER.warning("Attempted remote-ip override without token", fake_ip=fake_ip)
return None return None
user = tokens.first().user user = tokens.first().user
if not user.group_attributes().get(USER_ATTRIBUTE_CAN_OVERRIDE_IP, False): if not user.group_attributes(request).get(USER_ATTRIBUTE_CAN_OVERRIDE_IP, False):
LOGGER.warning( LOGGER.warning(
"Remote-IP override: user doesn't have permission", "Remote-IP override: user doesn't have permission",
user=user, user=user,

View file

@ -33,8 +33,8 @@ class AccessDeniedResponse(TemplateResponse):
# either superuser or has USER_ATTRIBUTE_DEBUG set # either superuser or has USER_ATTRIBUTE_DEBUG set
if self.policy_result: if self.policy_result:
if self._request.user and self._request.user.is_authenticated: if self._request.user and self._request.user.is_authenticated:
if self._request.user.is_superuser or self._request.user.group_attributes().get( if self._request.user.is_superuser or self._request.user.group_attributes(
USER_ATTRIBUTE_DEBUG, False self._request
): ).get(USER_ATTRIBUTE_DEBUG, False):
context["policy_result"] = self.policy_result context["policy_result"] = self.policy_result
return context return context

View file

@ -8,7 +8,7 @@ SCOPE_AK_PROXY_EXPRESSION = """
# which are used for example for the HTTP-Basic Authentication mapping. # which are used for example for the HTTP-Basic Authentication mapping.
return { return {
"ak_proxy": { "ak_proxy": {
"user_attributes": request.user.group_attributes() "user_attributes": request.user.group_attributes(request)
} }
}""" }"""

View file

@ -53,6 +53,7 @@ class TenantSerializer(ModelSerializer):
"flow_user_settings", "flow_user_settings",
"event_retention", "event_retention",
"web_certificate", "web_certificate",
"attributes",
] ]
@ -86,7 +87,21 @@ class TenantViewSet(UsedByMixin, ModelViewSet):
"branding_title", "branding_title",
"web_certificate__name", "web_certificate__name",
] ]
filterset_fields = "__all__" filterset_fields = [
"tenant_uuid",
"domain",
"default",
"branding_title",
"branding_logo",
"branding_favicon",
"flow_authentication",
"flow_invalidation",
"flow_recovery",
"flow_unenrollment",
"flow_user_settings",
"event_retention",
"web_certificate",
]
ordering = ["domain"] ordering = ["domain"]
@extend_schema( @extend_schema(

View file

@ -17,21 +17,21 @@ from authentik.core.models import (
) )
prompt_data = request.context.get("prompt_data") prompt_data = request.context.get("prompt_data")
if not request.user.group_attributes().get( if not request.user.group_attributes(request.http_request).get(
USER_ATTRIBUTE_CHANGE_EMAIL, CONFIG.y_bool("default_user_change_email", True) USER_ATTRIBUTE_CHANGE_EMAIL, CONFIG.y_bool("default_user_change_email", True)
): ):
if prompt_data.get("email") != request.user.email: if prompt_data.get("email") != request.user.email:
ak_message("Not allowed to change email address.") ak_message("Not allowed to change email address.")
return False return False
if not request.user.group_attributes().get( if not request.user.group_attributes(request.http_request).get(
USER_ATTRIBUTE_CHANGE_NAME, CONFIG.y_bool("default_user_change_name", True) USER_ATTRIBUTE_CHANGE_NAME, CONFIG.y_bool("default_user_change_name", True)
): ):
if prompt_data.get("name") != request.user.name: if prompt_data.get("name") != request.user.name:
ak_message("Not allowed to change name.") ak_message("Not allowed to change name.")
return False return False
if not request.user.group_attributes().get( if not request.user.group_attributes(request.http_request).get(
USER_ATTRIBUTE_CHANGE_USERNAME, CONFIG.y_bool("default_user_change_username", True) USER_ATTRIBUTE_CHANGE_USERNAME, CONFIG.y_bool("default_user_change_username", True)
): ):
if prompt_data.get("username") != request.user.username: if prompt_data.get("username") != request.user.username:

View file

@ -0,0 +1,18 @@
# Generated by Django 4.0.3 on 2022-04-06 08:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_tenants", "0002_tenant_flow_user_settings"),
]
operations = [
migrations.AddField(
model_name="tenant",
name="attributes",
field=models.JSONField(blank=True, default=dict),
),
]

View file

@ -63,6 +63,8 @@ class Tenant(models.Model):
help_text=_(("Web Certificate used by the authentik Core webserver.")), help_text=_(("Web Certificate used by the authentik Core webserver.")),
) )
attributes = models.JSONField(default=dict, blank=True)
def __str__(self) -> str: def __str__(self) -> str:
if self.default: if self.default:
return "Default tenant" return "Default tenant"

View file

@ -28229,6 +28229,9 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Web Certificate used by the authentik Core webserver. description: Web Certificate used by the authentik Core webserver.
attributes:
type: object
additionalProperties: {}
PatchedTokenRequest: PatchedTokenRequest:
type: object type: object
description: Token Serializer description: Token Serializer
@ -30673,6 +30676,9 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Web Certificate used by the authentik Core webserver. description: Web Certificate used by the authentik Core webserver.
attributes:
type: object
additionalProperties: {}
required: required:
- domain - domain
- tenant_uuid - tenant_uuid
@ -30725,6 +30731,9 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Web Certificate used by the authentik Core webserver. description: Web Certificate used by the authentik Core webserver.
attributes:
type: object
additionalProperties: {}
required: required:
- domain - domain
Token: Token:
@ -31211,9 +31220,7 @@ components:
- username - username
UserSelf: UserSelf:
type: object type: object
description: |- description: User Serializer for information a user can retrieve about themselves
User Serializer for information a user can retrieve about themselves and
update about themselves
properties: properties:
pk: pk:
type: integer type: integer
@ -31256,6 +31263,7 @@ components:
settings: settings:
type: object type: object
additionalProperties: {} additionalProperties: {}
readOnly: true
required: required:
- avatar - avatar
- groups - groups
@ -31263,6 +31271,7 @@ components:
- is_superuser - is_superuser
- name - name
- pk - pk
- settings
- uid - uid
- username - username
UserSelfGroups: UserSelfGroups:

View file

@ -1,4 +1,5 @@
import { t } from "@lingui/macro"; import { t } from "@lingui/macro";
import YAML from "yaml";
import { TemplateResult, html } from "lit"; import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js"; import { customElement } from "lit/decorators.js";
@ -341,6 +342,16 @@ export class TenantForm extends ModelForm<Tenant, string> {
${t`Format: "weeks=3;days=2;hours=3,seconds=2".`} ${t`Format: "weeks=3;days=2;hours=3,seconds=2".`}
</p> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Attributes`} name="attributes">
<ak-codemirror
mode="yaml"
value="${YAML.stringify(first(this.instance?.attributes, {}))}"
>
</ak-codemirror>
<p class="pf-c-form__helper-text">
${t`Set custom attributes using YAML or JSON. Any attributes set here will be inherited by users, if the request is handled by this tenant.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Web Certificate`} name="webCertificate"> <ak-form-element-horizontal label=${t`Web Certificate`} name="webCertificate">
<select class="pf-c-form-control"> <select class="pf-c-form-control">
<option <option