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:
parent
fcd9c58a73
commit
5861d41ad3
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}"""
|
}"""
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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:
|
||||||
|
|
18
authentik/tenants/migrations/0003_tenant_attributes.py
Normal file
18
authentik/tenants/migrations/0003_tenant_attributes.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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"
|
||||||
|
|
15
schema.yml
15
schema.yml
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
Reference in a new issue