diff --git a/authentik/api/decorators.py b/authentik/api/decorators.py new file mode 100644 index 000000000..fd89c01dc --- /dev/null +++ b/authentik/api/decorators.py @@ -0,0 +1,28 @@ +"""API Decorators""" +from functools import wraps +from typing import Callable + +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + + +def permission_required(perm: str, *other_perms: str): + """Check permissions for a single custom action""" + + def wrapper_outter(func: Callable): + """Check permissions for a single custom action""" + + @wraps(func) + def wrapper(self: ModelViewSet, request: Request, *args, **kwargs) -> Response: + obj = self.get_object() + if not request.user.has_perm(perm, obj): + return self.permission_denied(request) + for other_perm in other_perms: + if not request.user.has_perm(other_perm): + return self.permission_denied(request) + return func(self, request, *args, **kwargs) + + return wrapper + + return wrapper_outter diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py index eae9fc986..66076fe6c 100644 --- a/authentik/core/api/applications.py +++ b/authentik/core/api/applications.py @@ -1,12 +1,9 @@ """Application API Views""" from django.core.cache import cache from django.db.models import QuerySet -from django.http.response import Http404 from drf_yasg2.utils import swagger_auto_schema -from guardian.shortcuts import get_objects_for_user from rest_framework.decorators import action from rest_framework.fields import SerializerMethodField -from rest_framework.generics import get_object_or_404 from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ModelSerializer @@ -15,6 +12,7 @@ from rest_framework_guardian.filters import ObjectPermissionsFilter from structlog.stdlib import get_logger from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h +from authentik.api.decorators import permission_required from authentik.core.api.providers import ProviderSerializer from authentik.core.models import Application from authentik.events.models import EventAction @@ -110,16 +108,15 @@ class ApplicationViewSet(ModelViewSet): serializer = self.get_serializer(allowed_applications, many=True) return self.get_paginated_response(serializer.data) + @permission_required( + "authentik_core.view_application", "authentik_events.view_event" + ) @swagger_auto_schema(responses={200: CoordinateSerializer(many=True)}) @action(detail=True) + # pylint: disable=unused-argument def metrics(self, request: Request, slug: str): """Metrics for application logins""" - app = get_object_or_404( - get_objects_for_user(request.user, "authentik_core.view_application"), - slug=slug, - ) - if not request.user.has_perm("authentik_events.view_event"): - raise Http404 + app = self.get_object() return Response( get_events_per_1h( action=EventAction.AUTHORIZE_APPLICATION, diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index c8c04082c..2c9e0adcd 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -1,5 +1,7 @@ """User API Views""" from django.db.models.base import Model +from django.urls import reverse_lazy +from django.utils.http import urlencode from drf_yasg2.utils import swagger_auto_schema, swagger_serializer_method from guardian.utils import get_anonymous_user from rest_framework.decorators import action @@ -10,11 +12,12 @@ from rest_framework.serializers import BooleanField, ModelSerializer, Serializer from rest_framework.viewsets import ModelViewSet from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h +from authentik.api.decorators import permission_required from authentik.core.middleware import ( SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER, ) -from authentik.core.models import User +from authentik.core.models import Token, TokenIntents, User from authentik.events.models import EventAction @@ -54,6 +57,18 @@ class SessionUserSerializer(Serializer): raise NotImplementedError +class UserRecoverySerializer(Serializer): + """Recovery link for a user to reset their password""" + + link = CharField() + + def create(self, validated_data: dict) -> Model: + raise NotImplementedError + + def update(self, instance: Model, validated_data: dict) -> Model: + raise NotImplementedError + + class UserMetricsSerializer(Serializer): """User Metrics""" @@ -116,6 +131,7 @@ class UserViewSet(ModelViewSet): serializer.is_valid() return Response(serializer.data) + @permission_required("authentik_core.view_user", "authentik_events.view_event") @swagger_auto_schema(responses={200: UserMetricsSerializer(many=False)}) @action(detail=False) def metrics(self, request: Request) -> Response: @@ -123,3 +139,23 @@ class UserViewSet(ModelViewSet): serializer = UserMetricsSerializer(True) serializer.context["request"] = request return Response(serializer.data) + + @permission_required("authentik_core.reset_user_password") + @swagger_auto_schema( + responses={"200": UserRecoverySerializer(many=False)}, + ) + @action(detail=True) + # pylint: disable=invalid-name, unused-argument + def recovery(self, request: Request, pk: int) -> Response: + """Create a temporary link that a user can use to recover their accounts""" + user: User = self.get_object() + token, __ = Token.objects.get_or_create( + identifier=f"{user.uid}-password-reset", + user=user, + intent=TokenIntents.INTENT_RECOVERY, + ) + querystring = urlencode({"token": token.key}) + link = request.build_absolute_uri( + reverse_lazy("authentik_flows:default-recovery") + f"?{querystring}" + ) + return Response({"link": link}) diff --git a/authentik/events/api/event.py b/authentik/events/api/event.py index 75cfbeae9..3eb7230bc 100644 --- a/authentik/events/api/event.py +++ b/authentik/events/api/event.py @@ -3,6 +3,7 @@ import django_filters from django.db.models.aggregates import Count from django.db.models.fields.json import KeyTextTransform from drf_yasg2.utils import swagger_auto_schema +from guardian.shortcuts import get_objects_for_user from rest_framework.decorators import action from rest_framework.fields import CharField, DictField, IntegerField from rest_framework.request import Request @@ -132,7 +133,8 @@ class EventViewSet(ReadOnlyModelViewSet): filtered_action = request.query_params.get("action", EventAction.LOGIN) top_n = request.query_params.get("top_n", 15) return Response( - Event.objects.filter(action=filtered_action) + get_objects_for_user(request.user, "authentik_events.view_event") + .filter(action=filtered_action) .exclude(context__authorized_application=None) .annotate(application=KeyTextTransform("authorized_application", "context")) .annotate(user_pk=KeyTextTransform("pk", "user")) diff --git a/authentik/events/api/notification_transport.py b/authentik/events/api/notification_transport.py index e951f2a9f..b36b2dd71 100644 --- a/authentik/events/api/notification_transport.py +++ b/authentik/events/api/notification_transport.py @@ -1,7 +1,6 @@ """NotificationTransport API Views""" from django.http.response import Http404 from drf_yasg2.utils import no_body, swagger_auto_schema -from guardian.shortcuts import get_objects_for_user from rest_framework.decorators import action from rest_framework.fields import CharField, ListField, SerializerMethodField from rest_framework.request import Request @@ -9,6 +8,7 @@ from rest_framework.response import Response from rest_framework.serializers import ModelSerializer, Serializer from rest_framework.viewsets import ModelViewSet +from authentik.api.decorators import permission_required from authentik.events.models import ( Notification, NotificationSeverity, @@ -57,18 +57,17 @@ class NotificationTransportViewSet(ModelViewSet): queryset = NotificationTransport.objects.all() serializer_class = NotificationTransportSerializer + @permission_required("authentik_events.change_notificationtransport") @swagger_auto_schema( responses={200: NotificationTransportTestSerializer(many=False)}, request_body=no_body, ) @action(detail=True, methods=["post"]) - # pylint: disable=invalid-name + # pylint: disable=invalid-name, unused-argument def test(self, request: Request, pk=None) -> Response: """Send example notification using selected transport. Requires Modify permissions.""" - transports = get_objects_for_user( - request.user, "authentik_events.change_notificationtransport" - ).filter(pk=pk) + transports = self.get_object() if not transports.exists(): raise Http404 transport: NotificationTransport = transports.first() diff --git a/authentik/flows/api/flows.py b/authentik/flows/api/flows.py index a87aff0b3..c2a886e4c 100644 --- a/authentik/flows/api/flows.py +++ b/authentik/flows/api/flows.py @@ -3,13 +3,11 @@ from dataclasses import dataclass from django.core.cache import cache from django.db.models import Model -from django.http.response import HttpResponseBadRequest, JsonResponse -from django.shortcuts import get_object_or_404 +from django.http.response import JsonResponse from drf_yasg2 import openapi from drf_yasg2.utils import no_body, swagger_auto_schema from guardian.shortcuts import get_objects_for_user from rest_framework.decorators import action -from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ( @@ -21,6 +19,7 @@ from rest_framework.serializers import ( from rest_framework.viewsets import ModelViewSet from structlog.stdlib import get_logger +from authentik.api.decorators import permission_required from authentik.core.api.utils import CacheSerializer from authentik.flows.models import Flow from authentik.flows.planner import cache_key @@ -89,12 +88,14 @@ class FlowViewSet(ModelViewSet): search_fields = ["name", "slug", "designation", "title"] filterset_fields = ["flow_uuid", "name", "slug", "designation"] + @permission_required("authentik_flows.view_flow_cache") @swagger_auto_schema(responses={200: CacheSerializer(many=False)}) @action(detail=False) def cache_info(self, request: Request) -> Response: """Info about cached flows""" return Response(data={"count": len(cache.keys("flow_*"))}) + @permission_required("authentik_flows.clear_flow_cache") @swagger_auto_schema( request_body=no_body, responses={204: "Successfully cleared cache", 400: "Bad request"}, @@ -102,13 +103,12 @@ class FlowViewSet(ModelViewSet): @action(detail=False, methods=["POST"]) def cache_clear(self, request: Request) -> Response: """Clear flow cache""" - if not request.user.is_superuser: - return HttpResponseBadRequest() keys = cache.keys("flow_*") cache.delete_many(keys) LOGGER.debug("Cleared flow cache", keys=len(keys)) return Response(status=204) + @permission_required("authentik_flows.export_flow") @swagger_auto_schema( responses={ "200": openapi.Response( @@ -121,8 +121,6 @@ class FlowViewSet(ModelViewSet): def export(self, request: Request, slug: str) -> Response: """Export flow to .akflow file""" flow = self.get_object() - if not request.user.has_perm("authentik_flows.export_flow", flow): - raise PermissionDenied() exporter = FlowExporter(flow) response = JsonResponse(exporter.export(), encoder=DataclassEncoder, safe=False) response["Content-Disposition"] = f'attachment; filename="{flow.slug}.akflow"' @@ -130,13 +128,10 @@ class FlowViewSet(ModelViewSet): @swagger_auto_schema(responses={200: FlowDiagramSerializer()}) @action(detail=True, methods=["get"]) + # pylint: disable=unused-argument def diagram(self, request: Request, slug: str) -> Response: """Return diagram for flow with slug `slug`, in the format used by flowchart.js""" - flow = get_object_or_404( - get_objects_for_user(request.user, "authentik_flows.view_flow").filter( - slug=slug - ) - ) + flow = self.get_object() header = [ DiagramElement("st", "start", "Start"), ] diff --git a/authentik/flows/migrations/0017_auto_20210329_1334.py b/authentik/flows/migrations/0017_auto_20210329_1334.py new file mode 100644 index 000000000..bcaf18eef --- /dev/null +++ b/authentik/flows/migrations/0017_auto_20210329_1334.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.7 on 2021-03-29 13:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0016_auto_20201202_1307"), + ] + + operations = [ + migrations.AlterModelOptions( + name="flow", + options={ + "permissions": [ + ("export_flow", "Can export a Flow"), + ("view_flow_cache", "View Flow's cache metrics"), + ("clear_flow_cache", "Clear Flow's cache metrics"), + ], + "verbose_name": "Flow", + "verbose_name_plural": "Flows", + }, + ), + ] diff --git a/authentik/flows/models.py b/authentik/flows/models.py index 136882acd..3db3db439 100644 --- a/authentik/flows/models.py +++ b/authentik/flows/models.py @@ -158,6 +158,8 @@ class Flow(SerializerModel, PolicyBindingModel): permissions = [ ("export_flow", "Can export a Flow"), + ("view_flow_cache", "View Flow's cache metrics"), + ("clear_flow_cache", "Clear Flow's cache metrics"), ] diff --git a/authentik/policies/api.py b/authentik/policies/api.py index d06740ab4..3eb1f0a84 100644 --- a/authentik/policies/api.py +++ b/authentik/policies/api.py @@ -16,6 +16,7 @@ from rest_framework.serializers import ( from rest_framework.viewsets import GenericViewSet, ModelViewSet from structlog.stdlib import get_logger +from authentik.api.decorators import permission_required from authentik.core.api.applications import user_app_cache_key from authentik.core.api.groups import GroupSerializer from authentik.core.api.utils import ( @@ -143,12 +144,14 @@ class PolicyViewSet( ) return Response(TypeCreateSerializer(data, many=True).data) + @permission_required("authentik_policies.view_policy_cache") @swagger_auto_schema(responses={200: CacheSerializer(many=False)}) @action(detail=False) def cache_info(self, request: Request) -> Response: """Info about cached policies""" return Response(data={"count": len(cache.keys("policy_*"))}) + @permission_required("authentik_policies.clear_policy_cache") @swagger_auto_schema( request_body=no_body, responses={204: "Successfully cleared cache", 400: "Bad request"}, diff --git a/authentik/policies/migrations/0006_auto_20210329_1334.py b/authentik/policies/migrations/0006_auto_20210329_1334.py new file mode 100644 index 000000000..e35b550a0 --- /dev/null +++ b/authentik/policies/migrations/0006_auto_20210329_1334.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.7 on 2021-03-29 13:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies", "0005_binding_group"), + ] + + operations = [ + migrations.AlterModelOptions( + name="policy", + options={ + "base_manager_name": "objects", + "permissions": [ + ("view_policy_cache", "View Policy's cache metrics"), + ("clear_policy_cache", "Clear Policy's cache metrics"), + ], + "verbose_name": "Policy", + "verbose_name_plural": "Policies", + }, + ), + ] diff --git a/authentik/policies/models.py b/authentik/policies/models.py index 9ad95422a..7e34131c3 100644 --- a/authentik/policies/models.py +++ b/authentik/policies/models.py @@ -149,3 +149,8 @@ class Policy(SerializerModel, CreatedUpdatedModel): verbose_name = _("Policy") verbose_name_plural = _("Policies") + + permissions = [ + ("view_policy_cache", "View Policy's cache metrics"), + ("clear_policy_cache", "Clear Policy's cache metrics"), + ] diff --git a/swagger.yaml b/swagger.yaml index 264adfc9c..911e341d9 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -1726,6 +1726,24 @@ paths: description: A unique integer value identifying this User. required: true type: integer + /core/users/{id}/recovery/: + get: + operationId: core_users_recovery + description: Create a temporary link that a user can use to recover their accounts + parameters: [] + responses: + '200': + description: Recovery link for a user to reset their password + schema: + $ref: '#/definitions/UserRecovery' + tags: + - core + parameters: + - name: id + in: path + description: A unique integer value identifying this User. + required: true + type: integer /crypto/certificatekeypairs/: get: operationId: crypto_certificatekeypairs_list @@ -11120,6 +11138,16 @@ definitions: items: $ref: '#/definitions/Coordinate' readOnly: true + UserRecovery: + description: Recovery link for a user to reset their password + required: + - link + type: object + properties: + link: + title: Link + type: string + minLength: 1 CertificateKeyPair: description: CertificateKeyPair Serializer required: diff --git a/website/docs/integrations/services/minio/index.md b/website/docs/integrations/services/minio/index.md new file mode 100644 index 000000000..ca26424ce --- /dev/null +++ b/website/docs/integrations/services/minio/index.md @@ -0,0 +1,45 @@ +--- +title: MinIO +--- + +## What is MinIO + +From https://en.wikipedia.org/wiki/MinIO + +:::note +MinIO is an Amazon S3 compatible object storage suite capable of handling structured and unstructured data including log files, artifacts, backups, container images, photos and videos. The current maximum supported object size is 5TB. +::: + +## Preparation + +The following placeholders will be used: + +- `minio.company` is the FQDN of the MinIO install. +- `authentik.company` is the FQDN of the authentik install. + +Under _Property Mappings_, create a _Scope Mapping_. Give it a name like "OIDC-Scope-minio". Set the scope name to `minio` and the expression to the following + +```python +return { + "policy": "readwrite", +} +``` + +Create an application in authentik. Create an _OAuth2/OpenID Provider_ with the following parameters: + +- Client Type: `Public` +- JWT Algorithm: `RS256` +- Scopes: OpenID, Email, Profile and the scope you created above +- RSA Key: Select any available key +- Redirect URIs: `https://minio.company/minio/login/openid` + +Note the Client ID and Client Secret values. Create an application, using the provider you've created above. Note the slug of the application you've created. + +## MinIO + +``` +~ mc admin config set myminio identity_openid \ + config_url="https://id.beryju.org/application/o//.well-known/openid-configuration" \ + client_id="" \ + scopes="openid,profile,email,minio" +``` diff --git a/website/sidebars.js b/website/sidebars.js index 1198838d1..dfebd7e10 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -117,6 +117,7 @@ module.exports = { "integrations/services/grafana/index", "integrations/services/harbor/index", "integrations/services/home-assistant/index", + "integrations/services/minio/index", "integrations/services/nextcloud/index", "integrations/services/rancher/index", "integrations/services/sentry/index",