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:
parent
519062bc39
commit
2064395434
|
@ -60,6 +60,7 @@ class EndpointSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
||||||
"property_mappings",
|
"property_mappings",
|
||||||
"auth_mode",
|
"auth_mode",
|
||||||
"launch_url",
|
"launch_url",
|
||||||
|
"maximum_connections",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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
|
||||||
|
|
|
@ -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": "",
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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": []
|
||||||
|
|
12
schema.yml
12
schema.yml
|
@ -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
|
||||||
|
|
|
@ -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:")}
|
</p>
|
||||||
<a
|
<p class="pf-c-form__helper-text">
|
||||||
target="_blank"
|
${msg("See more here:")}
|
||||||
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>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
Reference in New Issue