diff --git a/authentik/enterprise/providers/rac/api/endpoints.py b/authentik/enterprise/providers/rac/api/endpoints.py
index e1c6c5dd8..1af281ef8 100644
--- a/authentik/enterprise/providers/rac/api/endpoints.py
+++ b/authentik/enterprise/providers/rac/api/endpoints.py
@@ -60,6 +60,7 @@ class EndpointSerializer(EnterpriseRequiredMixin, ModelSerializer):
"property_mappings",
"auth_mode",
"launch_url",
+ "maximum_connections",
]
diff --git a/authentik/enterprise/providers/rac/migrations/0002_endpoint_maximum_connections.py b/authentik/enterprise/providers/rac/migrations/0002_endpoint_maximum_connections.py
new file mode 100644
index 000000000..0760f2313
--- /dev/null
+++ b/authentik/enterprise/providers/rac/migrations/0002_endpoint_maximum_connections.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.0 on 2024-01-03 23:44
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("authentik_providers_rac", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="endpoint",
+ name="maximum_connections",
+ field=models.IntegerField(default=1),
+ ),
+ ]
diff --git a/authentik/enterprise/providers/rac/models.py b/authentik/enterprise/providers/rac/models.py
index f2806f32b..927bd23fe 100644
--- a/authentik/enterprise/providers/rac/models.py
+++ b/authentik/enterprise/providers/rac/models.py
@@ -81,6 +81,7 @@ class Endpoint(SerializerModel, PolicyBindingModel):
settings = models.JSONField(default=dict)
auth_mode = models.TextField(choices=AuthenticationMode.choices)
provider = models.ForeignKey("RACProvider", on_delete=models.CASCADE)
+ maximum_connections = models.IntegerField(default=1)
property_mappings = models.ManyToManyField(
"authentik_core.PropertyMapping", default=None, blank=True
diff --git a/authentik/enterprise/providers/rac/tests/test_endpoints_api.py b/authentik/enterprise/providers/rac/tests/test_endpoints_api.py
index 0a659bccd..3000b345c 100644
--- a/authentik/enterprise/providers/rac/tests/test_endpoints_api.py
+++ b/authentik/enterprise/providers/rac/tests/test_endpoints_api.py
@@ -81,6 +81,7 @@ class TestEndpointsAPI(APITestCase):
},
"protocol": "rdp",
"host": self.allowed.host,
+ "maximum_connections": 1,
"settings": {},
"property_mappings": [],
"auth_mode": "",
@@ -131,6 +132,7 @@ class TestEndpointsAPI(APITestCase):
},
"protocol": "rdp",
"host": self.allowed.host,
+ "maximum_connections": 1,
"settings": {},
"property_mappings": [],
"auth_mode": "",
@@ -158,6 +160,7 @@ class TestEndpointsAPI(APITestCase):
},
"protocol": "rdp",
"host": self.denied.host,
+ "maximum_connections": 1,
"settings": {},
"property_mappings": [],
"auth_mode": "",
diff --git a/authentik/enterprise/providers/rac/views.py b/authentik/enterprise/providers/rac/views.py
index e50f6ee5b..4b93aee76 100644
--- a/authentik/enterprise/providers/rac/views.py
+++ b/authentik/enterprise/providers/rac/views.py
@@ -5,6 +5,7 @@ from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.timezone import now
+from django.utils.translation import gettext as _
from authentik.core.models import Application, AuthenticatedSession
from authentik.core.views.interface import InterfaceView
@@ -79,35 +80,50 @@ class RACInterface(InterfaceView):
class RACFinalStage(RedirectStage):
"""RAC Connection final stage, set the connection token in the stage"""
+ endpoint: Endpoint
+ provider: RACProvider
+ application: Application
+
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
- endpoint: Endpoint = self.executor.current_stage.endpoint
- engine = PolicyEngine(endpoint, self.request.user, self.request)
+ self.endpoint = self.executor.current_stage.endpoint
+ self.provider = self.executor.current_stage.provider
+ self.application = self.executor.current_stage.application
+ # Check policies bound to endpoint directly
+ engine = PolicyEngine(self.endpoint, self.request.user, self.request)
engine.use_cache = False
engine.build()
passing = engine.result
if not passing.passing:
return self.executor.stage_invalid(", ".join(passing.messages))
+ # Check if we're already at the maximum connection limit
+ all_tokens = ConnectionToken.filter_not_expired(
+ endpoint=self.endpoint,
+ ).exclude(endpoint__maximum_connections__lte=-1)
+ if all_tokens.count() >= self.endpoint.maximum_connections:
+ msg = [_("Maximum connection limit reached.")]
+ # Check if any other tokens exist for the current user, and inform them
+ # they are already connected
+ if all_tokens.filter(session__user=self.request.user).exists():
+ msg.append(_("(You are already connected in another tab/window)"))
+ return self.executor.stage_invalid(" ".join(msg))
return super().dispatch(request, *args, **kwargs)
def get_challenge(self, *args, **kwargs) -> RedirectChallenge:
- endpoint: Endpoint = self.executor.current_stage.endpoint
- provider: RACProvider = self.executor.current_stage.provider
- application: Application = self.executor.current_stage.application
token = ConnectionToken.objects.create(
- provider=provider,
- endpoint=endpoint,
+ provider=self.provider,
+ endpoint=self.endpoint,
settings=self.executor.plan.context.get("connection_settings", {}),
session=AuthenticatedSession.objects.filter(
session_key=self.request.session.session_key
).first(),
- expires=now() + timedelta_from_string(provider.connection_expiry),
+ expires=now() + timedelta_from_string(self.provider.connection_expiry),
expiring=True,
)
Event.new(
EventAction.AUTHORIZE_APPLICATION,
- authorized_application=application,
+ authorized_application=self.application,
flow=self.executor.plan.flow_pk,
- endpoint=endpoint.name,
+ endpoint=self.endpoint.name,
).from_http(self.request)
setattr(
self.executor.current_stage,
diff --git a/blueprints/schema.json b/blueprints/schema.json
index 07d9cd227..213cb1673 100644
--- a/blueprints/schema.json
+++ b/blueprints/schema.json
@@ -8958,6 +8958,12 @@
"prompt"
],
"title": "Auth mode"
+ },
+ "maximum_connections": {
+ "type": "integer",
+ "minimum": -2147483648,
+ "maximum": 2147483647,
+ "title": "Maximum connections"
}
},
"required": []
diff --git a/schema.yml b/schema.yml
index 09b351707..ed6a46fed 100644
--- a/schema.yml
+++ b/schema.yml
@@ -31381,6 +31381,10 @@ components:
Build actual launch URL (the provider itself does not have one, just
individual endpoints)
readOnly: true
+ maximum_connections:
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
required:
- auth_mode
- host
@@ -31412,6 +31416,10 @@ components:
format: uuid
auth_mode:
$ref: '#/components/schemas/AuthModeEnum'
+ maximum_connections:
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
required:
- auth_mode
- host
@@ -37298,6 +37306,10 @@ components:
format: uuid
auth_mode:
$ref: '#/components/schemas/AuthModeEnum'
+ maximum_connections:
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
PatchedEventMatcherPolicyRequest:
type: object
description: Event Matcher Policy Serializer
diff --git a/web/src/admin/outposts/OutpostForm.ts b/web/src/admin/outposts/OutpostForm.ts
index 2c5ac9722..7c6c9dda5 100644
--- a/web/src/admin/outposts/OutpostForm.ts
+++ b/web/src/admin/outposts/OutpostForm.ts
@@ -3,6 +3,7 @@ import { docLink } from "@goauthentik/common/global";
import { groupBy } from "@goauthentik/common/utils";
import "@goauthentik/elements/CodeMirror";
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
+import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import "@goauthentik/elements/forms/SearchSelect";
@@ -220,24 +221,27 @@ export class OutpostForm extends ModelForm
- ${msg("Set custom attributes using YAML or JSON.")} -
-- ${msg("See more here:")} - ${msg("Documentation")} -
-+ ${msg("Set custom attributes using YAML or JSON.")} +
++ ${msg("See more here:")} + ${msg("Documentation")} +
+${msg("Hostname/IP to connect to.")}
++ ${msg( + "Maximum concurrent allowed connections to this endpoint. Can be set to -1 to disable the limit.", + )} +
+