Skip to content

Commit 240cf6d

Browse files
authored
enterprise/providers: Add RAC [AUTH-15] (goauthentik#7291)
* add basic guacamole Signed-off-by: Jens Langhammer <[email protected]> * make everything mostly work Signed-off-by: Jens Langhammer <[email protected]> * add rac build to CI Signed-off-by: Jens Langhammer <[email protected]> * fix resize, fix web lint, sendSize correctly Signed-off-by: Jens Langhammer <[email protected]> * pre-send connection from client, format Signed-off-by: Jens Langhammer <[email protected]> * improve throughput Signed-off-by: Jens Langhammer <[email protected]> * cleanup Signed-off-by: Jens Langhammer <[email protected]> * rework TokenOutpostConsumer into middleware Signed-off-by: Jens Langhammer <[email protected]> * fix some layout issues Signed-off-by: Jens Langhammer <[email protected]> * add outpost controllers Signed-off-by: Jens Langhammer <[email protected]> * start testing audio things Signed-off-by: Jens Langhammer <[email protected]> * fix a bunch of things Signed-off-by: Jens Langhammer <[email protected]> * add deps Signed-off-by: Jens Langhammer <[email protected]> * fix to work with outpost group Signed-off-by: Jens Langhammer <[email protected]> * add simple loadbalancing Signed-off-by: Jens Langhammer <[email protected]> * add simple reconnect Signed-off-by: Jens Langhammer <[email protected]> * show reconnecting text Signed-off-by: Jens Langhammer <[email protected]> * fix error when checking ports Signed-off-by: Jens Langhammer <[email protected]> * move to providers Signed-off-by: Jens Langhammer <[email protected]> * add flow check to interface Signed-off-by: Jens Langhammer <[email protected]> * fix go lint Signed-off-by: Jens Langhammer <[email protected]> * fix rac app label Signed-off-by: Jens Langhammer <[email protected]> * fix audio Signed-off-by: Jens Langhammer <[email protected]> * add logging Signed-off-by: Jens Langhammer <[email protected]> * cleanup Signed-off-by: Jens Langhammer <[email protected]> * allow overriding all settings Signed-off-by: Jens Langhammer <[email protected]> * fix duplicate keyboard, debug high DPI Signed-off-by: Jens Langhammer <[email protected]> * re-add deps Signed-off-by: Jens Langhammer <[email protected]> * fix lint Signed-off-by: Jens Langhammer <[email protected]> * fix missing __init__.py breaking model loading I love python Signed-off-by: Jens Langhammer <[email protected]> * fix tests Signed-off-by: Jens Langhammer <[email protected]> * bump successful ws connection to info Signed-off-by: Jens Langhammer <[email protected]> * hide cursor since guac draws that Signed-off-by: Jens Langhammer <[email protected]> * add clipboard support (bidirectional) Signed-off-by: Jens Langhammer <[email protected]> * make codespell not want to break the code Signed-off-by: Jens Langhammer <[email protected]> * run pr comment in separate task Signed-off-by: Jens Langhammer <[email protected]> * start endpoint and property mapping stuff Signed-off-by: Jens Langhammer <[email protected]> * more endpoint things Signed-off-by: Jens Langhammer <[email protected]> * unrelated: fix event model_pk filtering with ints Signed-off-by: Jens Langhammer <[email protected]> * unrelated: improve event display for changelog Signed-off-by: Jens Langhammer <[email protected]> * rebuild endpoint stuff again Signed-off-by: Jens Langhammer <[email protected]> * idk special url Signed-off-by: Jens Langhammer <[email protected]> * more stuff, connect token with session Signed-off-by: Jens Langhammer <[email protected]> * add disconnect Signed-off-by: Jens Langhammer <[email protected]> * rework disconnect cleanly disconnect from guacd instead of just letting the connection timeout Signed-off-by: Jens Langhammer <[email protected]> * clear cache when creating outpost Signed-off-by: Jens Langhammer <[email protected]> * support host:port and fix protocol Signed-off-by: Jens Langhammer <[email protected]> * center smaller viewport Signed-off-by: Jens Langhammer <[email protected]> * rework connection to wait more and stop after some time Signed-off-by: Jens Langhammer <[email protected]> * add policy control to endpoints Signed-off-by: Jens Langhammer <[email protected]> * remove provider protocol Signed-off-by: Jens Langhammer <[email protected]> * don't switch to different outpost connection when already chosen Signed-off-by: Jens Langhammer <[email protected]> * start using property mappings, add static settings Signed-off-by: Jens Langhammer <[email protected]> * add some RAC mapping settings Signed-off-by: Jens Langhammer <[email protected]> * fix lint Signed-off-by: Jens Langhammer <[email protected]> * start adding tests Signed-off-by: Jens Langhammer <[email protected]> * add tests for event changes Signed-off-by: Jens Langhammer <[email protected]> * add tests and fix issues found by said tests Signed-off-by: Jens Langhammer <[email protected]> * add preview banner, move endpoints to main page Signed-off-by: Jens Langhammer <[email protected]> * add locale Signed-off-by: Jens Langhammer <[email protected]> * auto-select endpoint if only one is available Signed-off-by: Jens Langhammer <[email protected]> * backport goauthentik#7831 to rac Signed-off-by: Jens Langhammer <[email protected]> * dont select property mappings on endpoints Signed-off-by: Jens Langhammer <[email protected]> * make table modal only load when opened Signed-off-by: Jens Langhammer <[email protected]> * only auto-redirect when open Signed-off-by: Jens Langhammer <[email protected]> * fix web deps Signed-off-by: Jens Langhammer <[email protected]> * check for token expiry and terminate session Signed-off-by: Jens Langhammer <[email protected]> * re-add endpoint name to title Signed-off-by: Jens Langhammer <[email protected]> * disconnect connection when token is manually deleted Signed-off-by: Jens Langhammer <[email protected]> * add initial RAC docs Signed-off-by: Jens Langhammer <[email protected]> * add connection expiry setting to provider Signed-off-by: Jens Langhammer <[email protected]> * fix flaky tests Signed-off-by: Jens Langhammer <[email protected]> --------- Signed-off-by: Jens Langhammer <[email protected]>
1 parent a365ec8 commit 240cf6d

File tree

95 files changed

+6561
-307
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

95 files changed

+6561
-307
lines changed

.dockerignore

+1
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ blueprints/local
99
.git
1010
!gen-ts-api/node_modules
1111
!gen-ts-api/dist/**
12+
!gen-go-api/

.github/codespell-words.txt

+1
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ keypair
22
keypairs
33
hass
44
warmup
5+
ontext

.github/workflows/ci-main.yml

+23-6
Original file line numberDiff line numberDiff line change
@@ -249,12 +249,6 @@ jobs:
249249
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
250250
cache-from: type=gha
251251
cache-to: type=gha,mode=max
252-
- name: Comment on PR
253-
if: github.event_name == 'pull_request'
254-
continue-on-error: true
255-
uses: ./.github/actions/comment-pr-instructions
256-
with:
257-
tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}
258252
build-arm64:
259253
needs: ci-core-mark
260254
runs-on: ubuntu-latest
@@ -303,3 +297,26 @@ jobs:
303297
platforms: linux/arm64
304298
cache-from: type=gha
305299
cache-to: type=gha,mode=max
300+
pr-comment:
301+
needs:
302+
- build
303+
- build-arm64
304+
runs-on: ubuntu-latest
305+
if: ${{ github.event_name == 'pull_request' }}
306+
permissions:
307+
# Needed to write comments on PRs
308+
pull-requests: write
309+
timeout-minutes: 120
310+
steps:
311+
- uses: actions/checkout@v4
312+
with:
313+
ref: ${{ github.event.pull_request.head.sha }}
314+
- name: prepare variables
315+
uses: ./.github/actions/docker-push-variables
316+
id: ev
317+
env:
318+
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
319+
- name: Comment on PR
320+
uses: ./.github/actions/comment-pr-instructions
321+
with:
322+
tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}

.github/workflows/ci-outpost.yml

+2
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ jobs:
6565
- proxy
6666
- ldap
6767
- radius
68+
- rac
6869
runs-on: ubuntu-latest
6970
permissions:
7071
# Needed to upload contianer images to ghcr.io
@@ -119,6 +120,7 @@ jobs:
119120
- proxy
120121
- ldap
121122
- radius
123+
- rac
122124
goos: [linux]
123125
goarch: [amd64, arm64]
124126
steps:

.github/workflows/release-publish.yml

+1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ jobs:
6565
- proxy
6666
- ldap
6767
- radius
68+
- rac
6869
steps:
6970
- uses: actions/checkout@v4
7071
- uses: actions/setup-go@v5

Makefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ test: ## Run the server tests and produce a coverage report (locally)
5858
lint-fix: ## Lint and automatically fix errors in the python source code. Reports spelling errors.
5959
isort $(PY_SOURCES)
6060
black $(PY_SOURCES)
61-
ruff $(PY_SOURCES)
61+
ruff --fix $(PY_SOURCES)
6262
codespell -w $(CODESPELL_ARGS)
6363

6464
lint: ## Lint the python and golang sources

authentik/core/channels.py

+14-7
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
11
"""Channels base classes"""
2+
from channels.db import database_sync_to_async
23
from channels.exceptions import DenyConnection
3-
from channels.generic.websocket import JsonWebsocketConsumer
44
from rest_framework.exceptions import AuthenticationFailed
55
from structlog.stdlib import get_logger
66

77
from authentik.api.authentication import bearer_auth
8-
from authentik.core.models import User
98

109
LOGGER = get_logger()
1110

1211

13-
class AuthJsonConsumer(JsonWebsocketConsumer):
12+
class TokenOutpostMiddleware:
1413
"""Authorize a client with a token"""
1514

16-
user: User
15+
def __init__(self, inner):
16+
self.inner = inner
1717

18-
def connect(self):
19-
headers = dict(self.scope["headers"])
18+
async def __call__(self, scope, receive, send):
19+
scope = dict(scope)
20+
await self.auth(scope)
21+
return await self.inner(scope, receive, send)
22+
23+
@database_sync_to_async
24+
def auth(self, scope):
25+
"""Authenticate request from header"""
26+
headers = dict(scope["headers"])
2027
if b"authorization" not in headers:
2128
LOGGER.warning("WS Request without authorization header")
2229
raise DenyConnection()
@@ -32,4 +39,4 @@ def connect(self):
3239
LOGGER.warning("Failed to authenticate", exc=exc)
3340
raise DenyConnection()
3441

35-
self.user = user
42+
scope["user"] = user

authentik/core/views/interface.py

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
2222
kwargs["version_family"] = f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}"
2323
kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}"
2424
kwargs["build"] = get_build_hash()
25+
kwargs["url_kwargs"] = self.kwargs
2526
return super().get_context_data(**kwargs)
2627

2728

authentik/enterprise/policy.py

+6-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Enterprise license policies"""
22
from typing import Optional
33

4+
from django.utils.translation import gettext_lazy as _
5+
46
from authentik.core.models import User, UserTypes
57
from authentik.enterprise.models import LicenseKey
68
from authentik.policies.types import PolicyRequest, PolicyResult
@@ -13,18 +15,18 @@ class EnterprisePolicyAccessView(PolicyAccessView):
1315
def check_license(self):
1416
"""Check license"""
1517
if not LicenseKey.get_total().is_valid():
16-
return False
18+
return PolicyResult(False, _("Enterprise required to access this feature."))
1719
if self.request.user.type != UserTypes.INTERNAL:
18-
return False
19-
return True
20+
return PolicyResult(False, _("Feature only accessible for internal users."))
21+
return PolicyResult(True)
2022

2123
def user_has_access(self, user: Optional[User] = None) -> PolicyResult:
2224
user = user or self.request.user
2325
request = PolicyRequest(user)
2426
request.http_request = self.request
2527
result = super().user_has_access(user)
2628
enterprise_result = self.check_license()
27-
if not enterprise_result:
29+
if not enterprise_result.passing:
2830
return enterprise_result
2931
return result
3032

authentik/enterprise/providers/__init__.py

Whitespace-only changes.

authentik/enterprise/providers/rac/__init__.py

Whitespace-only changes.

authentik/enterprise/providers/rac/api/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""RAC Provider API Views"""
2+
from typing import Optional
3+
4+
from django.core.cache import cache
5+
from django.db.models import QuerySet
6+
from django.urls import reverse
7+
from drf_spectacular.types import OpenApiTypes
8+
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
9+
from rest_framework.fields import SerializerMethodField
10+
from rest_framework.request import Request
11+
from rest_framework.response import Response
12+
from rest_framework.serializers import ModelSerializer
13+
from rest_framework.viewsets import ModelViewSet
14+
from structlog.stdlib import get_logger
15+
16+
from authentik.core.api.used_by import UsedByMixin
17+
from authentik.core.models import Provider
18+
from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer
19+
from authentik.enterprise.providers.rac.models import Endpoint
20+
from authentik.policies.engine import PolicyEngine
21+
from authentik.rbac.filters import ObjectFilter
22+
23+
LOGGER = get_logger()
24+
25+
26+
def user_endpoint_cache_key(user_pk: str) -> str:
27+
"""Cache key where endpoint list for user is saved"""
28+
return f"goauthentik.io/providers/rac/endpoint_access/{user_pk}"
29+
30+
31+
class EndpointSerializer(ModelSerializer):
32+
"""Endpoint Serializer"""
33+
34+
provider_obj = RACProviderSerializer(source="provider", read_only=True)
35+
launch_url = SerializerMethodField()
36+
37+
def get_launch_url(self, endpoint: Endpoint) -> Optional[str]:
38+
"""Build actual launch URL (the provider itself does not have one, just
39+
individual endpoints)"""
40+
try:
41+
# pylint: disable=no-member
42+
return reverse(
43+
"authentik_providers_rac:start",
44+
kwargs={"app": endpoint.provider.application.slug, "endpoint": endpoint.pk},
45+
)
46+
except Provider.application.RelatedObjectDoesNotExist:
47+
return None
48+
49+
class Meta:
50+
model = Endpoint
51+
fields = [
52+
"pk",
53+
"name",
54+
"provider",
55+
"provider_obj",
56+
"protocol",
57+
"host",
58+
"settings",
59+
"property_mappings",
60+
"auth_mode",
61+
"launch_url",
62+
]
63+
64+
65+
class EndpointViewSet(UsedByMixin, ModelViewSet):
66+
"""Endpoint Viewset"""
67+
68+
queryset = Endpoint.objects.all()
69+
serializer_class = EndpointSerializer
70+
filterset_fields = ["name", "provider"]
71+
search_fields = ["name", "protocol"]
72+
ordering = ["name", "protocol"]
73+
74+
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
75+
"""Custom filter_queryset method which ignores guardian, but still supports sorting"""
76+
for backend in list(self.filter_backends):
77+
if backend == ObjectFilter:
78+
continue
79+
queryset = backend().filter_queryset(self.request, queryset, self)
80+
return queryset
81+
82+
def _get_allowed_endpoints(self, queryset: QuerySet) -> list[Endpoint]:
83+
endpoints = []
84+
for endpoint in queryset:
85+
engine = PolicyEngine(endpoint, self.request.user, self.request)
86+
engine.build()
87+
if engine.passing:
88+
endpoints.append(endpoint)
89+
return endpoints
90+
91+
@extend_schema(
92+
parameters=[
93+
OpenApiParameter(
94+
"search",
95+
OpenApiTypes.STR,
96+
),
97+
OpenApiParameter(
98+
name="superuser_full_list",
99+
location=OpenApiParameter.QUERY,
100+
type=OpenApiTypes.BOOL,
101+
),
102+
],
103+
responses={
104+
200: EndpointSerializer(many=True),
105+
400: OpenApiResponse(description="Bad request"),
106+
},
107+
)
108+
def list(self, request: Request, *args, **kwargs) -> Response:
109+
"""List accessible endpoints"""
110+
should_cache = request.GET.get("search", "") == ""
111+
112+
superuser_full_list = str(request.GET.get("superuser_full_list", "false")).lower() == "true"
113+
if superuser_full_list and request.user.is_superuser:
114+
return super().list(request)
115+
116+
queryset = self._filter_queryset_for_list(self.get_queryset())
117+
self.paginate_queryset(queryset)
118+
119+
allowed_endpoints = []
120+
if not should_cache:
121+
allowed_endpoints = self._get_allowed_endpoints(queryset)
122+
if should_cache:
123+
allowed_endpoints = cache.get(user_endpoint_cache_key(self.request.user.pk))
124+
if not allowed_endpoints:
125+
LOGGER.debug("Caching allowed endpoint list")
126+
allowed_endpoints = self._get_allowed_endpoints(queryset)
127+
cache.set(
128+
user_endpoint_cache_key(self.request.user.pk),
129+
allowed_endpoints,
130+
timeout=86400,
131+
)
132+
serializer = self.get_serializer(allowed_endpoints, many=True)
133+
return self.get_paginated_response(serializer.data)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""RAC Provider API Views"""
2+
from rest_framework.fields import CharField
3+
from rest_framework.viewsets import ModelViewSet
4+
5+
from authentik.core.api.propertymappings import PropertyMappingSerializer
6+
from authentik.core.api.used_by import UsedByMixin
7+
from authentik.core.api.utils import JSONDictField
8+
from authentik.enterprise.providers.rac.models import RACPropertyMapping
9+
10+
11+
class RACPropertyMappingSerializer(PropertyMappingSerializer):
12+
"""RACPropertyMapping Serializer"""
13+
14+
static_settings = JSONDictField()
15+
expression = CharField(allow_blank=True, required=False)
16+
17+
def validate_expression(self, expression: str) -> str:
18+
"""Test Syntax"""
19+
if expression == "":
20+
return expression
21+
return super().validate_expression(expression)
22+
23+
class Meta:
24+
model = RACPropertyMapping
25+
fields = PropertyMappingSerializer.Meta.fields + ["static_settings"]
26+
27+
28+
class RACPropertyMappingViewSet(UsedByMixin, ModelViewSet):
29+
"""RACPropertyMapping Viewset"""
30+
31+
queryset = RACPropertyMapping.objects.all()
32+
serializer_class = RACPropertyMappingSerializer
33+
search_fields = ["name"]
34+
ordering = ["name"]
35+
filterset_fields = ["name", "managed"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""RAC Provider API Views"""
2+
from rest_framework.fields import CharField, ListField
3+
from rest_framework.viewsets import ModelViewSet
4+
5+
from authentik.core.api.providers import ProviderSerializer
6+
from authentik.core.api.used_by import UsedByMixin
7+
from authentik.enterprise.providers.rac.models import RACProvider
8+
9+
10+
class RACProviderSerializer(ProviderSerializer):
11+
"""RACProvider Serializer"""
12+
13+
outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all")
14+
15+
class Meta:
16+
model = RACProvider
17+
fields = ProviderSerializer.Meta.fields + ["settings", "outpost_set", "connection_expiry"]
18+
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
19+
20+
21+
class RACProviderViewSet(UsedByMixin, ModelViewSet):
22+
"""RACProvider Viewset"""
23+
24+
queryset = RACProvider.objects.all()
25+
serializer_class = RACProviderSerializer
26+
filterset_fields = {
27+
"application": ["isnull"],
28+
"name": ["iexact"],
29+
}
30+
search_fields = ["name"]
31+
ordering = ["name"]
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""RAC app config"""
2+
from authentik.blueprints.apps import ManagedAppConfig
3+
4+
5+
class AuthentikEnterpriseProviderRAC(ManagedAppConfig):
6+
"""authentik enterprise rac app config"""
7+
8+
name = "authentik.enterprise.providers.rac"
9+
label = "authentik_providers_rac"
10+
verbose_name = "authentik Enterprise.Providers.RAC"
11+
default = True
12+
mountpoint = ""
13+
ws_mountpoint = "authentik.enterprise.providers.rac.urls"
14+
15+
def reconcile_load_rac_signals(self):
16+
"""Load rac signals"""
17+
self.import_module("authentik.enterprise.providers.rac.signals")

0 commit comments

Comments
 (0)