Compare commits

..

4 Commits

Author SHA1 Message Date
8128d8dab5 fix rac cache missing key
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-07-01 21:49:31 +02:00
f4a68c7878 use nested for RAC
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-07-01 21:46:35 +02:00
7ab17822e3 add support for nested routes
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-07-01 21:46:16 +02:00
76da77f26e fix ql schema
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-07-01 21:20:10 +02:00
15 changed files with 716 additions and 732 deletions

View File

@ -0,0 +1,67 @@
from rest_framework.routers import DefaultRouter as UpstreamDefaultRouter
from rest_framework.viewsets import ViewSet
from rest_framework_nested.routers import NestedMixin
class DefaultRouter(UpstreamDefaultRouter):
include_format_suffixes = False
class NestedRouter(DefaultRouter):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.nested_routers = []
class nested:
def __init__(self, parent: "NestedRouter", prefix: str):
self.parent = parent
self.prefix = prefix
self.inner = None
def nested(self, lookup: str, prefix: str, viewset: type[ViewSet]):
if not self.inner:
self.inner = NestedDefaultRouter(self.parent, self.prefix, lookup=lookup)
self.inner.register(prefix, viewset)
return self
@property
def urls(self):
return self.parent.urls
def register(self, prefix, viewset, basename=None):
super().register(prefix, viewset, basename)
nested_router = self.nested(self, prefix)
self.nested_routers.append(nested_router)
return nested_router
def get_urls(self):
urls = super().get_urls()
for nested in self.nested_routers:
if not nested.inner:
continue
urls.extend(nested.inner.urls)
return urls
class NestedDefaultRouter(NestedMixin, DefaultRouter):
...
# def __init__(self, *args, **kwargs):
# self.args = args
# self.kwargs = kwargs
# self.routes = []
# def register(self, *args, **kwargs):
# self.routes.append((args, kwargs))
# @property
# def urls(self):
# class r(NestedMixin, DefaultRouter):
# ...
# router = r(*self.args, **self.kwargs)
# for route_args, route_kwrags in self.routes:
# router.register(*route_args, **route_kwrags)
# return router
root_router = DefaultRouter()

View File

@ -6,18 +6,15 @@ from django.urls import path
from django.urls.resolvers import URLPattern
from django.views.decorators.cache import cache_page
from drf_spectacular.views import SpectacularAPIView
from rest_framework import routers
from structlog.stdlib import get_logger
from authentik.api.v3.config import ConfigView
from authentik.api.v3.routers import root_router
from authentik.api.views import APIBrowserView
from authentik.lib.utils.reflection import get_apps
LOGGER = get_logger()
router = routers.DefaultRouter()
router.include_format_suffixes = False
_other_urls = []
for _authentik_app in get_apps():
try:
@ -38,7 +35,7 @@ for _authentik_app in get_apps():
if isinstance(url, URLPattern):
_other_urls.append(url)
else:
router.register(*url)
root_router.register(*url)
LOGGER.debug(
"Mounted API URLs",
app_name=_authentik_app.name,
@ -49,7 +46,7 @@ urlpatterns = (
[
path("", APIBrowserView.as_view(), name="schema-browser"),
]
+ router.urls
+ root_router.urls
+ _other_urls
+ [
path("root/config/", ConfigView.as_view(), name="config"),

View File

@ -6,7 +6,7 @@ from djangoql.ast import Name
from djangoql.exceptions import DjangoQLError
from djangoql.queryset import apply_search
from djangoql.schema import DjangoQLSchema
from rest_framework.filters import BaseFilterBackend, SearchFilter
from rest_framework.filters import SearchFilter
from rest_framework.request import Request
from structlog.stdlib import get_logger
@ -39,7 +39,8 @@ class BaseSchema(DjangoQLSchema):
return super().resolve_name(name)
class QLSearch(BaseFilterBackend):
# Inherits from SearchFilter to keep the schema correctly
class QLSearch(SearchFilter):
"""rest_framework search filter which uses DjangoQL"""
def __init__(self):

View File

@ -40,9 +40,16 @@ class ConnectionTokenViewSet(
):
"""ConnectionToken Viewset"""
queryset = ConnectionToken.objects.all().select_related("session", "endpoint")
queryset = ConnectionToken.objects.none()
serializer_class = ConnectionTokenSerializer
filterset_fields = ["endpoint", "session__user", "provider"]
search_fields = ["endpoint__name", "provider__name"]
ordering = ["endpoint__name", "provider__name"]
filterset_fields = ["endpoint", "session__user"]
search_fields = ["endpoint__name", "session__user__username"]
ordering = ["endpoint__name", "session__user__username"]
owner_field = "session__user"
def get_queryset(self):
return (
ConnectionToken.objects.all()
.select_related("session", "endpoint")
.filter(provider=self.kwargs["provider_pk"])
)

View File

@ -22,9 +22,9 @@ from authentik.rbac.filters import ObjectFilter
LOGGER = get_logger()
def user_endpoint_cache_key(user_pk: str) -> str:
def user_endpoint_cache_key(user_pk: str, provider_pk: str) -> str:
"""Cache key where endpoint list for user is saved"""
return f"goauthentik.io/providers/rac/endpoint_access/{user_pk}"
return f"goauthentik.io/providers/rac/endpoint_access/{user_pk}/{provider_pk}"
class EndpointSerializer(ModelSerializer):
@ -65,12 +65,15 @@ class EndpointSerializer(ModelSerializer):
class EndpointViewSet(UsedByMixin, ModelViewSet):
"""Endpoint Viewset"""
queryset = Endpoint.objects.all()
queryset = Endpoint.objects.none()
serializer_class = EndpointSerializer
filterset_fields = ["name", "provider"]
filterset_fields = ["name"]
search_fields = ["name", "protocol"]
ordering = ["name", "protocol"]
def get_queryset(self):
return Endpoint.objects.filter(provider=self.kwargs["provider_pk"])
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
"""Custom filter_queryset method which ignores guardian, but still supports sorting"""
for backend in list(self.filter_backends):
@ -120,14 +123,11 @@ class EndpointViewSet(UsedByMixin, ModelViewSet):
if not should_cache:
allowed_endpoints = self._get_allowed_endpoints(queryset)
if should_cache:
allowed_endpoints = cache.get(user_endpoint_cache_key(self.request.user.pk))
key = user_endpoint_cache_key(self.request.user.pk, self.kwargs["provider_pk"])
allowed_endpoints = cache.get(key)
if not allowed_endpoints:
LOGGER.debug("Caching allowed endpoint list")
allowed_endpoints = self._get_allowed_endpoints(queryset)
cache.set(
user_endpoint_cache_key(self.request.user.pk),
allowed_endpoints,
timeout=86400,
)
cache.set(key, allowed_endpoints, timeout=86400)
serializer = self.get_serializer(allowed_endpoints, many=True)
return self.get_paginated_response(serializer.data)

View File

@ -43,5 +43,5 @@ def pre_delete_connection_token_disconnect(sender, instance: ConnectionToken, **
@receiver([post_save, post_delete], sender=Endpoint)
def post_save_post_delete_endpoint(**_):
"""Clear user's endpoint cache upon endpoint creation or deletion"""
keys = cache.keys(user_endpoint_cache_key("*"))
keys = cache.keys(user_endpoint_cache_key("*", "*"))
cache.delete_many(keys)

View File

@ -2,6 +2,7 @@
from django.urls import path
from authentik.api.v3.routers import NestedRouter
from authentik.outposts.channels import TokenOutpostMiddleware
from authentik.providers.rac.api.connection_tokens import ConnectionTokenViewSet
from authentik.providers.rac.api.endpoints import EndpointViewSet
@ -38,8 +39,10 @@ websocket_urlpatterns = [
]
api_urlpatterns = [
("providers/rac", RACProviderViewSet),
*NestedRouter()
.register("providers/rac", RACProviderViewSet)
.nested("provider", "endpoints", EndpointViewSet)
.nested("provider", "connection_tokens", ConnectionTokenViewSet)
.urls,
("propertymappings/provider/rac", RACPropertyMappingViewSet),
("rac/endpoints", EndpointViewSet),
("rac/connection_tokens", ConnectionTokenViewSet),
]

View File

@ -28,6 +28,7 @@ dependencies = [
"djangorestframework-guardian==0.3.0",
"djangorestframework==3.16.0",
"docker==7.1.0",
"drf-nested-routers==0.94.2",
"drf-orjson-renderer==1.7.3",
"drf-spectacular==0.28.0",
"dumb-init==1.2.5.post1",

1092
schema.yml

File diff suppressed because it is too large Load Diff

15
uv.lock generated
View File

@ -191,6 +191,7 @@ dependencies = [
{ name = "djangorestframework" },
{ name = "djangorestframework-guardian" },
{ name = "docker" },
{ name = "drf-nested-routers" },
{ name = "drf-orjson-renderer" },
{ name = "drf-spectacular" },
{ name = "dumb-init" },
@ -290,6 +291,7 @@ requires-dist = [
{ name = "djangorestframework", git = "https://github.com/goauthentik/django-rest-framework?rev=896722bab969fabc74a08b827da59409cf9f1a4e" },
{ name = "djangorestframework-guardian", specifier = "==0.3.0" },
{ name = "docker", specifier = "==7.1.0" },
{ name = "drf-nested-routers", specifier = "==0.94.2" },
{ name = "drf-orjson-renderer", specifier = "==1.7.3" },
{ name = "drf-spectacular", specifier = "==0.28.0" },
{ name = "dumb-init", specifier = "==1.2.5.post1" },
@ -1190,6 +1192,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/71/1f500097efe09e04c3be862ab26c997314237a8b0a16dc3e3047fee23f4c/drf_jsonschema_serializer-3.0.0-py3-none-any.whl", hash = "sha256:d0e5cce095a5638b0bb7867aa060ed59ab9eed2f54ba5058dd9b483c9c887ed5", size = 8994, upload-time = "2024-06-26T13:09:59.929Z" },
]
[[package]]
name = "drf-nested-routers"
version = "0.94.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "djangorestframework" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/98/2d29f3ecd337255bc2775b9addef347b6fd30ff7b3757649d0e50602ba08/drf_nested_routers-0.94.2.tar.gz", hash = "sha256:aa70923b716dc47cd93b8129b06be6c15706b405cf5f718f59cb8eed01de59cc", size = 22845, upload-time = "2025-05-14T17:03:50.896Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/dc/6bdb857a631fe6558db18a009c93ae16c3ad94fef0b7be7a3aa35c3264fa/drf_nested_routers-0.94.2-py2.py3-none-any.whl", hash = "sha256:74dbdceeae2a32f8668ba0df8e3eeabeb9b1c64d2621d914901ae653e4e3bcff", size = 36367, upload-time = "2025-05-14T17:03:49.257Z" },
]
[[package]]
name = "drf-orjson-renderer"
version = "1.7.3"

View File

@ -12,7 +12,7 @@ import { customElement, property } from "lit/decorators.js";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import { ConnectionToken, RACProvider, RacApi } from "@goauthentik/api";
import { ConnectionToken, ProvidersApi, RACProvider } from "@goauthentik/api";
@customElement("ak-rac-connection-token-list")
export class ConnectionTokenListPage extends Table<ConnectionToken> {
@ -37,9 +37,9 @@ export class ConnectionTokenListPage extends Table<ConnectionToken> {
}
async apiEndpoint(): Promise<PaginatedResponse<ConnectionToken>> {
return new RacApi(DEFAULT_CONFIG).racConnectionTokensList({
return new ProvidersApi(DEFAULT_CONFIG).providersRacConnectionTokensList({
...(await this.defaultEndpointConfig()),
provider: this.provider?.pk,
providerPk: this.provider!.pk,
sessionUser: this.userId,
});
}
@ -56,12 +56,14 @@ export class ConnectionTokenListPage extends Table<ConnectionToken> {
];
}}
.usedBy=${(item: ConnectionToken) => {
return new RacApi(DEFAULT_CONFIG).racConnectionTokensUsedByList({
return new ProvidersApi(DEFAULT_CONFIG).providersRacConnectionTokensUsedByList({
providerPk: this.provider!.pk,
connectionTokenUuid: item.pk || "",
});
}}
.delete=${(item: ConnectionToken) => {
return new RacApi(DEFAULT_CONFIG).racConnectionTokensDestroy({
return new ProvidersApi(DEFAULT_CONFIG).providersRacConnectionTokensDestroy({
providerPk: this.provider!.pk,
connectionTokenUuid: item.pk || "",
});
}}

View File

@ -12,7 +12,7 @@ import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { AuthModeEnum, Endpoint, ProtocolEnum, RacApi } from "@goauthentik/api";
import { AuthModeEnum, Endpoint, ProtocolEnum, ProvidersApi } from "@goauthentik/api";
import { propertyMappingsProvider, propertyMappingsSelector } from "./RACProviderFormHelpers.js";
@ -22,7 +22,8 @@ export class EndpointForm extends ModelForm<Endpoint, string> {
providerID?: number;
loadInstance(pk: string): Promise<Endpoint> {
return new RacApi(DEFAULT_CONFIG).racEndpointsRetrieve({
return new ProvidersApi(DEFAULT_CONFIG).providersRacEndpointsRetrieve({
providerPk: this.providerID!,
pbmUuid: pk,
});
}
@ -41,12 +42,14 @@ export class EndpointForm extends ModelForm<Endpoint, string> {
data.provider = this.instance.provider;
}
if (this.instance) {
return new RacApi(DEFAULT_CONFIG).racEndpointsPartialUpdate({
return new ProvidersApi(DEFAULT_CONFIG).providersRacEndpointsPartialUpdate({
providerPk: this.providerID!,
pbmUuid: this.instance.pk || "",
patchedEndpointRequest: data,
});
}
return new RacApi(DEFAULT_CONFIG).racEndpointsCreate({
return new ProvidersApi(DEFAULT_CONFIG).providersRacEndpointsCreate({
providerPk: this.providerID!,
endpointRequest: data,
});
}

View File

@ -17,8 +17,8 @@ import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList
import {
Endpoint,
ProvidersApi,
RACProvider,
RacApi,
RbacPermissionsAssignedByUsersListModelEnum,
} from "@goauthentik/api";
@ -43,9 +43,9 @@ export class EndpointListPage extends Table<Endpoint> {
}
async apiEndpoint(): Promise<PaginatedResponse<Endpoint>> {
return new RacApi(DEFAULT_CONFIG).racEndpointsList({
return new ProvidersApi(DEFAULT_CONFIG).providersRacEndpointsList({
...(await this.defaultEndpointConfig()),
provider: this.provider?.pk,
providerPk: this.provider!.pk,
superuserFullList: true,
});
}
@ -70,12 +70,14 @@ export class EndpointListPage extends Table<Endpoint> {
];
}}
.usedBy=${(item: Endpoint) => {
return new RacApi(DEFAULT_CONFIG).racEndpointsUsedByList({
return new ProvidersApi(DEFAULT_CONFIG).providersRacEndpointsUsedByList({
providerPk: this.provider!.pk,
pbmUuid: item.pk,
});
}}
.delete=${(item: Endpoint) => {
return new RacApi(DEFAULT_CONFIG).racEndpointsDestroy({
return new ProvidersApi(DEFAULT_CONFIG).providersRacEndpointsDestroy({
providerPk: this.provider!.pk,
pbmUuid: item.pk,
});
}}

View File

@ -6,7 +6,7 @@ import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Application, Endpoint, RacApi } from "@goauthentik/api";
import { Application, Endpoint, ProvidersApi } from "@goauthentik/api";
@customElement("ak-library-rac-endpoint-launch")
export class RACLaunchEndpointModal extends TableModal<Endpoint> {
@ -30,9 +30,9 @@ export class RACLaunchEndpointModal extends TableModal<Endpoint> {
app?: Application;
async apiEndpoint(): Promise<PaginatedResponse<Endpoint>> {
const endpoints = await new RacApi(DEFAULT_CONFIG).racEndpointsList({
const endpoints = await new ProvidersApi(DEFAULT_CONFIG).providersRacEndpointsList({
...(await this.defaultEndpointConfig()),
provider: this.app?.provider || 0,
providerPk: this.app?.provider || 0,
});
if (this.open && endpoints.pagination.count === 1) {
this.clickHandler(endpoints.results[0]);

View File

@ -1,172 +0,0 @@
---
title: Notification Rule Expression Policies
---
## Introduction
Notification rules with bound expression policies are very powerful. The following are examples of what can be achieved.
### Change user attributes upon account deactivation
This example code is triggered when a user account with the `sshPublicKey` attribute set is deactivated. It saves the `sshPublicKey` attribute to a new `inactivesshPublicKey` attribute, and subsequently nullifies the `sshPublicKey` attribute.
```python
from authentik.core.models import User
# Check if an event has occurred
event = request.context.get("event", None)
if not event:
ak_logger.info("no event")
return False
# Check if the event action includes updating a model
if event.action != "model_updated":
ak_logger.info("event action does not match")
return False
model_app = event.context["model"]["app"]
model_name = event.context["model"]["model_name"]
# Check if the model that was updated is the user model
if model_app != "authentik_core" or model_name != "user":
ak_logger.info("model does not match")
user_pk = event.context["model"]["pk"]
user = User.objects.filter(pk=user_pk).first()
# Check if an user object was found
if not user:
ak_logger.info("user not found")
return False
# Check if user is active
if user.is_active:
ak_logger.info("user is active, not changing")
return False
# Check if user has the `sshPublicKey` attribute set
if not user.attributes.get("sshPublicKey"):
ak_logger.info("no public keys to remove")
return False
# Save the `sshPublicKey` attribute to a new `inactiveSSHPublicKey` attribute
user.attributes["inactiveSSHPublicKey"] = user.attributes["sshPublicKey"]
# Nullify the `sshPublicKey` attribute
user.attributes["sshPublicKey"] = []
# Save the changes made to the user
user.save()
return False
```
### Alert when application is created without binding
This code is triggered when a new application is created without any user, group, or policy bound to it. The notification rule can then be configured to alert an administrator. This feature is useful for ensuring limited access to applications, as by default, an application without any users, groups, or policies bound to it can be accessed by all users.
```python
from authentik.core.models import Application
from authentik.policies.models import PolicyBinding
# Check if an event has occurred
event = request.context.get("event", None)
if not event:
ak_logger.info("no event")
return False
# Check if the event action includes creating a model
if event.action != "model_created":
ak_logger.info("event action does not match")
return False
model_app = event.context["model"]["app"]
model_name = event.context["model"]["model_name"]
# Check if the model that was created is the application model
if model_app != "authentik_core" or model_name != "application":
ak_logger.info("model does not match")
application_pk = event.context["model"]["pk"]
application = Application.objects.filter(pk=application_pk).first()
# Check if an application object was found
if not application:
ak_logger.info("application not found")
return False
# Check if application has binding
if PolicyBinding.objects.filter(target=application).exists():
output = PolicyBinding.objects.filter(target=application)
ak_logger.info("application has bindings, returning true")
return True
return False
```
### Append user addition history to group attributes
This code is triggered when a user is added to a group. It then creates and updates a `UserAddedHistory` attribute to the group with a date/time stamp and the username of the added user. This functionality is already available within the changelog of a group, but this code can be used as a template to trigger alerts or other events.
:::note
This policy interacts with the `diff` event output. This filed is only available with an enterprise license.
:::
```python
from authentik.core.models import User
from authentik.core.models import Group
from datetime import datetime
# Check if an event has occurred
event = request.context.get("event", None)
if not event:
ak_logger.info("no event")
return False
# Check if the event action includes updating a model
if event.action != "model_updated":
ak_logger.info("event action does not match")
return False
model_app = event.context["model"]["app"]
model_name = event.context["model"]["model_name"]
# Check if the model that was updated is the group model
if model_app != "authentik_core" or model_name != "group":
ak_logger.info("model does not match")
group_pk = event.context["model"]["pk"]
group = Group.objects.filter(pk=group_pk).first()
# If user was added to group, get user object, else return false
if "add" in event.context["diff"]["users"]:
ak_logger.info("user added to group")
user_pk = event.context["diff"]["users"]["add"][0]
user = User.objects.filter(pk=user_pk).first()
else:
ak_logger.info("user not added to group")
return False
# Check if a group object was found
if not group:
ak_logger.info("group not found")
return False
# Check if an user object was found
if not user:
ak_logger.info("user not found")
return False
if not group.attributes.get("UserAddedHistory"):
group.attributes["UserAddedHistory"] = []
current_date_time = datetime.now().isoformat(timespec='seconds')
group.attributes["UserAddedHistory"].append(current_date_time + " - Added user: " + user.username)
# Save the changes made to the group
group.save()
return False
```