mirror of
https://github.com/goauthentik/authentik
synced 2026-05-12 18:06:21 +02:00
Compare commits
12 Commits
version/20
...
version/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8b5e4c127 | ||
|
|
81ec98b198 | ||
|
|
c46ab19e79 | ||
|
|
de9fc5de6b | ||
|
|
eab3d9b411 | ||
|
|
7cb40d786f | ||
|
|
b4fce08bbc | ||
|
|
8a2ba1c518 | ||
|
|
25b4306693 | ||
|
|
1e279950f1 | ||
|
|
960429355f | ||
|
|
b4f3748353 |
@@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2024.8.3
|
||||
current_version = 2024.8.4
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from os import environ
|
||||
|
||||
__version__ = "2024.8.3"
|
||||
__version__ = "2024.8.4"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@@ -51,9 +51,11 @@ class BlueprintInstanceSerializer(ModelSerializer):
|
||||
context = self.instance.context if self.instance else {}
|
||||
valid, logs = Importer.from_string(content, context).validate()
|
||||
if not valid:
|
||||
text_logs = "\n".join([x["event"] for x in logs])
|
||||
raise ValidationError(
|
||||
_("Failed to validate blueprint: {logs}".format_map({"logs": text_logs}))
|
||||
[
|
||||
_("Failed to validate blueprint"),
|
||||
*[f"- {x.event}" for x in logs],
|
||||
]
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
@@ -78,5 +78,5 @@ class TestBlueprintsV1API(APITestCase):
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
res.content.decode(),
|
||||
{"content": ["Failed to validate blueprint: Invalid blueprint version"]},
|
||||
{"content": ["Failed to validate blueprint", "- Invalid blueprint version"]},
|
||||
)
|
||||
|
||||
@@ -429,7 +429,7 @@ class Importer:
|
||||
orig_import = deepcopy(self._import)
|
||||
if self._import.version != 1:
|
||||
self.logger.warning("Invalid blueprint version")
|
||||
return False, [{"event": "Invalid blueprint version"}]
|
||||
return False, [LogEvent("Invalid blueprint version", log_level="warning", logger=None)]
|
||||
with (
|
||||
transaction_rollback(),
|
||||
capture_logs() as logs,
|
||||
|
||||
@@ -679,7 +679,10 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
LOGGER.debug("User attempted to impersonate", user=request.user)
|
||||
return Response(status=401)
|
||||
user_to_be = self.get_object()
|
||||
if not request.user.has_perm("impersonate", user_to_be):
|
||||
# Check both object-level perms and global perms
|
||||
if not request.user.has_perm(
|
||||
"authentik_core.impersonate", user_to_be
|
||||
) and not request.user.has_perm("authentik_core.impersonate"):
|
||||
LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
|
||||
return Response(status=401)
|
||||
if user_to_be.pk == self.request.user.pk:
|
||||
|
||||
@@ -44,6 +44,26 @@ class TestImpersonation(APITestCase):
|
||||
self.assertEqual(response_body["user"]["username"], self.user.username)
|
||||
self.assertNotIn("original", response_body)
|
||||
|
||||
def test_impersonate_global(self):
|
||||
"""Test impersonation with global permissions"""
|
||||
new_user = create_test_user()
|
||||
assign_perm("authentik_core.impersonate", new_user)
|
||||
assign_perm("authentik_core.view_user", new_user)
|
||||
self.client.force_login(new_user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:user-impersonate",
|
||||
kwargs={"pk": self.other_user.pk},
|
||||
)
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
response_body = loads(response.content.decode())
|
||||
self.assertEqual(response_body["user"]["username"], self.other_user.username)
|
||||
self.assertEqual(response_body["original"]["username"], new_user.username)
|
||||
|
||||
def test_impersonate_scoped(self):
|
||||
"""Test impersonation with scoped permissions"""
|
||||
new_user = create_test_user()
|
||||
|
||||
@@ -21,7 +21,14 @@ class DebugSession(Session):
|
||||
|
||||
def send(self, req: PreparedRequest, *args, **kwargs):
|
||||
request_id = str(uuid4())
|
||||
LOGGER.debug("HTTP request sent", uid=request_id, path=req.path_url, headers=req.headers)
|
||||
LOGGER.debug(
|
||||
"HTTP request sent",
|
||||
uid=request_id,
|
||||
url=req.url,
|
||||
method=req.method,
|
||||
headers=req.headers,
|
||||
body=req.body,
|
||||
)
|
||||
resp = super().send(req, *args, **kwargs)
|
||||
LOGGER.debug(
|
||||
"HTTP response received",
|
||||
|
||||
@@ -108,7 +108,7 @@ class EventMatcherPolicy(Policy):
|
||||
result=result,
|
||||
)
|
||||
matches.append(result)
|
||||
passing = any(x.passing for x in matches)
|
||||
passing = all(x.passing for x in matches)
|
||||
messages = chain(*[x.messages for x in matches])
|
||||
result = PolicyResult(passing, *messages)
|
||||
result.source_results = matches
|
||||
|
||||
@@ -77,11 +77,24 @@ class TestEventMatcherPolicy(TestCase):
|
||||
request = PolicyRequest(get_anonymous_user())
|
||||
request.context["event"] = event
|
||||
policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(
|
||||
client_ip="1.2.3.5", app="bar"
|
||||
client_ip="1.2.3.5", app="foo"
|
||||
)
|
||||
response = policy.passes(request)
|
||||
self.assertFalse(response.passing)
|
||||
|
||||
def test_multiple(self):
|
||||
"""Test multiple"""
|
||||
event = Event.new(EventAction.LOGIN)
|
||||
event.app = "foo"
|
||||
event.client_ip = "1.2.3.4"
|
||||
request = PolicyRequest(get_anonymous_user())
|
||||
request.context["event"] = event
|
||||
policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(
|
||||
client_ip="1.2.3.4", app="foo"
|
||||
)
|
||||
response = policy.passes(request)
|
||||
self.assertTrue(response.passing)
|
||||
|
||||
def test_invalid(self):
|
||||
"""Test passing event"""
|
||||
request = PolicyRequest(get_anonymous_user())
|
||||
|
||||
@@ -439,15 +439,14 @@ class TokenParams:
|
||||
# (22 chars being the length of the "template")
|
||||
username=f"ak-{self.provider.name[:150-22]}-client_credentials",
|
||||
defaults={
|
||||
"attributes": {
|
||||
USER_ATTRIBUTE_GENERATED: True,
|
||||
},
|
||||
"last_login": timezone.now(),
|
||||
"name": f"Autogenerated user from application {app.name} (client credentials)",
|
||||
"path": f"{USER_PATH_SYSTEM_PREFIX}/apps/{app.slug}",
|
||||
"type": UserTypes.SERVICE_ACCOUNT,
|
||||
},
|
||||
)
|
||||
self.user.attributes[USER_ATTRIBUTE_GENERATED] = True
|
||||
self.user.save()
|
||||
self.__check_policy_access(app, request)
|
||||
|
||||
Event.new(
|
||||
@@ -471,9 +470,6 @@ class TokenParams:
|
||||
self.user, created = User.objects.update_or_create(
|
||||
username=f"{self.provider.name}-{token.get('sub')}",
|
||||
defaults={
|
||||
"attributes": {
|
||||
USER_ATTRIBUTE_GENERATED: True,
|
||||
},
|
||||
"last_login": timezone.now(),
|
||||
"name": (
|
||||
f"Autogenerated user from application {app.name} (client credentials JWT)"
|
||||
@@ -482,6 +478,8 @@ class TokenParams:
|
||||
"type": UserTypes.SERVICE_ACCOUNT,
|
||||
},
|
||||
)
|
||||
self.user.attributes[USER_ATTRIBUTE_GENERATED] = True
|
||||
self.user.save()
|
||||
exp = token.get("exp")
|
||||
if created and exp:
|
||||
self.user.attributes[USER_ATTRIBUTE_EXPIRES] = exp
|
||||
|
||||
@@ -50,6 +50,7 @@ class AssertionProcessor:
|
||||
|
||||
_issue_instant: str
|
||||
_assertion_id: str
|
||||
_response_id: str
|
||||
|
||||
_valid_not_before: str
|
||||
_session_not_on_or_after: str
|
||||
@@ -62,6 +63,7 @@ class AssertionProcessor:
|
||||
|
||||
self._issue_instant = get_time_string()
|
||||
self._assertion_id = get_random_id()
|
||||
self._response_id = get_random_id()
|
||||
|
||||
self._valid_not_before = get_time_string(
|
||||
timedelta_from_string(self.provider.assertion_valid_not_before)
|
||||
@@ -130,7 +132,9 @@ class AssertionProcessor:
|
||||
"""Generate AuthnStatement with AuthnContext and ContextClassRef Elements."""
|
||||
auth_n_statement = Element(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
|
||||
auth_n_statement.attrib["AuthnInstant"] = self._valid_not_before
|
||||
auth_n_statement.attrib["SessionIndex"] = self._assertion_id
|
||||
auth_n_statement.attrib["SessionIndex"] = sha256(
|
||||
self.http_request.session.session_key.encode("ascii")
|
||||
).hexdigest()
|
||||
auth_n_statement.attrib["SessionNotOnOrAfter"] = self._session_not_on_or_after
|
||||
|
||||
auth_n_context = SubElement(auth_n_statement, f"{{{NS_SAML_ASSERTION}}}AuthnContext")
|
||||
@@ -285,7 +289,7 @@ class AssertionProcessor:
|
||||
response.attrib["Version"] = "2.0"
|
||||
response.attrib["IssueInstant"] = self._issue_instant
|
||||
response.attrib["Destination"] = self.provider.acs_url
|
||||
response.attrib["ID"] = get_random_id()
|
||||
response.attrib["ID"] = self._response_id
|
||||
if self.auth_n_request.id:
|
||||
response.attrib["InResponseTo"] = self.auth_n_request.id
|
||||
|
||||
@@ -308,7 +312,7 @@ class AssertionProcessor:
|
||||
ref = xmlsec.template.add_reference(
|
||||
signature_node,
|
||||
digest_algorithm_transform,
|
||||
uri="#" + self._assertion_id,
|
||||
uri="#" + element.attrib["ID"],
|
||||
)
|
||||
xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped)
|
||||
xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N)
|
||||
|
||||
@@ -180,6 +180,10 @@ class TestAuthNRequest(TestCase):
|
||||
# Now create a response and convert it to string (provider)
|
||||
response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
|
||||
response = response_proc.build_response()
|
||||
# Ensure both response and assertion ID are in the response twice (once as ID attribute,
|
||||
# once as ds:Reference URI)
|
||||
self.assertEqual(response.count(response_proc._assertion_id), 2)
|
||||
self.assertEqual(response.count(response_proc._response_id), 2)
|
||||
|
||||
# Now parse the response (source)
|
||||
http_request.POST = QueryDict(mutable=True)
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
from itertools import batched
|
||||
|
||||
from django.db import transaction
|
||||
from pydantic import ValidationError
|
||||
from pydanticscim.group import GroupMember
|
||||
from pydanticscim.responses import PatchOp, PatchOperation
|
||||
from pydanticscim.responses import PatchOp
|
||||
|
||||
from authentik.core.models import Group
|
||||
from authentik.lib.sync.mapper import PropertyMappingManager
|
||||
@@ -19,7 +20,7 @@ from authentik.providers.scim.clients.base import SCIMClient
|
||||
from authentik.providers.scim.clients.exceptions import (
|
||||
SCIMRequestException,
|
||||
)
|
||||
from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchRequest
|
||||
from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOperation, PatchRequest
|
||||
from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema
|
||||
from authentik.providers.scim.models import (
|
||||
SCIMMapping,
|
||||
@@ -104,13 +105,47 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
provider=self.provider, group=group, scim_id=scim_id
|
||||
)
|
||||
users = list(group.users.order_by("id").values_list("id", flat=True))
|
||||
self._patch_add_users(group, users)
|
||||
self._patch_add_users(connection, users)
|
||||
return connection
|
||||
|
||||
def update(self, group: Group, connection: SCIMProviderGroup):
|
||||
"""Update existing group"""
|
||||
scim_group = self.to_schema(group, connection)
|
||||
scim_group.id = connection.scim_id
|
||||
try:
|
||||
if self._config.patch.supported:
|
||||
return self._update_patch(group, scim_group, connection)
|
||||
return self._update_put(group, scim_group, connection)
|
||||
except NotFoundSyncException:
|
||||
# Resource missing is handled by self.write, which will re-create the group
|
||||
raise
|
||||
|
||||
def _update_patch(
|
||||
self, group: Group, scim_group: SCIMGroupSchema, connection: SCIMProviderGroup
|
||||
):
|
||||
"""Update a group via PATCH request"""
|
||||
# Patch group's attributes instead of replacing it and re-adding users if we can
|
||||
self._request(
|
||||
"PATCH",
|
||||
f"/Groups/{connection.scim_id}",
|
||||
json=PatchRequest(
|
||||
Operations=[
|
||||
PatchOperation(
|
||||
op=PatchOp.replace,
|
||||
path=None,
|
||||
value=scim_group.model_dump(mode="json", exclude_unset=True),
|
||||
)
|
||||
]
|
||||
).model_dump(
|
||||
mode="json",
|
||||
exclude_unset=True,
|
||||
exclude_none=True,
|
||||
),
|
||||
)
|
||||
return self.patch_compare_users(group)
|
||||
|
||||
def _update_put(self, group: Group, scim_group: SCIMGroupSchema, connection: SCIMProviderGroup):
|
||||
"""Update a group via PUT request"""
|
||||
try:
|
||||
self._request(
|
||||
"PUT",
|
||||
@@ -120,33 +155,25 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
exclude_unset=True,
|
||||
),
|
||||
)
|
||||
users = list(group.users.order_by("id").values_list("id", flat=True))
|
||||
return self._patch_add_users(group, users)
|
||||
except NotFoundSyncException:
|
||||
# Resource missing is handled by self.write, which will re-create the group
|
||||
raise
|
||||
return self.patch_compare_users(group)
|
||||
except (SCIMRequestException, ObjectExistsSyncException):
|
||||
# Some providers don't support PUT on groups, so this is mainly a fix for the initial
|
||||
# sync, send patch add requests for all the users the group currently has
|
||||
users = list(group.users.order_by("id").values_list("id", flat=True))
|
||||
self._patch_add_users(group, users)
|
||||
# Also update the group name
|
||||
return self._patch(
|
||||
scim_group.id,
|
||||
PatchOperation(
|
||||
op=PatchOp.replace,
|
||||
path="displayName",
|
||||
value=scim_group.displayName,
|
||||
),
|
||||
)
|
||||
return self._update_patch(group, scim_group, connection)
|
||||
|
||||
def update_group(self, group: Group, action: Direction, users_set: set[int]):
|
||||
"""Update a group, either using PUT to replace it or PATCH if supported"""
|
||||
scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first()
|
||||
if not scim_group:
|
||||
self.logger.warning(
|
||||
"could not sync group membership, group does not exist", group=group
|
||||
)
|
||||
return
|
||||
if self._config.patch.supported:
|
||||
if action == Direction.add:
|
||||
return self._patch_add_users(group, users_set)
|
||||
return self._patch_add_users(scim_group, users_set)
|
||||
if action == Direction.remove:
|
||||
return self._patch_remove_users(group, users_set)
|
||||
return self._patch_remove_users(scim_group, users_set)
|
||||
try:
|
||||
return self.write(group)
|
||||
except SCIMRequestException as exc:
|
||||
@@ -154,19 +181,24 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
# Assume that provider does not support PUT and also doesn't support
|
||||
# ServiceProviderConfig, so try PATCH as a fallback
|
||||
if action == Direction.add:
|
||||
return self._patch_add_users(group, users_set)
|
||||
return self._patch_add_users(scim_group, users_set)
|
||||
if action == Direction.remove:
|
||||
return self._patch_remove_users(group, users_set)
|
||||
return self._patch_remove_users(scim_group, users_set)
|
||||
raise exc
|
||||
|
||||
def _patch(
|
||||
def _patch_chunked(
|
||||
self,
|
||||
group_id: str,
|
||||
*ops: PatchOperation,
|
||||
):
|
||||
"""Helper function that chunks patch requests based on the maxOperations attribute.
|
||||
This is not strictly according to specs but there's nothing in the schema that allows the
|
||||
us to know what the maximum patch operations per request should be."""
|
||||
chunk_size = self._config.bulk.maxOperations
|
||||
if chunk_size < 1:
|
||||
chunk_size = len(ops)
|
||||
if len(ops) < 1:
|
||||
return
|
||||
for chunk in batched(ops, chunk_size):
|
||||
req = PatchRequest(Operations=list(chunk))
|
||||
self._request(
|
||||
@@ -177,16 +209,70 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
),
|
||||
)
|
||||
|
||||
def _patch_add_users(self, group: Group, users_set: set[int]):
|
||||
"""Add users in users_set to group"""
|
||||
if len(users_set) < 1:
|
||||
return
|
||||
@transaction.atomic
|
||||
def patch_compare_users(self, group: Group):
|
||||
"""Compare users with a SCIM group and add/remove any differences"""
|
||||
# Get scim group first
|
||||
scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first()
|
||||
if not scim_group:
|
||||
self.logger.warning(
|
||||
"could not sync group membership, group does not exist", group=group
|
||||
)
|
||||
return
|
||||
# Get a list of all users in the authentik group
|
||||
raw_users_should = list(group.users.order_by("id").values_list("id", flat=True))
|
||||
# Lookup the SCIM IDs of the users
|
||||
users_should: list[str] = list(
|
||||
SCIMProviderUser.objects.filter(
|
||||
user__pk__in=raw_users_should, provider=self.provider
|
||||
).values_list("scim_id", flat=True)
|
||||
)
|
||||
if len(raw_users_should) != len(users_should):
|
||||
self.logger.warning(
|
||||
"User count mismatch, not all users in the group are synced to SCIM yet.",
|
||||
group=group,
|
||||
)
|
||||
# Get current group status
|
||||
current_group = SCIMGroupSchema.model_validate(
|
||||
self._request("GET", f"/Groups/{scim_group.scim_id}")
|
||||
)
|
||||
users_to_add = []
|
||||
users_to_remove = []
|
||||
# Check users currently in group and if they shouldn't be in the group and remove them
|
||||
for user in current_group.members or []:
|
||||
if user.value not in users_should:
|
||||
users_to_remove.append(user.value)
|
||||
# Check users that should be in the group and add them
|
||||
for user in users_should:
|
||||
if len([x for x in current_group.members if x.value == user]) < 1:
|
||||
users_to_add.append(user)
|
||||
# Only send request if we need to make changes
|
||||
if len(users_to_add) < 1 and len(users_to_remove) < 1:
|
||||
return
|
||||
return self._patch_chunked(
|
||||
scim_group.scim_id,
|
||||
*[
|
||||
PatchOperation(
|
||||
op=PatchOp.add,
|
||||
path="members",
|
||||
value=[{"value": x}],
|
||||
)
|
||||
for x in users_to_add
|
||||
],
|
||||
*[
|
||||
PatchOperation(
|
||||
op=PatchOp.remove,
|
||||
path="members",
|
||||
value=[{"value": x}],
|
||||
)
|
||||
for x in users_to_remove
|
||||
],
|
||||
)
|
||||
|
||||
def _patch_add_users(self, scim_group: SCIMProviderGroup, users_set: set[int]):
|
||||
"""Add users in users_set to group"""
|
||||
if len(users_set) < 1:
|
||||
return
|
||||
user_ids = list(
|
||||
SCIMProviderUser.objects.filter(
|
||||
user__pk__in=users_set, provider=self.provider
|
||||
@@ -194,7 +280,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
)
|
||||
if len(user_ids) < 1:
|
||||
return
|
||||
self._patch(
|
||||
self._patch_chunked(
|
||||
scim_group.scim_id,
|
||||
*[
|
||||
PatchOperation(
|
||||
@@ -206,16 +292,10 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
],
|
||||
)
|
||||
|
||||
def _patch_remove_users(self, group: Group, users_set: set[int]):
|
||||
def _patch_remove_users(self, scim_group: SCIMProviderGroup, users_set: set[int]):
|
||||
"""Remove users in users_set from group"""
|
||||
if len(users_set) < 1:
|
||||
return
|
||||
scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first()
|
||||
if not scim_group:
|
||||
self.logger.warning(
|
||||
"could not sync group membership, group does not exist", group=group
|
||||
)
|
||||
return
|
||||
user_ids = list(
|
||||
SCIMProviderUser.objects.filter(
|
||||
user__pk__in=users_set, provider=self.provider
|
||||
@@ -223,7 +303,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
)
|
||||
if len(user_ids) < 1:
|
||||
return
|
||||
self._patch(
|
||||
self._patch_chunked(
|
||||
scim_group.scim_id,
|
||||
*[
|
||||
PatchOperation(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from pydantic import Field
|
||||
from pydanticscim.group import Group as BaseGroup
|
||||
from pydanticscim.responses import PatchOperation as BasePatchOperation
|
||||
from pydanticscim.responses import PatchRequest as BasePatchRequest
|
||||
from pydanticscim.responses import SCIMError as BaseSCIMError
|
||||
from pydanticscim.service_provider import Bulk as BaseBulk
|
||||
@@ -68,6 +69,12 @@ class PatchRequest(BasePatchRequest):
|
||||
schemas: tuple[str] = ("urn:ietf:params:scim:api:messages:2.0:PatchOp",)
|
||||
|
||||
|
||||
class PatchOperation(BasePatchOperation):
|
||||
"""PatchOperation with optional path"""
|
||||
|
||||
path: str | None
|
||||
|
||||
|
||||
class SCIMError(BaseSCIMError):
|
||||
"""SCIM error with optional status code"""
|
||||
|
||||
|
||||
@@ -252,3 +252,118 @@ class SCIMMembershipTests(TestCase):
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
def test_member_add_save(self):
|
||||
"""Test member add + save"""
|
||||
config = ServiceProviderConfiguration.default()
|
||||
|
||||
config.patch.supported = True
|
||||
user_scim_id = generate_id()
|
||||
group_scim_id = generate_id()
|
||||
uid = generate_id()
|
||||
group = Group.objects.create(
|
||||
name=uid,
|
||||
)
|
||||
|
||||
user = User.objects.create(username=generate_id())
|
||||
|
||||
# Test initial sync of group creation
|
||||
with Mocker() as mocker:
|
||||
mocker.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json=config.model_dump(),
|
||||
)
|
||||
mocker.post(
|
||||
"https://localhost/Users",
|
||||
json={
|
||||
"id": user_scim_id,
|
||||
},
|
||||
)
|
||||
mocker.post(
|
||||
"https://localhost/Groups",
|
||||
json={
|
||||
"id": group_scim_id,
|
||||
},
|
||||
)
|
||||
|
||||
self.configure()
|
||||
sync_tasks.trigger_single_task(self.provider, scim_sync).get()
|
||||
|
||||
self.assertEqual(mocker.call_count, 6)
|
||||
self.assertEqual(mocker.request_history[0].method, "GET")
|
||||
self.assertEqual(mocker.request_history[1].method, "GET")
|
||||
self.assertEqual(mocker.request_history[2].method, "GET")
|
||||
self.assertEqual(mocker.request_history[3].method, "POST")
|
||||
self.assertEqual(mocker.request_history[4].method, "GET")
|
||||
self.assertEqual(mocker.request_history[5].method, "POST")
|
||||
self.assertJSONEqual(
|
||||
mocker.request_history[3].body,
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"emails": [],
|
||||
"active": True,
|
||||
"externalId": user.uid,
|
||||
"name": {"familyName": " ", "formatted": " ", "givenName": ""},
|
||||
"displayName": "",
|
||||
"userName": user.username,
|
||||
},
|
||||
)
|
||||
self.assertJSONEqual(
|
||||
mocker.request_history[5].body,
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
"externalId": str(group.pk),
|
||||
"displayName": group.name,
|
||||
},
|
||||
)
|
||||
|
||||
with Mocker() as mocker:
|
||||
mocker.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json=config.model_dump(),
|
||||
)
|
||||
mocker.get(
|
||||
f"https://localhost/Groups/{group_scim_id}",
|
||||
json={},
|
||||
)
|
||||
mocker.patch(
|
||||
f"https://localhost/Groups/{group_scim_id}",
|
||||
json={},
|
||||
)
|
||||
group.users.add(user)
|
||||
group.save()
|
||||
self.assertEqual(mocker.call_count, 5)
|
||||
self.assertEqual(mocker.request_history[0].method, "GET")
|
||||
self.assertEqual(mocker.request_history[1].method, "PATCH")
|
||||
self.assertEqual(mocker.request_history[2].method, "GET")
|
||||
self.assertEqual(mocker.request_history[3].method, "PATCH")
|
||||
self.assertEqual(mocker.request_history[4].method, "GET")
|
||||
self.assertJSONEqual(
|
||||
mocker.request_history[1].body,
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{
|
||||
"op": "add",
|
||||
"path": "members",
|
||||
"value": [{"value": user_scim_id}],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertJSONEqual(
|
||||
mocker.request_history[3].body,
|
||||
{
|
||||
"Operations": [
|
||||
{
|
||||
"op": "replace",
|
||||
"value": {
|
||||
"id": group_scim_id,
|
||||
"displayName": group.name,
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
"externalId": str(group.pk),
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2024.8.3 Blueprint schema",
|
||||
"title": "authentik 2024.8.4 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
volumes:
|
||||
- redis:/data
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.3}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.4}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
@@ -52,7 +52,7 @@ services:
|
||||
- postgresql
|
||||
- redis
|
||||
worker:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.3}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.4}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
|
||||
@@ -29,4 +29,4 @@ func UserAgent() string {
|
||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2024.8.3"
|
||||
const VERSION = "2024.8.4"
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
@@ -70,12 +71,20 @@ func NewProxyServer(ac *ak.APIController) *ProxyServer {
|
||||
}
|
||||
|
||||
func (ps *ProxyServer) HandleHost(rw http.ResponseWriter, r *http.Request) bool {
|
||||
// Always handle requests for outpost paths that should answer regardless of hostname
|
||||
if strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io/ping") ||
|
||||
strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io/static") {
|
||||
ps.mux.ServeHTTP(rw, r)
|
||||
return true
|
||||
}
|
||||
// lookup app by hostname
|
||||
a, _ := ps.lookupApp(r)
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
// check if the app should handle this URL, or is setup in proxy mode
|
||||
if a.ShouldHandleURL(r) || a.Mode() == api.PROXYMODE_PROXY {
|
||||
a.ServeHTTP(rw, r)
|
||||
ps.mux.ServeHTTP(rw, r)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2024.8.3",
|
||||
"version": "2024.8.4",
|
||||
"private": true
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "authentik"
|
||||
version = "2024.8.3"
|
||||
version = "2024.8.4"
|
||||
description = ""
|
||||
authors = ["authentik Team <hello@goauthentik.io>"]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2024.8.3
|
||||
version: 2024.8.4
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
|
||||
@@ -8,7 +8,12 @@ import { msg } from "@lit/localize";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import { GoogleWorkspaceProviderGroup, ProvidersApi, SyncObjectModelEnum } from "@goauthentik/api";
|
||||
import {
|
||||
GoogleWorkspaceProviderGroup,
|
||||
ProvidersApi,
|
||||
ProvidersGoogleWorkspaceSyncObjectCreateRequest,
|
||||
SyncObjectModelEnum,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-provider-google-workspace-groups-list")
|
||||
export class GoogleWorkspaceProviderGroupList extends Table<GoogleWorkspaceProviderGroup> {
|
||||
@@ -31,8 +36,11 @@ export class GoogleWorkspaceProviderGroupList extends Table<GoogleWorkspaceProvi
|
||||
<ak-sync-object-form
|
||||
.provider=${this.providerId}
|
||||
model=${SyncObjectModelEnum.Group}
|
||||
.sync=${new ProvidersApi(DEFAULT_CONFIG)
|
||||
.providersGoogleWorkspaceSyncObjectCreate}
|
||||
.sync=${(data: ProvidersGoogleWorkspaceSyncObjectCreateRequest) => {
|
||||
return new ProvidersApi(
|
||||
DEFAULT_CONFIG,
|
||||
).providersGoogleWorkspaceSyncObjectCreate(data);
|
||||
}}
|
||||
slot="form"
|
||||
>
|
||||
</ak-sync-object-form>
|
||||
|
||||
@@ -8,7 +8,12 @@ import { msg } from "@lit/localize";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import { GoogleWorkspaceProviderUser, ProvidersApi, SyncObjectModelEnum } from "@goauthentik/api";
|
||||
import {
|
||||
GoogleWorkspaceProviderUser,
|
||||
ProvidersApi,
|
||||
ProvidersGoogleWorkspaceSyncObjectCreateRequest,
|
||||
SyncObjectModelEnum,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-provider-google-workspace-users-list")
|
||||
export class GoogleWorkspaceProviderUserList extends Table<GoogleWorkspaceProviderUser> {
|
||||
@@ -31,8 +36,11 @@ export class GoogleWorkspaceProviderUserList extends Table<GoogleWorkspaceProvid
|
||||
<ak-sync-object-form
|
||||
.provider=${this.providerId}
|
||||
model=${SyncObjectModelEnum.User}
|
||||
.sync=${new ProvidersApi(DEFAULT_CONFIG)
|
||||
.providersGoogleWorkspaceSyncObjectCreate}
|
||||
.sync=${(data: ProvidersGoogleWorkspaceSyncObjectCreateRequest) => {
|
||||
return new ProvidersApi(
|
||||
DEFAULT_CONFIG,
|
||||
).providersGoogleWorkspaceSyncObjectCreate(data);
|
||||
}}
|
||||
slot="form"
|
||||
>
|
||||
</ak-sync-object-form>
|
||||
|
||||
@@ -8,7 +8,12 @@ import { msg } from "@lit/localize";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import { MicrosoftEntraProviderGroup, ProvidersApi, SyncObjectModelEnum } from "@goauthentik/api";
|
||||
import {
|
||||
MicrosoftEntraProviderGroup,
|
||||
ProvidersApi,
|
||||
ProvidersMicrosoftEntraSyncObjectCreateRequest,
|
||||
SyncObjectModelEnum,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-provider-microsoft-entra-groups-list")
|
||||
export class MicrosoftEntraProviderGroupList extends Table<MicrosoftEntraProviderGroup> {
|
||||
@@ -28,8 +33,11 @@ export class MicrosoftEntraProviderGroupList extends Table<MicrosoftEntraProvide
|
||||
<ak-sync-object-form
|
||||
.provider=${this.providerId}
|
||||
model=${SyncObjectModelEnum.Group}
|
||||
.sync=${new ProvidersApi(DEFAULT_CONFIG)
|
||||
.providersMicrosoftEntraSyncObjectCreate}
|
||||
.sync=${(data: ProvidersMicrosoftEntraSyncObjectCreateRequest) => {
|
||||
return new ProvidersApi(
|
||||
DEFAULT_CONFIG,
|
||||
).providersMicrosoftEntraSyncObjectCreate(data);
|
||||
}}
|
||||
slot="form"
|
||||
>
|
||||
</ak-sync-object-form>
|
||||
|
||||
@@ -8,7 +8,12 @@ import { msg } from "@lit/localize";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import { MicrosoftEntraProviderUser, ProvidersApi, SyncObjectModelEnum } from "@goauthentik/api";
|
||||
import {
|
||||
MicrosoftEntraProviderUser,
|
||||
ProvidersApi,
|
||||
ProvidersMicrosoftEntraSyncObjectCreateRequest,
|
||||
SyncObjectModelEnum,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-provider-microsoft-entra-users-list")
|
||||
export class MicrosoftEntraProviderUserList extends Table<MicrosoftEntraProviderUser> {
|
||||
@@ -31,8 +36,11 @@ export class MicrosoftEntraProviderUserList extends Table<MicrosoftEntraProvider
|
||||
<ak-sync-object-form
|
||||
.provider=${this.providerId}
|
||||
model=${SyncObjectModelEnum.User}
|
||||
.sync=${new ProvidersApi(DEFAULT_CONFIG)
|
||||
.providersMicrosoftEntraSyncObjectCreate}
|
||||
.sync=${(data: ProvidersMicrosoftEntraSyncObjectCreateRequest) => {
|
||||
return new ProvidersApi(
|
||||
DEFAULT_CONFIG,
|
||||
).providersMicrosoftEntraSyncObjectCreate(data);
|
||||
}}
|
||||
slot="form"
|
||||
>
|
||||
</ak-sync-object-form>
|
||||
|
||||
@@ -38,12 +38,15 @@ export async function scimPropertyMappingsProvider(page = 1, search = "") {
|
||||
};
|
||||
}
|
||||
|
||||
export function makeSCIMPropertyMappingsSelector(instanceMappings: string[] | undefined) {
|
||||
export function makeSCIMPropertyMappingsSelector(
|
||||
instanceMappings: string[] | undefined,
|
||||
defaultSelected: string,
|
||||
) {
|
||||
const localMappings = instanceMappings ? new Set(instanceMappings) : undefined;
|
||||
return localMappings
|
||||
? ([pk, _]: DualSelectPair) => localMappings.has(pk)
|
||||
: ([_0, _1, _2, mapping]: DualSelectPair<SCIMMapping>) =>
|
||||
mapping?.managed === "goauthentik.io/providers/scim/user";
|
||||
mapping?.managed === defaultSelected;
|
||||
}
|
||||
|
||||
@customElement("ak-provider-scim-form")
|
||||
@@ -172,6 +175,7 @@ export class SCIMProviderFormPage extends BaseProviderForm<SCIMProvider> {
|
||||
.provider=${scimPropertyMappingsProvider}
|
||||
.selector=${makeSCIMPropertyMappingsSelector(
|
||||
this.instance?.propertyMappings,
|
||||
"goauthentik.io/providers/scim/user",
|
||||
)}
|
||||
available-label=${msg("Available User Property Mappings")}
|
||||
selected-label=${msg("Selected User Property Mappings")}
|
||||
@@ -188,6 +192,7 @@ export class SCIMProviderFormPage extends BaseProviderForm<SCIMProvider> {
|
||||
.provider=${scimPropertyMappingsProvider}
|
||||
.selector=${makeSCIMPropertyMappingsSelector(
|
||||
this.instance?.propertyMappingsGroup,
|
||||
"goauthentik.io/providers/scim/group",
|
||||
)}
|
||||
available-label=${msg("Available Group Property Mappings")}
|
||||
selected-label=${msg("Selected Group Property Mappings")}
|
||||
|
||||
@@ -8,7 +8,12 @@ import { msg } from "@lit/localize";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import { ProvidersApi, SCIMProviderGroup, SyncObjectModelEnum } from "@goauthentik/api";
|
||||
import {
|
||||
ProvidersApi,
|
||||
ProvidersScimSyncObjectCreateRequest,
|
||||
SCIMProviderGroup,
|
||||
SyncObjectModelEnum,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-provider-scim-groups-list")
|
||||
export class SCIMProviderGroupList extends Table<SCIMProviderGroup> {
|
||||
@@ -29,7 +34,9 @@ export class SCIMProviderGroupList extends Table<SCIMProviderGroup> {
|
||||
<ak-sync-object-form
|
||||
.provider=${this.providerId}
|
||||
model=${SyncObjectModelEnum.Group}
|
||||
.sync=${new ProvidersApi(DEFAULT_CONFIG).providersScimSyncObjectCreate}
|
||||
.sync=${(data: ProvidersScimSyncObjectCreateRequest) => {
|
||||
return new ProvidersApi(DEFAULT_CONFIG).providersScimSyncObjectCreate(data);
|
||||
}}
|
||||
slot="form"
|
||||
>
|
||||
</ak-sync-object-form>
|
||||
|
||||
@@ -8,7 +8,12 @@ import { msg } from "@lit/localize";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import { ProvidersApi, SCIMProviderUser, SyncObjectModelEnum } from "@goauthentik/api";
|
||||
import {
|
||||
ProvidersApi,
|
||||
ProvidersScimSyncObjectCreateRequest,
|
||||
SCIMProviderUser,
|
||||
SyncObjectModelEnum,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-provider-scim-users-list")
|
||||
export class SCIMProviderUserList extends Table<SCIMProviderUser> {
|
||||
@@ -29,7 +34,9 @@ export class SCIMProviderUserList extends Table<SCIMProviderUser> {
|
||||
<ak-sync-object-form
|
||||
.provider=${this.providerId}
|
||||
model=${SyncObjectModelEnum.User}
|
||||
.sync=${new ProvidersApi(DEFAULT_CONFIG).providersScimSyncObjectCreate}
|
||||
.sync=${(data: ProvidersScimSyncObjectCreateRequest) => {
|
||||
return new ProvidersApi(DEFAULT_CONFIG).providersScimSyncObjectCreate(data);
|
||||
}}
|
||||
slot="form"
|
||||
>
|
||||
</ak-sync-object-form>
|
||||
|
||||
@@ -102,10 +102,14 @@ export class UserDeviceTable extends Table<Device> {
|
||||
html`${item.name}`,
|
||||
html`${deviceTypeName(item)}`,
|
||||
html`${item.confirmed ? msg("Yes") : msg("No")}`,
|
||||
html`<div>${getRelativeTime(item.created)}</div>
|
||||
<small>${item.created.toLocaleString()}</small>`,
|
||||
html`<div>${getRelativeTime(item.lastUpdated)}</div>
|
||||
<small>${item.lastUpdated.toLocaleString()}</small>`,
|
||||
html`${item.created.getTime() > 0
|
||||
? html`<div>${getRelativeTime(item.created)}</div>
|
||||
<small>${item.created.toLocaleString()}</small>`
|
||||
: html`-`}`,
|
||||
html`${item.lastUpdated
|
||||
? html`<div>${getRelativeTime(item.lastUpdated)}</div>
|
||||
<small>${item.lastUpdated.toLocaleString()}</small>`
|
||||
: html`-`}`,
|
||||
html`${item.lastUsed
|
||||
? html`<div>${getRelativeTime(item.lastUsed)}</div>
|
||||
<small>${item.lastUsed.toLocaleString()}</small>`
|
||||
|
||||
@@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
|
||||
export const ERROR_CLASS = "pf-m-danger";
|
||||
export const PROGRESS_CLASS = "pf-m-in-progress";
|
||||
export const CURRENT_CLASS = "pf-m-current";
|
||||
export const VERSION = "2024.8.3";
|
||||
export const VERSION = "2024.8.4";
|
||||
export const TITLE_DEFAULT = "authentik";
|
||||
export const ROUTE_SEPARATOR = ";";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user