enterprise/providers/rac: add option to limit concurrent connections to endpoint (#8053)

* enterprise/providers/rac: add option to limit concurrent connections to endpoint

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* unrelated: put outpost settings in group

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L 2024-01-04 16:27:16 +01:00 committed by GitHub
parent 519062bc39
commit 2064395434
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 106 additions and 29 deletions

View File

@ -60,6 +60,7 @@ class EndpointSerializer(EnterpriseRequiredMixin, ModelSerializer):
"property_mappings", "property_mappings",
"auth_mode", "auth_mode",
"launch_url", "launch_url",
"maximum_connections",
] ]

View File

@ -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),
),
]

View File

@ -81,6 +81,7 @@ class Endpoint(SerializerModel, PolicyBindingModel):
settings = models.JSONField(default=dict) settings = models.JSONField(default=dict)
auth_mode = models.TextField(choices=AuthenticationMode.choices) auth_mode = models.TextField(choices=AuthenticationMode.choices)
provider = models.ForeignKey("RACProvider", on_delete=models.CASCADE) provider = models.ForeignKey("RACProvider", on_delete=models.CASCADE)
maximum_connections = models.IntegerField(default=1)
property_mappings = models.ManyToManyField( property_mappings = models.ManyToManyField(
"authentik_core.PropertyMapping", default=None, blank=True "authentik_core.PropertyMapping", default=None, blank=True

View File

@ -81,6 +81,7 @@ class TestEndpointsAPI(APITestCase):
}, },
"protocol": "rdp", "protocol": "rdp",
"host": self.allowed.host, "host": self.allowed.host,
"maximum_connections": 1,
"settings": {}, "settings": {},
"property_mappings": [], "property_mappings": [],
"auth_mode": "", "auth_mode": "",
@ -131,6 +132,7 @@ class TestEndpointsAPI(APITestCase):
}, },
"protocol": "rdp", "protocol": "rdp",
"host": self.allowed.host, "host": self.allowed.host,
"maximum_connections": 1,
"settings": {}, "settings": {},
"property_mappings": [], "property_mappings": [],
"auth_mode": "", "auth_mode": "",
@ -158,6 +160,7 @@ class TestEndpointsAPI(APITestCase):
}, },
"protocol": "rdp", "protocol": "rdp",
"host": self.denied.host, "host": self.denied.host,
"maximum_connections": 1,
"settings": {}, "settings": {},
"property_mappings": [], "property_mappings": [],
"auth_mode": "", "auth_mode": "",

View File

@ -5,6 +5,7 @@ from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext as _
from authentik.core.models import Application, AuthenticatedSession from authentik.core.models import Application, AuthenticatedSession
from authentik.core.views.interface import InterfaceView from authentik.core.views.interface import InterfaceView
@ -79,35 +80,50 @@ class RACInterface(InterfaceView):
class RACFinalStage(RedirectStage): class RACFinalStage(RedirectStage):
"""RAC Connection final stage, set the connection token in the stage""" """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: def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
endpoint: Endpoint = self.executor.current_stage.endpoint self.endpoint = self.executor.current_stage.endpoint
engine = PolicyEngine(endpoint, self.request.user, self.request) 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.use_cache = False
engine.build() engine.build()
passing = engine.result passing = engine.result
if not passing.passing: if not passing.passing:
return self.executor.stage_invalid(", ".join(passing.messages)) 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) return super().dispatch(request, *args, **kwargs)
def get_challenge(self, *args, **kwargs) -> RedirectChallenge: 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( token = ConnectionToken.objects.create(
provider=provider, provider=self.provider,
endpoint=endpoint, endpoint=self.endpoint,
settings=self.executor.plan.context.get("connection_settings", {}), settings=self.executor.plan.context.get("connection_settings", {}),
session=AuthenticatedSession.objects.filter( session=AuthenticatedSession.objects.filter(
session_key=self.request.session.session_key session_key=self.request.session.session_key
).first(), ).first(),
expires=now() + timedelta_from_string(provider.connection_expiry), expires=now() + timedelta_from_string(self.provider.connection_expiry),
expiring=True, expiring=True,
) )
Event.new( Event.new(
EventAction.AUTHORIZE_APPLICATION, EventAction.AUTHORIZE_APPLICATION,
authorized_application=application, authorized_application=self.application,
flow=self.executor.plan.flow_pk, flow=self.executor.plan.flow_pk,
endpoint=endpoint.name, endpoint=self.endpoint.name,
).from_http(self.request) ).from_http(self.request)
setattr( setattr(
self.executor.current_stage, self.executor.current_stage,

View File

@ -8958,6 +8958,12 @@
"prompt" "prompt"
], ],
"title": "Auth mode" "title": "Auth mode"
},
"maximum_connections": {
"type": "integer",
"minimum": -2147483648,
"maximum": 2147483647,
"title": "Maximum connections"
} }
}, },
"required": [] "required": []

View File

@ -31381,6 +31381,10 @@ components:
Build actual launch URL (the provider itself does not have one, just Build actual launch URL (the provider itself does not have one, just
individual endpoints) individual endpoints)
readOnly: true readOnly: true
maximum_connections:
type: integer
maximum: 2147483647
minimum: -2147483648
required: required:
- auth_mode - auth_mode
- host - host
@ -31412,6 +31416,10 @@ components:
format: uuid format: uuid
auth_mode: auth_mode:
$ref: '#/components/schemas/AuthModeEnum' $ref: '#/components/schemas/AuthModeEnum'
maximum_connections:
type: integer
maximum: 2147483647
minimum: -2147483648
required: required:
- auth_mode - auth_mode
- host - host
@ -37298,6 +37306,10 @@ components:
format: uuid format: uuid
auth_mode: auth_mode:
$ref: '#/components/schemas/AuthModeEnum' $ref: '#/components/schemas/AuthModeEnum'
maximum_connections:
type: integer
maximum: 2147483647
minimum: -2147483648
PatchedEventMatcherPolicyRequest: PatchedEventMatcherPolicyRequest:
type: object type: object
description: Event Matcher Policy Serializer description: Event Matcher Policy Serializer

View File

@ -3,6 +3,7 @@ import { docLink } from "@goauthentik/common/global";
import { groupBy } from "@goauthentik/common/utils"; import { groupBy } from "@goauthentik/common/utils";
import "@goauthentik/elements/CodeMirror"; import "@goauthentik/elements/CodeMirror";
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import "@goauthentik/elements/forms/SearchSelect"; import "@goauthentik/elements/forms/SearchSelect";
@ -220,24 +221,27 @@ export class OutpostForm extends ModelForm<Outpost, string> {
${msg("Hold control/command to select multiple items.")} ${msg("Hold control/command to select multiple items.")}
</p> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Configuration")} name="config"> <ak-form-group aria-label="Advanced settings">
<ak-codemirror <span slot="header"> ${msg("Advanced settings")} </span>
mode=${CodeMirrorMode.YAML} <ak-form-element-horizontal label=${msg("Configuration")} name="config">
value="${YAML.stringify( <ak-codemirror
this.instance ? this.instance.config : this.defaultConfig?.config, mode=${CodeMirrorMode.YAML}
)}" value="${YAML.stringify(
></ak-codemirror> this.instance ? this.instance.config : this.defaultConfig?.config,
<p class="pf-c-form__helper-text"> )}"
${msg("Set custom attributes using YAML or JSON.")} ></ak-codemirror>
</p> <p class="pf-c-form__helper-text">
<p class="pf-c-form__helper-text"> ${msg("Set custom attributes using YAML or JSON.")}
${msg("See more here:")}&nbsp; </p>
<a <p class="pf-c-form__helper-text">
target="_blank" ${msg("See more here:")}&nbsp;
href="${docLink("/docs/outposts?utm_source=authentik#configuration")}" <a
>${msg("Documentation")}</a target="_blank"
> href="${docLink("/docs/outposts?utm_source=authentik#configuration")}"
</p> >${msg("Documentation")}</a
</ak-form-element-horizontal>`; >
</p>
</ak-form-element-horizontal>
</ak-form-group>`;
} }
} }

View File

@ -106,6 +106,23 @@ export class EndpointForm extends ModelForm<Endpoint, string> {
/> />
<p class="pf-c-form__helper-text">${msg("Hostname/IP to connect to.")}</p> <p class="pf-c-form__helper-text">${msg("Hostname/IP to connect to.")}</p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Maximum concurrent connections")}
name="maximumConnections"
?required=${true}
>
<input
type="number"
value="${first(this.instance?.maximumConnections, 1)}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg(
"Maximum concurrent allowed connections to this endpoint. Can be set to -1 to disable the limit.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal <ak-form-element-horizontal
label=${msg("Property mappings")} label=${msg("Property mappings")}
?required=${true} ?required=${true}