mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
sources/SCIM: Full Patch support for User and Group (#15485)
* add patch support Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix group members Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add tests for group adding Signed-off-by: Jens Langhammer <jens@goauthentik.io> * format, more tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * mark patch as supported Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix Signed-off-by: Jens Langhammer <jens@goauthentik.io> * support excludedAttributes Signed-off-by: Jens Langhammer <jens@goauthentik.io> * allow updating externalId Signed-off-by: Jens Langhammer <jens@goauthentik.io> * more patcher tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * let the ai do things? Signed-off-by: Jens Langhammer <jens@goauthentik.io> * format Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix ai generated code Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * remove the old code Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add fix to handle URN format Signed-off-by: Jens Langhammer <jens@goauthentik.io> * tests pass Signed-off-by: Jens Langhammer <jens@goauthentik.io> * improve 404 handling for non uuid IDs Signed-off-by: Jens Langhammer <jens@goauthentik.io> * better None path handling Signed-off-by: Jens Langhammer <jens@goauthentik.io> * split code to make it more readable Signed-off-by: Jens Langhammer <jens@goauthentik.io> * handle patch operation with Path None and value containing urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix tests that were not correct Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix external ID change - the bad way Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add separate field for externalId Signed-off-by: Jens Langhammer <jens@goauthentik.io> * more schema fixes Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix replace for manager Signed-off-by: Jens Langhammer <jens@goauthentik.io> * save last_updated Signed-off-by: Jens Langhammer <jens@goauthentik.io> * more unittests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * more tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import AnyUrl, BaseModel, ConfigDict, Field
|
||||
from pydanticscim.group import Group as BaseGroup
|
||||
from pydanticscim.responses import PatchOperation as BasePatchOperation
|
||||
from pydanticscim.responses import PatchRequest as BasePatchRequest
|
||||
@@ -12,19 +12,95 @@ from pydanticscim.service_provider import ChangePassword, Filter, Patch, Sort
|
||||
from pydanticscim.service_provider import (
|
||||
ServiceProviderConfiguration as BaseServiceProviderConfiguration,
|
||||
)
|
||||
from pydanticscim.user import AddressKind
|
||||
from pydanticscim.user import User as BaseUser
|
||||
|
||||
SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
|
||||
SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
|
||||
|
||||
|
||||
class Address(BaseModel):
|
||||
formatted: str | None = Field(
|
||||
None,
|
||||
description="The full mailing address, formatted for display "
|
||||
"or use with a mailing label. This attribute MAY contain newlines.",
|
||||
)
|
||||
streetAddress: str | None = Field(
|
||||
None,
|
||||
description="The full street address component, which may "
|
||||
"include house number, street name, P.O. box, and multi-line "
|
||||
"extended street address information. This attribute MAY contain newlines.",
|
||||
)
|
||||
locality: str | None = Field(None, description="The city or locality component.")
|
||||
region: str | None = Field(None, description="The state or region component.")
|
||||
postalCode: str | None = Field(None, description="The zip code or postal code component.")
|
||||
country: str | None = Field(None, description="The country name component.")
|
||||
type: AddressKind | None = Field(
|
||||
None,
|
||||
description="A label indicating the attribute's function, e.g., 'work' or 'home'.",
|
||||
)
|
||||
primary: bool | None = None
|
||||
|
||||
|
||||
class Manager(BaseModel):
|
||||
value: str | None = Field(
|
||||
None,
|
||||
description="The id of the SCIM resource representingthe User's manager. REQUIRED.",
|
||||
)
|
||||
ref: AnyUrl | None = Field(
|
||||
None,
|
||||
alias="$ref",
|
||||
description="The URI of the SCIM resource representing the User's manager. REQUIRED.",
|
||||
)
|
||||
displayName: str | None = Field(
|
||||
None,
|
||||
description="The displayName of the User's manager. OPTIONAL and READ-ONLY.",
|
||||
)
|
||||
|
||||
|
||||
class EnterpriseUser(BaseModel):
|
||||
employeeNumber: str | None = Field(
|
||||
None,
|
||||
description="Numeric or alphanumeric identifier assigned to a person, "
|
||||
"typically based on order of hire or association with anorganization.",
|
||||
)
|
||||
costCenter: str | None = Field(None, description="Identifies the name of a cost center.")
|
||||
organization: str | None = Field(None, description="Identifies the name of an organization.")
|
||||
division: str | None = Field(None, description="Identifies the name of a division.")
|
||||
department: str | None = Field(
|
||||
None,
|
||||
description="Numeric or alphanumeric identifier assigned to a person,"
|
||||
" typically based on order of hire or association with anorganization.",
|
||||
)
|
||||
manager: Manager | None = Field(
|
||||
None,
|
||||
description="The User's manager. A complex type that optionally allows "
|
||||
"service providers to represent organizational hierarchy by referencing"
|
||||
" the 'id' attribute of another User.",
|
||||
)
|
||||
|
||||
|
||||
class User(BaseUser):
|
||||
"""Modified User schema with added externalId field"""
|
||||
|
||||
model_config = ConfigDict(serialize_by_alias=True)
|
||||
|
||||
id: str | int | None = None
|
||||
schemas: list[str] = [SCIM_USER_SCHEMA]
|
||||
externalId: str | None = None
|
||||
meta: dict | None = None
|
||||
addresses: list[Address] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"A physical mailing address for this User. Canonical type "
|
||||
"values of 'work', 'home', and 'other'."
|
||||
),
|
||||
)
|
||||
enterprise_user: EnterpriseUser | None = Field(
|
||||
default=None,
|
||||
alias="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
|
||||
serialization_alias="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
|
||||
)
|
||||
|
||||
|
||||
class Group(BaseGroup):
|
||||
@@ -92,7 +168,7 @@ class PatchOperation(BasePatchOperation):
|
||||
"""PatchOperation with optional path"""
|
||||
|
||||
op: PatchOp
|
||||
path: str | None
|
||||
path: str | None = None
|
||||
|
||||
|
||||
class SCIMError(BaseSCIMError):
|
||||
|
||||
@@ -18,6 +18,7 @@ class SCIMSourceGroupSerializer(SourceSerializer):
|
||||
model = SCIMSourceGroup
|
||||
fields = [
|
||||
"id",
|
||||
"external_id",
|
||||
"group",
|
||||
"group_obj",
|
||||
"source",
|
||||
@@ -31,5 +32,5 @@ class SCIMSourceGroupViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = SCIMSourceGroup.objects.all().select_related("group")
|
||||
serializer_class = SCIMSourceGroupSerializer
|
||||
filterset_fields = ["source__slug", "group__name", "group__group_uuid"]
|
||||
search_fields = ["source__slug", "group__name", "attributes"]
|
||||
search_fields = ["source__slug", "group__name", "attributes", "external_id"]
|
||||
ordering = ["group__name"]
|
||||
|
||||
@@ -18,6 +18,7 @@ class SCIMSourceUserSerializer(SourceSerializer):
|
||||
model = SCIMSourceUser
|
||||
fields = [
|
||||
"id",
|
||||
"external_id",
|
||||
"user",
|
||||
"user_obj",
|
||||
"source",
|
||||
@@ -31,5 +32,5 @@ class SCIMSourceUserViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = SCIMSourceUser.objects.all().select_related("user")
|
||||
serializer_class = SCIMSourceUserSerializer
|
||||
filterset_fields = ["source__slug", "user__username", "user__id"]
|
||||
search_fields = ["source__slug", "user__username", "attributes"]
|
||||
search_fields = ["source__slug", "user__username", "attributes", "user__uuid", "external_id"]
|
||||
ordering = ["user__username"]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
SCIM_URN_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Schema"
|
||||
SCIM_URN_GROUP = "urn:ietf:params:scim:schemas:core:2.0:Group"
|
||||
SCIM_URN_USER = "urn:ietf:params:scim:schemas:core:2.0:User"
|
||||
SCIM_URN_USER_ENTERPRISE = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
|
||||
@@ -1,8 +0,0 @@
|
||||
"""SCIM Errors"""
|
||||
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
|
||||
|
||||
class PatchError(SentryIgnoredException):
|
||||
"""Error raised within an atomic block when an error happened
|
||||
so nothing is saved"""
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
# Generated by Django 5.1.11 on 2025-07-13 01:07
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
from django.apps.registry import Apps
|
||||
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def migrate_ext_id(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
SCIMSourceUser = apps.get_model("authentik_sources_scim", "SCIMSourceUser")
|
||||
SCIMSourceGroup = apps.get_model("authentik_sources_scim", "SCIMSourceGroup")
|
||||
db_alias = schema_editor.connection.alias
|
||||
for user in SCIMSourceUser.objects.using(db_alias).all():
|
||||
user.external_id = user.id
|
||||
user.save(update_fields=["external_id"])
|
||||
for group in SCIMSourceGroup.objects.using(db_alias).all():
|
||||
group.external_id = group.id
|
||||
group.save(update_fields=["external_id"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_sources_scim", "0002_scimsourcepropertymapping"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="scimsourcegroup",
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="scimsourceuser",
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="scimsourcegroup",
|
||||
name="external_id",
|
||||
field=models.TextField(default=None, null=True),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="scimsourceuser",
|
||||
name="external_id",
|
||||
field=models.TextField(default=None, null=True),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="scimsourcegroup",
|
||||
unique_together={("external_id", "source")},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="scimsourceuser",
|
||||
unique_together={("external_id", "source")},
|
||||
),
|
||||
migrations.RunPython(migrate_ext_id, migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name="scimsourcegroup",
|
||||
name="external_id",
|
||||
field=models.TextField(),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="scimsourceuser",
|
||||
name="external_id",
|
||||
field=models.TextField(),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="scimsourcegroup",
|
||||
index=models.Index(fields=["external_id"], name="authentik_s_externa_05e346_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="scimsourceuser",
|
||||
index=models.Index(fields=["external_id"], name="authentik_s_externa_4bd760_idx"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="scimsourcegroup",
|
||||
name="id",
|
||||
field=models.TextField(default=uuid.uuid4, primary_key=True, serialize=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="scimsourceuser",
|
||||
name="id",
|
||||
field=models.TextField(default=uuid.uuid4, primary_key=True, serialize=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="scimsourcegroup",
|
||||
name="last_update",
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="scimsourceuser",
|
||||
name="last_update",
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
"""SCIM Source"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import models
|
||||
from django.templatetags.static import static
|
||||
@@ -103,10 +104,12 @@ class SCIMSourcePropertyMapping(PropertyMapping):
|
||||
class SCIMSourceUser(SerializerModel):
|
||||
"""Mapping of a user and source to a SCIM user ID"""
|
||||
|
||||
id = models.TextField(primary_key=True)
|
||||
id = models.TextField(primary_key=True, default=uuid4)
|
||||
external_id = models.TextField()
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
source = models.ForeignKey(SCIMSource, on_delete=models.CASCADE)
|
||||
attributes = models.JSONField(default=dict)
|
||||
last_update = models.DateTimeField(auto_now=True)
|
||||
|
||||
@property
|
||||
def serializer(self) -> BaseSerializer:
|
||||
@@ -115,7 +118,10 @@ class SCIMSourceUser(SerializerModel):
|
||||
return SCIMSourceUserSerializer
|
||||
|
||||
class Meta:
|
||||
unique_together = (("id", "user", "source"),)
|
||||
unique_together = (("external_id", "source"),)
|
||||
indexes = [
|
||||
models.Index(fields=["external_id"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"SCIM User {self.user_id} to {self.source_id}"
|
||||
@@ -124,10 +130,12 @@ class SCIMSourceUser(SerializerModel):
|
||||
class SCIMSourceGroup(SerializerModel):
|
||||
"""Mapping of a group and source to a SCIM user ID"""
|
||||
|
||||
id = models.TextField(primary_key=True)
|
||||
id = models.TextField(primary_key=True, default=uuid4)
|
||||
external_id = models.TextField()
|
||||
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
||||
source = models.ForeignKey(SCIMSource, on_delete=models.CASCADE)
|
||||
attributes = models.JSONField(default=dict)
|
||||
last_update = models.DateTimeField(auto_now=True)
|
||||
|
||||
@property
|
||||
def serializer(self) -> BaseSerializer:
|
||||
@@ -136,7 +144,10 @@ class SCIMSourceGroup(SerializerModel):
|
||||
return SCIMSourceGroupSerializer
|
||||
|
||||
class Meta:
|
||||
unique_together = (("id", "group", "source"),)
|
||||
unique_together = (("external_id", "source"),)
|
||||
indexes = [
|
||||
models.Index(fields=["external_id"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"SCIM Group {self.group_id} to {self.source_id}"
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from authentik.sources.scim.constants import (
|
||||
SCIM_URN_GROUP,
|
||||
SCIM_URN_SCHEMA,
|
||||
SCIM_URN_USER,
|
||||
SCIM_URN_USER_ENTERPRISE,
|
||||
)
|
||||
|
||||
|
||||
# Token types for SCIM path parsing
|
||||
class TokenType(Enum):
|
||||
ATTRIBUTE = "ATTRIBUTE"
|
||||
DOT = "DOT"
|
||||
LBRACKET = "LBRACKET"
|
||||
RBRACKET = "RBRACKET"
|
||||
LPAREN = "LPAREN"
|
||||
RPAREN = "RPAREN"
|
||||
STRING = "STRING"
|
||||
NUMBER = "NUMBER"
|
||||
BOOLEAN = "BOOLEAN"
|
||||
NULL = "NULL"
|
||||
OPERATOR = "OPERATOR"
|
||||
AND = "AND"
|
||||
OR = "OR"
|
||||
NOT = "NOT"
|
||||
EOF = "EOF"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Token:
|
||||
type: TokenType
|
||||
value: str
|
||||
position: int = 0
|
||||
|
||||
|
||||
class SCIMPathLexer:
|
||||
"""Lexer for SCIM paths and filter expressions"""
|
||||
|
||||
OPERATORS = ["eq", "ne", "co", "sw", "ew", "gt", "lt", "ge", "le", "pr"]
|
||||
|
||||
def __init__(self, text: str):
|
||||
self.schema_urns = [
|
||||
SCIM_URN_SCHEMA,
|
||||
SCIM_URN_GROUP,
|
||||
SCIM_URN_USER,
|
||||
SCIM_URN_USER_ENTERPRISE,
|
||||
]
|
||||
self.text = text
|
||||
self.pos = 0
|
||||
self.current_char = self.text[self.pos] if self.pos < len(self.text) else None
|
||||
|
||||
def advance(self):
|
||||
"""Move to next character"""
|
||||
self.pos += 1
|
||||
self.current_char = self.text[self.pos] if self.pos < len(self.text) else None
|
||||
|
||||
def skip_whitespace(self):
|
||||
"""Skip whitespace characters"""
|
||||
while self.current_char and self.current_char.isspace():
|
||||
self.advance()
|
||||
|
||||
def read_string(self, quote_char):
|
||||
"""Read a quoted string"""
|
||||
value = ""
|
||||
self.advance() # Skip opening quote
|
||||
|
||||
while self.current_char and self.current_char != quote_char:
|
||||
if self.current_char == "\\":
|
||||
self.advance()
|
||||
if self.current_char:
|
||||
value += self.current_char
|
||||
self.advance()
|
||||
else:
|
||||
value += self.current_char
|
||||
self.advance()
|
||||
|
||||
if self.current_char == quote_char:
|
||||
self.advance() # Skip closing quote
|
||||
|
||||
return value
|
||||
|
||||
def read_number(self):
|
||||
"""Read a number (integer or float)"""
|
||||
value = ""
|
||||
while self.current_char and (self.current_char.isdigit() or self.current_char == "."):
|
||||
value += self.current_char
|
||||
self.advance()
|
||||
return value
|
||||
|
||||
def read_identifier(self):
|
||||
"""Read an identifier (attribute name or operator) - supports URN format"""
|
||||
value = ""
|
||||
while self.current_char and (self.current_char.isalnum() or self.current_char in "_-:"):
|
||||
value += self.current_char
|
||||
self.advance()
|
||||
# If the identifier value so far is a schema URN, take that as the identifier and
|
||||
# treat the next part as a sub_attribute
|
||||
if value in self.schema_urns:
|
||||
self.current_char = "."
|
||||
return value
|
||||
|
||||
# Handle dots within URN identifiers (like "2.0")
|
||||
# A dot is part of the identifier if it's followed by a digit
|
||||
if (
|
||||
self.current_char == "."
|
||||
and self.pos + 1 < len(self.text)
|
||||
and self.text[self.pos + 1].isdigit()
|
||||
):
|
||||
value += self.current_char
|
||||
self.advance()
|
||||
# Continue reading digits after the dot
|
||||
while self.current_char and self.current_char.isdigit():
|
||||
value += self.current_char
|
||||
self.advance()
|
||||
|
||||
return value
|
||||
|
||||
def get_next_token(self) -> Token: # noqa PLR0911
|
||||
"""Get the next token from the input"""
|
||||
while self.current_char:
|
||||
if self.current_char.isspace():
|
||||
self.skip_whitespace()
|
||||
continue
|
||||
|
||||
if self.current_char == ".":
|
||||
self.advance()
|
||||
return Token(TokenType.DOT, ".")
|
||||
|
||||
if self.current_char == "[":
|
||||
self.advance()
|
||||
return Token(TokenType.LBRACKET, "[")
|
||||
|
||||
if self.current_char == "]":
|
||||
self.advance()
|
||||
return Token(TokenType.RBRACKET, "]")
|
||||
|
||||
if self.current_char == "(":
|
||||
self.advance()
|
||||
return Token(TokenType.LPAREN, "(")
|
||||
|
||||
if self.current_char == ")":
|
||||
self.advance()
|
||||
return Token(TokenType.RPAREN, ")")
|
||||
|
||||
if self.current_char in "\"'":
|
||||
quote_char = self.current_char
|
||||
value = self.read_string(quote_char)
|
||||
return Token(TokenType.STRING, value)
|
||||
|
||||
if self.current_char.isdigit():
|
||||
value = self.read_number()
|
||||
return Token(TokenType.NUMBER, value)
|
||||
|
||||
if self.current_char.isalpha() or self.current_char == "_":
|
||||
value = self.read_identifier()
|
||||
|
||||
# Check for special keywords
|
||||
if value.lower() == "true":
|
||||
return Token(TokenType.BOOLEAN, True)
|
||||
elif value.lower() == "false":
|
||||
return Token(TokenType.BOOLEAN, False)
|
||||
elif value.lower() == "null":
|
||||
return Token(TokenType.NULL, None)
|
||||
elif value.lower() == "and":
|
||||
return Token(TokenType.AND, "and")
|
||||
elif value.lower() == "or":
|
||||
return Token(TokenType.OR, "or")
|
||||
elif value.lower() == "not":
|
||||
return Token(TokenType.NOT, "not")
|
||||
elif value.lower() in self.OPERATORS:
|
||||
return Token(TokenType.OPERATOR, value.lower())
|
||||
else:
|
||||
return Token(TokenType.ATTRIBUTE, value)
|
||||
|
||||
# Skip unknown characters
|
||||
self.advance()
|
||||
|
||||
return Token(TokenType.EOF, "")
|
||||
@@ -0,0 +1,131 @@
|
||||
from typing import Any
|
||||
|
||||
from authentik.sources.scim.patch.lexer import SCIMPathLexer, TokenType
|
||||
|
||||
|
||||
class SCIMPathParser:
|
||||
"""Parser for SCIM paths including filter expressions"""
|
||||
|
||||
def __init__(self):
|
||||
self.lexer = None
|
||||
self.current_token = None
|
||||
|
||||
def parse_path(self, path: str | None) -> list[dict[str, Any]]:
|
||||
"""Parse a SCIM path into components"""
|
||||
self.lexer = SCIMPathLexer(path)
|
||||
self.current_token = self.lexer.get_next_token()
|
||||
|
||||
components = []
|
||||
|
||||
while self.current_token.type != TokenType.EOF:
|
||||
component = self._parse_path_component()
|
||||
if component:
|
||||
components.append(component)
|
||||
|
||||
return components
|
||||
|
||||
def _parse_path_component(self) -> dict[str, Any] | None:
|
||||
"""Parse a single path component"""
|
||||
if self.current_token.type != TokenType.ATTRIBUTE:
|
||||
return None
|
||||
|
||||
attribute = self.current_token.value
|
||||
self._consume(TokenType.ATTRIBUTE)
|
||||
|
||||
filter_expr = None
|
||||
sub_attribute = None
|
||||
|
||||
# Check for filter expression
|
||||
if self.current_token.type == TokenType.LBRACKET:
|
||||
self._consume(TokenType.LBRACKET)
|
||||
filter_expr = self._parse_filter_expression()
|
||||
self._consume(TokenType.RBRACKET)
|
||||
|
||||
# Check for sub-attribute
|
||||
if self.current_token.type == TokenType.DOT:
|
||||
self._consume(TokenType.DOT)
|
||||
if self.current_token.type == TokenType.ATTRIBUTE:
|
||||
sub_attribute = self.current_token.value
|
||||
self._consume(TokenType.ATTRIBUTE)
|
||||
|
||||
return {"attribute": attribute, "filter": filter_expr, "sub_attribute": sub_attribute}
|
||||
|
||||
def _parse_filter_expression(self) -> dict[str, Any] | None:
|
||||
"""Parse a filter expression like 'primary eq true' or
|
||||
'type eq "work" and primary eq true'"""
|
||||
return self._parse_or_expression()
|
||||
|
||||
def _parse_or_expression(self) -> dict[str, Any] | None:
|
||||
"""Parse OR expressions"""
|
||||
left = self._parse_and_expression()
|
||||
|
||||
while self.current_token.type == TokenType.OR:
|
||||
self._consume(TokenType.OR)
|
||||
right = self._parse_and_expression()
|
||||
left = {"type": "logical", "operator": "or", "left": left, "right": right}
|
||||
|
||||
return left
|
||||
|
||||
def _parse_and_expression(self) -> dict[str, Any] | None:
|
||||
"""Parse AND expressions"""
|
||||
left = self._parse_primary_expression()
|
||||
|
||||
while self.current_token.type == TokenType.AND:
|
||||
self._consume(TokenType.AND)
|
||||
right = self._parse_primary_expression()
|
||||
left = {"type": "logical", "operator": "and", "left": left, "right": right}
|
||||
|
||||
return left
|
||||
|
||||
def _parse_primary_expression(self) -> dict[str, Any] | None:
|
||||
"""Parse primary expressions (attribute operator value)"""
|
||||
if self.current_token.type == TokenType.LPAREN:
|
||||
self._consume(TokenType.LPAREN)
|
||||
expr = self._parse_or_expression()
|
||||
self._consume(TokenType.RPAREN)
|
||||
return expr
|
||||
|
||||
if self.current_token.type == TokenType.NOT:
|
||||
self._consume(TokenType.NOT)
|
||||
expr = self._parse_primary_expression()
|
||||
return {"type": "logical", "operator": "not", "operand": expr}
|
||||
|
||||
if self.current_token.type != TokenType.ATTRIBUTE:
|
||||
return None
|
||||
|
||||
attribute = self.current_token.value
|
||||
self._consume(TokenType.ATTRIBUTE)
|
||||
|
||||
if self.current_token.type != TokenType.OPERATOR:
|
||||
return None
|
||||
|
||||
operator = self.current_token.value
|
||||
self._consume(TokenType.OPERATOR)
|
||||
|
||||
# Parse value
|
||||
value = None
|
||||
if self.current_token.type == TokenType.STRING:
|
||||
value = self.current_token.value
|
||||
self._consume(TokenType.STRING)
|
||||
elif self.current_token.type == TokenType.NUMBER:
|
||||
value = (
|
||||
float(self.current_token.value)
|
||||
if "." in self.current_token.value
|
||||
else int(self.current_token.value)
|
||||
)
|
||||
self._consume(TokenType.NUMBER)
|
||||
elif self.current_token.type == TokenType.BOOLEAN:
|
||||
value = self.current_token.value
|
||||
self._consume(TokenType.BOOLEAN)
|
||||
elif self.current_token.type == TokenType.NULL:
|
||||
value = None
|
||||
self._consume(TokenType.NULL)
|
||||
|
||||
return {"type": "comparison", "attribute": attribute, "operator": operator, "value": value}
|
||||
|
||||
def _consume(self, expected_type: TokenType):
|
||||
"""Consume a token of the expected type"""
|
||||
if self.current_token.type == expected_type:
|
||||
self.current_token = self.lexer.get_next_token()
|
||||
else:
|
||||
raise ValueError(f"Expected {expected_type}, got {self.current_token.type}")
|
||||
@@ -0,0 +1,246 @@
|
||||
from typing import Any
|
||||
|
||||
from authentik.providers.scim.clients.schema import PatchOp, PatchOperation
|
||||
from authentik.sources.scim.constants import SCIM_URN_USER_ENTERPRISE
|
||||
from authentik.sources.scim.patch.parser import SCIMPathParser
|
||||
|
||||
|
||||
class SCIMPatchProcessor:
|
||||
"""Processes SCIM patch operations on Python dictionaries"""
|
||||
|
||||
def __init__(self):
|
||||
self.parser = SCIMPathParser()
|
||||
|
||||
def apply_patches(self, data: dict[str, Any], patches: list[PatchOperation]) -> dict[str, Any]:
|
||||
"""Apply a list of patch operations to the data"""
|
||||
result = data.copy()
|
||||
|
||||
for _patch in patches:
|
||||
patch = PatchOperation.model_validate(_patch)
|
||||
if patch.path is None:
|
||||
# Handle operations with no path - value contains attribute paths as keys
|
||||
self._apply_bulk_operation(result, patch.op, patch.value)
|
||||
elif patch.op == PatchOp.add:
|
||||
self._apply_add(result, patch.path, patch.value)
|
||||
elif patch.op == PatchOp.remove:
|
||||
self._apply_remove(result, patch.path)
|
||||
elif patch.op == PatchOp.replace:
|
||||
self._apply_replace(result, patch.path, patch.value)
|
||||
|
||||
return result
|
||||
|
||||
def _apply_bulk_operation(
|
||||
self, data: dict[str, Any], operation: PatchOp, value: dict[str, Any]
|
||||
):
|
||||
"""Apply bulk operations when path is None"""
|
||||
if not isinstance(value, dict):
|
||||
return
|
||||
for path, val in value.items():
|
||||
if operation == PatchOp.add:
|
||||
self._apply_add(data, path, val)
|
||||
elif operation == PatchOp.remove:
|
||||
self._apply_remove(data, path)
|
||||
elif operation == PatchOp.replace:
|
||||
self._apply_replace(data, path, val)
|
||||
|
||||
def _apply_add(self, data: dict[str, Any], path: str, value: Any):
|
||||
"""Apply ADD operation"""
|
||||
components = self.parser.parse_path(path)
|
||||
|
||||
if len(components) == 1 and not components[0]["filter"]:
|
||||
# Simple path
|
||||
attr = components[0]["attribute"]
|
||||
if components[0]["sub_attribute"]:
|
||||
if attr not in data:
|
||||
data[attr] = {}
|
||||
# Somewhat hacky workaround for the manager attribute of the enterprise schema
|
||||
# ideally we'd do this based on the schema
|
||||
if attr == SCIM_URN_USER_ENTERPRISE and components[0]["sub_attribute"] == "manager":
|
||||
data[attr][components[0]["sub_attribute"]] = {"value": value}
|
||||
else:
|
||||
data[attr][components[0]["sub_attribute"]] = value
|
||||
elif attr in data:
|
||||
data[attr].append(value)
|
||||
else:
|
||||
data[attr] = value
|
||||
else:
|
||||
# Complex path with filters
|
||||
self._navigate_and_modify(data, components, value, "add")
|
||||
|
||||
def _apply_remove(self, data: dict[str, Any], path: str):
|
||||
"""Apply REMOVE operation"""
|
||||
components = self.parser.parse_path(path)
|
||||
|
||||
if len(components) == 1 and not components[0]["filter"]:
|
||||
# Simple path
|
||||
attr = components[0]["attribute"]
|
||||
if components[0]["sub_attribute"]:
|
||||
if attr in data and isinstance(data[attr], dict):
|
||||
data[attr].pop(components[0]["sub_attribute"], None)
|
||||
else:
|
||||
data.pop(attr, None)
|
||||
else:
|
||||
# Complex path with filters
|
||||
self._navigate_and_modify(data, components, None, "remove")
|
||||
|
||||
def _apply_replace(self, data: dict[str, Any], path: str, value: Any):
|
||||
"""Apply REPLACE operation"""
|
||||
components = self.parser.parse_path(path)
|
||||
|
||||
if len(components) == 1 and not components[0]["filter"]:
|
||||
# Simple path
|
||||
attr = components[0]["attribute"]
|
||||
if components[0]["sub_attribute"]:
|
||||
if attr not in data:
|
||||
data[attr] = {}
|
||||
# Somewhat hacky workaround for the manager attribute of the enterprise schema
|
||||
# ideally we'd do this based on the schema
|
||||
if attr == SCIM_URN_USER_ENTERPRISE and components[0]["sub_attribute"] == "manager":
|
||||
data[attr][components[0]["sub_attribute"]] = {"value": value}
|
||||
else:
|
||||
data[attr][components[0]["sub_attribute"]] = value
|
||||
else:
|
||||
data[attr] = value
|
||||
else:
|
||||
# Complex path with filters
|
||||
self._navigate_and_modify(data, components, value, "replace")
|
||||
|
||||
def _navigate_and_modify( # noqa PLR0912
|
||||
self, data: dict[str, Any], components: list[dict[str, Any]], value: Any, operation: str
|
||||
):
|
||||
"""Navigate through complex paths and apply modifications"""
|
||||
current = data
|
||||
|
||||
for i, component in enumerate(components):
|
||||
attr = component["attribute"]
|
||||
filter_expr = component["filter"]
|
||||
sub_attr = component["sub_attribute"]
|
||||
|
||||
if filter_expr:
|
||||
# Handle array with filter
|
||||
if attr not in current:
|
||||
if operation == "add":
|
||||
current[attr] = []
|
||||
else:
|
||||
return
|
||||
|
||||
if not isinstance(current[attr], list):
|
||||
return
|
||||
|
||||
# Find matching items
|
||||
matching_items = []
|
||||
for item in current[attr]:
|
||||
if self._matches_filter(item, filter_expr):
|
||||
matching_items.append(item)
|
||||
|
||||
if not matching_items and operation == "add":
|
||||
# Create new item if none match (only for simple comparison filters)
|
||||
if filter_expr.get("type", "comparison") == "comparison":
|
||||
new_item = {filter_expr["attribute"]: filter_expr["value"]}
|
||||
current[attr].append(new_item)
|
||||
matching_items = [new_item]
|
||||
|
||||
# Apply operation to matching items
|
||||
for item in matching_items:
|
||||
if sub_attr:
|
||||
if operation in {"add", "replace"}:
|
||||
item[sub_attr] = value
|
||||
elif operation == "remove":
|
||||
item.pop(sub_attr, None)
|
||||
elif operation in {"add", "replace"}:
|
||||
if isinstance(value, dict):
|
||||
item.update(value)
|
||||
else:
|
||||
# If value is not a dict, we can't merge it
|
||||
pass
|
||||
elif operation == "remove":
|
||||
# Remove the entire item
|
||||
if item in current[attr]:
|
||||
current[attr].remove(item)
|
||||
# Handle simple attribute
|
||||
elif i == len(components) - 1:
|
||||
# Last component
|
||||
if sub_attr:
|
||||
if attr not in current:
|
||||
current[attr] = {}
|
||||
if operation in {"add", "replace"}:
|
||||
current[attr][sub_attr] = value
|
||||
elif operation == "remove":
|
||||
current[attr].pop(sub_attr, None)
|
||||
elif operation in {"add", "replace"}:
|
||||
current[attr] = value
|
||||
elif operation == "remove":
|
||||
current.pop(attr, None)
|
||||
else:
|
||||
# Navigate deeper
|
||||
if attr not in current:
|
||||
current[attr] = {}
|
||||
current = current[attr]
|
||||
|
||||
def _matches_filter(self, item: dict[str, Any], filter_expr: dict[str, Any]) -> bool:
|
||||
"""Check if an item matches the filter expression"""
|
||||
if not filter_expr:
|
||||
return True
|
||||
|
||||
filter_type = filter_expr.get("type", "comparison")
|
||||
|
||||
if filter_type == "comparison":
|
||||
return self._matches_comparison(item, filter_expr)
|
||||
elif filter_type == "logical":
|
||||
return self._matches_logical(item, filter_expr)
|
||||
|
||||
return False
|
||||
|
||||
def _matches_comparison( # noqa PLR0912
|
||||
self, item: dict[str, Any], filter_expr: dict[str, Any]
|
||||
) -> bool:
|
||||
"""Check if an item matches a comparison filter"""
|
||||
attr = filter_expr["attribute"]
|
||||
operator = filter_expr["operator"]
|
||||
expected_value = filter_expr["value"]
|
||||
|
||||
if attr not in item:
|
||||
return False
|
||||
|
||||
actual_value = item[attr]
|
||||
|
||||
if operator == "eq":
|
||||
return actual_value == expected_value
|
||||
elif operator == "ne":
|
||||
return actual_value != expected_value
|
||||
elif operator == "co":
|
||||
return str(expected_value) in str(actual_value)
|
||||
elif operator == "sw":
|
||||
return str(actual_value).startswith(str(expected_value))
|
||||
elif operator == "ew":
|
||||
return str(actual_value).endswith(str(expected_value))
|
||||
elif operator == "gt":
|
||||
return actual_value > expected_value
|
||||
elif operator == "lt":
|
||||
return actual_value < expected_value
|
||||
elif operator == "ge":
|
||||
return actual_value >= expected_value
|
||||
elif operator == "le":
|
||||
return actual_value <= expected_value
|
||||
elif operator == "pr":
|
||||
return actual_value is not None
|
||||
|
||||
return False
|
||||
|
||||
def _matches_logical(self, item: dict[str, Any], filter_expr: dict[str, Any]) -> bool:
|
||||
"""Check if an item matches a logical filter expression"""
|
||||
operator = filter_expr["operator"]
|
||||
|
||||
if operator == "and":
|
||||
left_result = self._matches_filter(item, filter_expr["left"])
|
||||
right_result = self._matches_filter(item, filter_expr["right"])
|
||||
return left_result and right_result
|
||||
elif operator == "or":
|
||||
left_result = self._matches_filter(item, filter_expr["left"])
|
||||
right_result = self._matches_filter(item, filter_expr["right"])
|
||||
return left_result or right_result
|
||||
elif operator == "not":
|
||||
operand_result = self._matches_filter(item, filter_expr["operand"])
|
||||
return not operand_result
|
||||
|
||||
return False
|
||||
@@ -1101,17 +1101,6 @@
|
||||
"returned": "default",
|
||||
"uniqueness": "none"
|
||||
},
|
||||
{
|
||||
"name": "password",
|
||||
"type": "string",
|
||||
"multiValued": false,
|
||||
"description": "The User's cleartext password. This attribute is intended to be used as a means to specify an initial\npassword when creating a new User or to reset an existing User's password.",
|
||||
"required": false,
|
||||
"caseExact": false,
|
||||
"mutability": "writeOnly",
|
||||
"returned": "never",
|
||||
"uniqueness": "none"
|
||||
},
|
||||
{
|
||||
"name": "emails",
|
||||
"type": "complex",
|
||||
|
||||
@@ -75,7 +75,9 @@ class TestSCIMGroups(APITestCase):
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists())
|
||||
self.assertTrue(
|
||||
SCIMSourceGroup.objects.filter(source=self.source, external_id=ext_id).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
Event.objects.filter(
|
||||
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
|
||||
@@ -86,6 +88,7 @@ class TestSCIMGroups(APITestCase):
|
||||
"""Test group create"""
|
||||
user = create_test_user()
|
||||
ext_id = generate_id()
|
||||
name = generate_id()
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
@@ -95,7 +98,7 @@ class TestSCIMGroups(APITestCase):
|
||||
),
|
||||
data=dumps(
|
||||
{
|
||||
"displayName": generate_id(),
|
||||
"displayName": name,
|
||||
"externalId": ext_id,
|
||||
"members": [{"value": str(user.uuid)}],
|
||||
}
|
||||
@@ -104,12 +107,22 @@ class TestSCIMGroups(APITestCase):
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists())
|
||||
connection = SCIMSourceGroup.objects.filter(source=self.source, external_id=ext_id).first()
|
||||
self.assertIsNotNone(connection)
|
||||
self.assertTrue(
|
||||
Event.objects.filter(
|
||||
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
|
||||
).exists()
|
||||
)
|
||||
connection.refresh_from_db()
|
||||
self.assertEqual(
|
||||
connection.attributes,
|
||||
{
|
||||
"displayName": name,
|
||||
"externalId": ext_id,
|
||||
"members": [{"value": str(user.uuid)}],
|
||||
},
|
||||
)
|
||||
|
||||
def test_group_create_members_empty(self):
|
||||
"""Test group create"""
|
||||
@@ -126,7 +139,9 @@ class TestSCIMGroups(APITestCase):
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists())
|
||||
self.assertTrue(
|
||||
SCIMSourceGroup.objects.filter(source=self.source, external_id=ext_id).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
Event.objects.filter(
|
||||
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
|
||||
@@ -136,7 +151,9 @@ class TestSCIMGroups(APITestCase):
|
||||
def test_group_create_duplicate(self):
|
||||
"""Test group create (duplicate)"""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
existing = SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
|
||||
existing = SCIMSourceGroup.objects.create(
|
||||
source=self.source, group=group, external_id=uuid4()
|
||||
)
|
||||
ext_id = generate_id()
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
@@ -165,7 +182,9 @@ class TestSCIMGroups(APITestCase):
|
||||
def test_group_update(self):
|
||||
"""Test group update"""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
existing = SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
|
||||
existing = SCIMSourceGroup.objects.create(
|
||||
source=self.source, group=group, external_id=uuid4()
|
||||
)
|
||||
ext_id = generate_id()
|
||||
response = self.client.put(
|
||||
reverse(
|
||||
@@ -205,12 +224,49 @@ class TestSCIMGroups(APITestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_group_patch_add(self):
|
||||
def test_group_patch_modify(self):
|
||||
"""Test group patch"""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
connection = SCIMSourceGroup.objects.create(
|
||||
source=self.source,
|
||||
group=group,
|
||||
external_id=uuid4(),
|
||||
attributes={"displayName": group.name, "members": []},
|
||||
)
|
||||
response = self.client.patch(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
kwargs={"source_slug": self.source.slug, "group_id": group.pk},
|
||||
),
|
||||
data=dumps(
|
||||
{
|
||||
"Operations": [
|
||||
{
|
||||
"op": "Add",
|
||||
"value": {"externalId": "d85051cb-0557-4aa1-98ca-51eabcee4d40"},
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
connection = SCIMSourceGroup.objects.filter(id="d85051cb-0557-4aa1-98ca-51eabcee4d40")
|
||||
self.assertIsNotNone(connection)
|
||||
|
||||
def test_group_patch_member_add(self):
|
||||
"""Test group patch"""
|
||||
user = create_test_user()
|
||||
|
||||
other_user = create_test_user()
|
||||
group = Group.objects.create(name=generate_id())
|
||||
SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
|
||||
group.users.add(other_user)
|
||||
connection = SCIMSourceGroup.objects.create(
|
||||
source=self.source,
|
||||
group=group,
|
||||
external_id=uuid4(),
|
||||
attributes={"displayName": group.name, "members": [{"value": str(other_user.uuid)}]},
|
||||
)
|
||||
response = self.client.patch(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
@@ -222,7 +278,7 @@ class TestSCIMGroups(APITestCase):
|
||||
{
|
||||
"op": "Add",
|
||||
"path": "members",
|
||||
"value": {"value": str(user.uuid)},
|
||||
"value": [{"value": str(user.uuid)}],
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -230,16 +286,33 @@ class TestSCIMGroups(APITestCase):
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, second=200)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
self.assertTrue(group.users.filter(pk=user.pk).exists())
|
||||
self.assertTrue(group.users.filter(pk=other_user.pk).exists())
|
||||
connection.refresh_from_db()
|
||||
self.assertEqual(
|
||||
connection.attributes,
|
||||
{
|
||||
"displayName": group.name,
|
||||
"members": sorted(
|
||||
[{"value": str(other_user.uuid)}, {"value": str(user.uuid)}],
|
||||
key=lambda u: u["value"],
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def test_group_patch_remove(self):
|
||||
def test_group_patch_member_remove(self):
|
||||
"""Test group patch"""
|
||||
user = create_test_user()
|
||||
|
||||
group = Group.objects.create(name=generate_id())
|
||||
group.users.add(user)
|
||||
SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
|
||||
connection = SCIMSourceGroup.objects.create(
|
||||
source=self.source,
|
||||
group=group,
|
||||
external_id=uuid4(),
|
||||
attributes={"displayName": group.name, "members": []},
|
||||
)
|
||||
response = self.client.patch(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
@@ -251,7 +324,7 @@ class TestSCIMGroups(APITestCase):
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "members",
|
||||
"value": {"value": str(user.uuid)},
|
||||
"value": [{"value": str(user.uuid)}],
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -259,13 +332,21 @@ class TestSCIMGroups(APITestCase):
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, second=200)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
self.assertFalse(group.users.filter(pk=user.pk).exists())
|
||||
connection.refresh_from_db()
|
||||
self.assertEqual(
|
||||
connection.attributes,
|
||||
{
|
||||
"displayName": group.name,
|
||||
"members": [],
|
||||
},
|
||||
)
|
||||
|
||||
def test_group_delete(self):
|
||||
"""Test group delete"""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
|
||||
SCIMSourceGroup.objects.create(source=self.source, group=group, external_id=uuid4())
|
||||
response = self.client.delete(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
from unittest import TestCase
|
||||
|
||||
from authentik.sources.scim.constants import (
|
||||
SCIM_URN_GROUP,
|
||||
SCIM_URN_SCHEMA,
|
||||
SCIM_URN_USER,
|
||||
SCIM_URN_USER_ENTERPRISE,
|
||||
)
|
||||
from authentik.sources.scim.patch.lexer import SCIMPathLexer, Token, TokenType
|
||||
|
||||
|
||||
class TestTokenType(TestCase):
|
||||
"""Test TokenType enum"""
|
||||
|
||||
def test_token_type_values(self):
|
||||
"""Test that all token types have correct values"""
|
||||
self.assertEqual(TokenType.ATTRIBUTE.value, "ATTRIBUTE")
|
||||
self.assertEqual(TokenType.DOT.value, "DOT")
|
||||
self.assertEqual(TokenType.LBRACKET.value, "LBRACKET")
|
||||
self.assertEqual(TokenType.RBRACKET.value, "RBRACKET")
|
||||
self.assertEqual(TokenType.LPAREN.value, "LPAREN")
|
||||
self.assertEqual(TokenType.RPAREN.value, "RPAREN")
|
||||
self.assertEqual(TokenType.STRING.value, "STRING")
|
||||
self.assertEqual(TokenType.NUMBER.value, "NUMBER")
|
||||
self.assertEqual(TokenType.BOOLEAN.value, "BOOLEAN")
|
||||
self.assertEqual(TokenType.NULL.value, "NULL")
|
||||
self.assertEqual(TokenType.OPERATOR.value, "OPERATOR")
|
||||
self.assertEqual(TokenType.AND.value, "AND")
|
||||
self.assertEqual(TokenType.OR.value, "OR")
|
||||
self.assertEqual(TokenType.NOT.value, "NOT")
|
||||
self.assertEqual(TokenType.EOF.value, "EOF")
|
||||
|
||||
|
||||
class TestToken(TestCase):
|
||||
"""Test Token dataclass"""
|
||||
|
||||
def test_token_creation(self):
|
||||
"""Test token creation with all parameters"""
|
||||
token = Token(TokenType.ATTRIBUTE, "userName", 5)
|
||||
self.assertEqual(token.type, TokenType.ATTRIBUTE)
|
||||
self.assertEqual(token.value, "userName")
|
||||
self.assertEqual(token.position, 5)
|
||||
|
||||
def test_token_creation_default_position(self):
|
||||
"""Test token creation with default position"""
|
||||
token = Token(TokenType.DOT, ".")
|
||||
self.assertEqual(token.type, TokenType.DOT)
|
||||
self.assertEqual(token.value, ".")
|
||||
self.assertEqual(token.position, 0)
|
||||
|
||||
|
||||
class TestSCIMPathLexer(TestCase):
|
||||
"""Test SCIMPathLexer class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.simple_lexer = SCIMPathLexer("userName")
|
||||
|
||||
def test_init(self):
|
||||
"""Test lexer initialization"""
|
||||
lexer = SCIMPathLexer("test")
|
||||
self.assertEqual(lexer.text, "test")
|
||||
self.assertEqual(lexer.pos, 0)
|
||||
self.assertEqual(lexer.current_char, "t")
|
||||
self.assertIn(SCIM_URN_SCHEMA, lexer.schema_urns)
|
||||
self.assertIn(SCIM_URN_GROUP, lexer.schema_urns)
|
||||
self.assertIn(SCIM_URN_USER, lexer.schema_urns)
|
||||
self.assertIn(SCIM_URN_USER_ENTERPRISE, lexer.schema_urns)
|
||||
self.assertEqual(
|
||||
lexer.OPERATORS, ["eq", "ne", "co", "sw", "ew", "gt", "lt", "ge", "le", "pr"]
|
||||
)
|
||||
|
||||
def test_init_empty_string(self):
|
||||
"""Test lexer initialization with empty string"""
|
||||
lexer = SCIMPathLexer("")
|
||||
self.assertEqual(lexer.text, "")
|
||||
self.assertEqual(lexer.pos, 0)
|
||||
self.assertIsNone(lexer.current_char)
|
||||
|
||||
def test_advance(self):
|
||||
"""Test advance method"""
|
||||
lexer = SCIMPathLexer("abc")
|
||||
self.assertEqual(lexer.current_char, "a")
|
||||
|
||||
lexer.advance()
|
||||
self.assertEqual(lexer.pos, 1)
|
||||
self.assertEqual(lexer.current_char, "b")
|
||||
|
||||
lexer.advance()
|
||||
self.assertEqual(lexer.pos, 2)
|
||||
self.assertEqual(lexer.current_char, "c")
|
||||
|
||||
lexer.advance()
|
||||
self.assertEqual(lexer.pos, 3)
|
||||
self.assertIsNone(lexer.current_char)
|
||||
|
||||
def test_skip_whitespace(self):
|
||||
"""Test skip_whitespace method"""
|
||||
lexer = SCIMPathLexer(" \t\n abc")
|
||||
lexer.skip_whitespace()
|
||||
self.assertEqual(lexer.current_char, "a")
|
||||
|
||||
def test_skip_whitespace_only_whitespace(self):
|
||||
"""Test skip_whitespace with only whitespace"""
|
||||
lexer = SCIMPathLexer(" \t\n ")
|
||||
lexer.skip_whitespace()
|
||||
self.assertIsNone(lexer.current_char)
|
||||
|
||||
def test_skip_whitespace_no_whitespace(self):
|
||||
"""Test skip_whitespace with no leading whitespace"""
|
||||
lexer = SCIMPathLexer("abc")
|
||||
original_pos = lexer.pos
|
||||
lexer.skip_whitespace()
|
||||
self.assertEqual(lexer.pos, original_pos)
|
||||
self.assertEqual(lexer.current_char, "a")
|
||||
|
||||
def test_read_string_double_quotes(self):
|
||||
"""Test reading double-quoted string"""
|
||||
lexer = SCIMPathLexer('"hello world"')
|
||||
result = lexer.read_string('"')
|
||||
self.assertEqual(result, "hello world")
|
||||
self.assertIsNone(lexer.current_char) # Should be at end
|
||||
|
||||
def test_read_string_single_quotes(self):
|
||||
"""Test reading single-quoted string"""
|
||||
lexer = SCIMPathLexer("'hello world'")
|
||||
result = lexer.read_string("'")
|
||||
self.assertEqual(result, "hello world")
|
||||
self.assertIsNone(lexer.current_char)
|
||||
|
||||
def test_read_string_with_escapes(self):
|
||||
"""Test reading string with escape characters"""
|
||||
lexer = SCIMPathLexer('"hello \\"world\\""')
|
||||
result = lexer.read_string('"')
|
||||
self.assertEqual(result, 'hello "world"')
|
||||
|
||||
def test_read_string_with_backslash_at_end(self):
|
||||
"""Test reading string with backslash at end"""
|
||||
lexer = SCIMPathLexer('"hello\\"')
|
||||
result = lexer.read_string('"')
|
||||
self.assertEqual(result, 'hello"')
|
||||
|
||||
def test_read_string_unclosed(self):
|
||||
"""Test reading unclosed string"""
|
||||
lexer = SCIMPathLexer('"hello world')
|
||||
result = lexer.read_string('"')
|
||||
self.assertEqual(result, "hello world")
|
||||
self.assertIsNone(lexer.current_char)
|
||||
|
||||
def test_read_string_empty(self):
|
||||
"""Test reading empty string"""
|
||||
lexer = SCIMPathLexer('""')
|
||||
result = lexer.read_string('"')
|
||||
self.assertEqual(result, "")
|
||||
|
||||
def test_read_number_integer(self):
|
||||
"""Test reading integer number"""
|
||||
lexer = SCIMPathLexer("123")
|
||||
result = lexer.read_number()
|
||||
self.assertEqual(result, "123")
|
||||
self.assertIsNone(lexer.current_char)
|
||||
|
||||
def test_read_number_float(self):
|
||||
"""Test reading float number"""
|
||||
lexer = SCIMPathLexer("123.456")
|
||||
result = lexer.read_number()
|
||||
self.assertEqual(result, "123.456")
|
||||
self.assertIsNone(lexer.current_char)
|
||||
|
||||
def test_read_number_with_multiple_dots(self):
|
||||
"""Test reading number with multiple dots (invalid but handled)"""
|
||||
lexer = SCIMPathLexer("123.456.789")
|
||||
result = lexer.read_number()
|
||||
self.assertEqual(result, "123.456.789")
|
||||
self.assertIsNone(lexer.current_char)
|
||||
|
||||
def test_read_number_starting_with_dot(self):
|
||||
"""Test reading number starting with dot"""
|
||||
lexer = SCIMPathLexer(".123")
|
||||
result = lexer.read_number()
|
||||
self.assertEqual(result, ".123")
|
||||
|
||||
def test_read_identifier_simple(self):
|
||||
"""Test reading simple identifier"""
|
||||
lexer = SCIMPathLexer("userName")
|
||||
result = lexer.read_identifier()
|
||||
self.assertEqual(result, "userName")
|
||||
self.assertIsNone(lexer.current_char)
|
||||
|
||||
def test_read_identifier_with_underscore(self):
|
||||
"""Test reading identifier with underscore"""
|
||||
lexer = SCIMPathLexer("user_name")
|
||||
result = lexer.read_identifier()
|
||||
self.assertEqual(result, "user_name")
|
||||
|
||||
def test_read_identifier_with_hyphen(self):
|
||||
"""Test reading identifier with hyphen"""
|
||||
lexer = SCIMPathLexer("user-name")
|
||||
result = lexer.read_identifier()
|
||||
self.assertEqual(result, "user-name")
|
||||
|
||||
def test_read_identifier_with_colon(self):
|
||||
"""Test reading identifier with colon (URN format)"""
|
||||
lexer = SCIMPathLexer("urn:ietf:params:scim:schemas:core:2.0:User")
|
||||
result = lexer.read_identifier()
|
||||
self.assertEqual(result, "urn:ietf:params:scim:schemas:core:2.0:User")
|
||||
|
||||
def test_read_identifier_schema_urn(self):
|
||||
"""Test reading schema URN identifier"""
|
||||
lexer = SCIMPathLexer(f"{SCIM_URN_USER}.userName")
|
||||
result = lexer.read_identifier()
|
||||
self.assertEqual(result, SCIM_URN_USER)
|
||||
self.assertEqual(lexer.current_char, ".") # Should stop at dot and set current_char to dot
|
||||
|
||||
def test_read_identifier_with_version_number(self):
|
||||
"""Test reading identifier with version number (dots followed by digits)"""
|
||||
lexer = SCIMPathLexer("urn:ietf:params:scim:schemas:core:2.0:User")
|
||||
result = lexer.read_identifier()
|
||||
self.assertEqual(result, "urn:ietf:params:scim:schemas:core:2.0:User")
|
||||
|
||||
def test_read_identifier_partial_urn_match(self):
|
||||
"""Test reading identifier that partially matches URN"""
|
||||
lexer = SCIMPathLexer("urn:ietf:params:scim:schemas:core:2.0:CustomUser")
|
||||
result = lexer.read_identifier()
|
||||
self.assertEqual(result, "urn:ietf:params:scim:schemas:core:2.0:CustomUser")
|
||||
|
||||
# Test get_next_token method
|
||||
def test_get_next_token_dot(self):
|
||||
"""Test tokenizing dot"""
|
||||
lexer = SCIMPathLexer(".")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.DOT)
|
||||
self.assertEqual(token.value, ".")
|
||||
|
||||
def test_get_next_token_lbracket(self):
|
||||
"""Test tokenizing left bracket"""
|
||||
lexer = SCIMPathLexer("[")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.LBRACKET)
|
||||
self.assertEqual(token.value, "[")
|
||||
|
||||
def test_get_next_token_rbracket(self):
|
||||
"""Test tokenizing right bracket"""
|
||||
lexer = SCIMPathLexer("]")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.RBRACKET)
|
||||
self.assertEqual(token.value, "]")
|
||||
|
||||
def test_get_next_token_lparen(self):
|
||||
"""Test tokenizing left parenthesis"""
|
||||
lexer = SCIMPathLexer("(")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.LPAREN)
|
||||
self.assertEqual(token.value, "(")
|
||||
|
||||
def test_get_next_token_rparen(self):
|
||||
"""Test tokenizing right parenthesis"""
|
||||
lexer = SCIMPathLexer(")")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.RPAREN)
|
||||
self.assertEqual(token.value, ")")
|
||||
|
||||
def test_get_next_token_string_double_quotes(self):
|
||||
"""Test tokenizing double-quoted string"""
|
||||
lexer = SCIMPathLexer('"test string"')
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.STRING)
|
||||
self.assertEqual(token.value, "test string")
|
||||
|
||||
def test_get_next_token_string_single_quotes(self):
|
||||
"""Test tokenizing single-quoted string"""
|
||||
lexer = SCIMPathLexer("'test string'")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.STRING)
|
||||
self.assertEqual(token.value, "test string")
|
||||
|
||||
def test_get_next_token_number_integer(self):
|
||||
"""Test tokenizing integer"""
|
||||
lexer = SCIMPathLexer("123")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.NUMBER)
|
||||
self.assertEqual(token.value, "123")
|
||||
|
||||
def test_get_next_token_number_float(self):
|
||||
"""Test tokenizing float"""
|
||||
lexer = SCIMPathLexer("123.45")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.NUMBER)
|
||||
self.assertEqual(token.value, "123.45")
|
||||
|
||||
def test_get_next_token_boolean_true(self):
|
||||
"""Test tokenizing boolean true"""
|
||||
lexer = SCIMPathLexer("true")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.BOOLEAN)
|
||||
self.assertTrue(token.value)
|
||||
|
||||
def test_get_next_token_boolean_false(self):
|
||||
"""Test tokenizing boolean false"""
|
||||
lexer = SCIMPathLexer("false")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.BOOLEAN)
|
||||
self.assertFalse(token.value)
|
||||
|
||||
def test_get_next_token_boolean_case_insensitive(self):
|
||||
"""Test tokenizing boolean with different cases"""
|
||||
for value in ["TRUE", "True", "FALSE", "False"]:
|
||||
with self.subTest(value=value):
|
||||
lexer = SCIMPathLexer(value)
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.BOOLEAN)
|
||||
|
||||
def test_get_next_token_null(self):
|
||||
"""Test tokenizing null"""
|
||||
lexer = SCIMPathLexer("null")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.NULL)
|
||||
self.assertIsNone(token.value)
|
||||
|
||||
def test_get_next_token_null_case_insensitive(self):
|
||||
"""Test tokenizing null with different cases"""
|
||||
for value in ["NULL", "Null"]:
|
||||
with self.subTest(value=value):
|
||||
lexer = SCIMPathLexer(value)
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.NULL)
|
||||
|
||||
def test_get_next_token_and(self):
|
||||
"""Test tokenizing AND operator"""
|
||||
lexer = SCIMPathLexer("and")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.AND)
|
||||
self.assertEqual(token.value, "and")
|
||||
|
||||
def test_get_next_token_or(self):
|
||||
"""Test tokenizing OR operator"""
|
||||
lexer = SCIMPathLexer("or")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.OR)
|
||||
self.assertEqual(token.value, "or")
|
||||
|
||||
def test_get_next_token_not(self):
|
||||
"""Test tokenizing NOT operator"""
|
||||
lexer = SCIMPathLexer("not")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.NOT)
|
||||
self.assertEqual(token.value, "not")
|
||||
|
||||
def test_get_next_token_operators(self):
|
||||
"""Test tokenizing all comparison operators"""
|
||||
operators = ["eq", "ne", "co", "sw", "ew", "gt", "lt", "ge", "le", "pr"]
|
||||
for op in operators:
|
||||
with self.subTest(operator=op):
|
||||
lexer = SCIMPathLexer(op)
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.OPERATOR)
|
||||
self.assertEqual(token.value, op)
|
||||
|
||||
def test_get_next_token_operators_case_insensitive(self):
|
||||
"""Test tokenizing operators with different cases"""
|
||||
for op in ["EQ", "Eq", "NE", "Ne"]:
|
||||
with self.subTest(operator=op):
|
||||
lexer = SCIMPathLexer(op)
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.OPERATOR)
|
||||
self.assertEqual(token.value, op.lower())
|
||||
|
||||
def test_get_next_token_attribute(self):
|
||||
"""Test tokenizing attribute name"""
|
||||
lexer = SCIMPathLexer("userName")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.ATTRIBUTE)
|
||||
self.assertEqual(token.value, "userName")
|
||||
|
||||
def test_get_next_token_attribute_with_underscore(self):
|
||||
"""Test tokenizing attribute name with underscore"""
|
||||
lexer = SCIMPathLexer("_userName")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.ATTRIBUTE)
|
||||
self.assertEqual(token.value, "_userName")
|
||||
|
||||
def test_get_next_token_eof(self):
|
||||
"""Test tokenizing end of file"""
|
||||
lexer = SCIMPathLexer("")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.EOF)
|
||||
self.assertEqual(token.value, "")
|
||||
|
||||
def test_get_next_token_with_whitespace(self):
|
||||
"""Test tokenizing with leading whitespace"""
|
||||
lexer = SCIMPathLexer(" userName")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.ATTRIBUTE)
|
||||
self.assertEqual(token.value, "userName")
|
||||
|
||||
def test_get_next_token_skip_unknown_characters(self):
|
||||
"""Test that unknown characters are skipped"""
|
||||
lexer = SCIMPathLexer("@#$userName")
|
||||
token = lexer.get_next_token()
|
||||
self.assertEqual(token.type, TokenType.ATTRIBUTE)
|
||||
self.assertEqual(token.value, "userName")
|
||||
|
||||
def test_get_next_token_multiple_tokens(self):
|
||||
"""Test tokenizing multiple tokens in sequence"""
|
||||
lexer = SCIMPathLexer("userName.givenName")
|
||||
|
||||
token1 = lexer.get_next_token()
|
||||
self.assertEqual(token1.type, TokenType.ATTRIBUTE)
|
||||
self.assertEqual(token1.value, "userName")
|
||||
|
||||
token2 = lexer.get_next_token()
|
||||
self.assertEqual(token2.type, TokenType.DOT)
|
||||
self.assertEqual(token2.value, ".")
|
||||
|
||||
token3 = lexer.get_next_token()
|
||||
self.assertEqual(token3.type, TokenType.ATTRIBUTE)
|
||||
self.assertEqual(token3.value, "givenName")
|
||||
|
||||
token4 = lexer.get_next_token()
|
||||
self.assertEqual(token4.type, TokenType.EOF)
|
||||
|
||||
def test_get_next_token_complex_filter(self):
|
||||
"""Test tokenizing complex filter expression"""
|
||||
lexer = SCIMPathLexer('emails[type eq "work" and primary eq true]')
|
||||
|
||||
tokens = []
|
||||
while True:
|
||||
token = lexer.get_next_token()
|
||||
tokens.append(token)
|
||||
if token.type == TokenType.EOF:
|
||||
break
|
||||
|
||||
expected_types = [
|
||||
TokenType.ATTRIBUTE, # emails
|
||||
TokenType.LBRACKET, # [
|
||||
TokenType.ATTRIBUTE, # type
|
||||
TokenType.OPERATOR, # eq
|
||||
TokenType.STRING, # "work"
|
||||
TokenType.AND, # and
|
||||
TokenType.ATTRIBUTE, # primary
|
||||
TokenType.OPERATOR, # eq
|
||||
TokenType.BOOLEAN, # true
|
||||
TokenType.RBRACKET, # ]
|
||||
TokenType.EOF,
|
||||
]
|
||||
|
||||
self.assertEqual(len(tokens), len(expected_types))
|
||||
for token, expected_type in zip(tokens, expected_types, strict=False):
|
||||
self.assertEqual(token.type, expected_type)
|
||||
|
||||
def test_get_next_token_urn_attribute(self):
|
||||
"""Test tokenizing URN-based attribute"""
|
||||
lexer = SCIMPathLexer(f"{SCIM_URN_USER}.userName")
|
||||
|
||||
token1 = lexer.get_next_token()
|
||||
self.assertEqual(token1.type, TokenType.ATTRIBUTE)
|
||||
self.assertEqual(token1.value, SCIM_URN_USER)
|
||||
|
||||
token2 = lexer.get_next_token()
|
||||
self.assertEqual(token2.type, TokenType.DOT)
|
||||
|
||||
token3 = lexer.get_next_token()
|
||||
self.assertEqual(token3.type, TokenType.ATTRIBUTE)
|
||||
self.assertEqual(token3.value, "userName")
|
||||
|
||||
def test_get_next_token_enterprise_urn(self):
|
||||
"""Test tokenizing enterprise URN"""
|
||||
lexer = SCIMPathLexer(f"{SCIM_URN_USER_ENTERPRISE}.manager")
|
||||
|
||||
token1 = lexer.get_next_token()
|
||||
self.assertEqual(token1.type, TokenType.ATTRIBUTE)
|
||||
self.assertEqual(token1.value, SCIM_URN_USER_ENTERPRISE)
|
||||
|
||||
token2 = lexer.get_next_token()
|
||||
self.assertEqual(token2.type, TokenType.DOT)
|
||||
|
||||
def test_lexer_state_after_eof(self):
|
||||
"""Test lexer state after reaching EOF"""
|
||||
lexer = SCIMPathLexer("a")
|
||||
|
||||
# Get first token
|
||||
token1 = lexer.get_next_token()
|
||||
self.assertEqual(token1.type, TokenType.ATTRIBUTE)
|
||||
|
||||
# Get EOF token
|
||||
token2 = lexer.get_next_token()
|
||||
self.assertEqual(token2.type, TokenType.EOF)
|
||||
|
||||
# Should continue returning EOF
|
||||
token3 = lexer.get_next_token()
|
||||
self.assertEqual(token3.type, TokenType.EOF)
|
||||
|
||||
def test_read_identifier_edge_cases(self):
|
||||
"""Test read_identifier with edge cases"""
|
||||
# Test identifier ending with colon
|
||||
lexer = SCIMPathLexer("test:")
|
||||
result = lexer.read_identifier()
|
||||
self.assertEqual(result, "test:")
|
||||
|
||||
# Test identifier with numbers
|
||||
lexer = SCIMPathLexer("test123")
|
||||
result = lexer.read_identifier()
|
||||
self.assertEqual(result, "test123")
|
||||
|
||||
def test_complex_urn_parsing(self):
|
||||
"""Test parsing complex URN with version numbers"""
|
||||
urn = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
|
||||
lexer = SCIMPathLexer(urn)
|
||||
result = lexer.read_identifier()
|
||||
self.assertEqual(result, urn)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ from authentik.core.tests.utils import create_test_user
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.scim.clients.schema import User as SCIMUserSchema
|
||||
from authentik.sources.scim.constants import SCIM_URN_USER_ENTERPRISE
|
||||
from authentik.sources.scim.models import SCIMSource, SCIMSourcePropertyMapping, SCIMSourceUser
|
||||
from authentik.sources.scim.views.v2.base import SCIM_CONTENT_TYPE
|
||||
|
||||
@@ -81,7 +82,9 @@ class TestSCIMUsers(APITestCase):
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(SCIMSourceUser.objects.filter(source=self.source, id=ext_id).exists())
|
||||
self.assertTrue(
|
||||
SCIMSourceUser.objects.filter(source=self.source, external_id=ext_id).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
Event.objects.filter(
|
||||
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
|
||||
@@ -174,14 +177,16 @@ class TestSCIMUsers(APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(
|
||||
SCIMSourceUser.objects.get(source=self.source, id=ext_id).user.attributes["phone"],
|
||||
SCIMSourceUser.objects.get(source=self.source, external_id=ext_id).user.attributes[
|
||||
"phone"
|
||||
],
|
||||
"0123456789",
|
||||
)
|
||||
|
||||
def test_user_update(self):
|
||||
"""Test user update"""
|
||||
user = create_test_user()
|
||||
existing = SCIMSourceUser.objects.create(source=self.source, user=user, id=uuid4())
|
||||
existing = SCIMSourceUser.objects.create(source=self.source, user=user, external_id=uuid4())
|
||||
ext_id = generate_id()
|
||||
response = self.client.put(
|
||||
reverse(
|
||||
@@ -209,10 +214,51 @@ class TestSCIMUsers(APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_user_update_patch(self):
|
||||
"""Test user update (patch)"""
|
||||
user = create_test_user()
|
||||
existing = SCIMSourceUser.objects.create(
|
||||
source=self.source,
|
||||
user=user,
|
||||
external_id=uuid4(),
|
||||
attributes={
|
||||
"userName": generate_id(),
|
||||
},
|
||||
)
|
||||
response = self.client.patch(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-users",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
"user_id": str(user.uuid),
|
||||
},
|
||||
),
|
||||
data=dumps(
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{
|
||||
"op": "Add",
|
||||
"path": f"{SCIM_URN_USER_ENTERPRISE}:manager",
|
||||
"value": "86b2ed3e-30cd-4881-bb58-c4e910821339",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
existing.refresh_from_db()
|
||||
self.assertEqual(
|
||||
existing.attributes[SCIM_URN_USER_ENTERPRISE],
|
||||
{"manager": {"value": "86b2ed3e-30cd-4881-bb58-c4e910821339"}},
|
||||
)
|
||||
|
||||
def test_user_delete(self):
|
||||
"""Test user delete"""
|
||||
user = create_test_user()
|
||||
SCIMSourceUser.objects.create(source=self.source, user=user, id=uuid4())
|
||||
SCIMSourceUser.objects.create(source=self.source, user=user, external_id=uuid4())
|
||||
response = self.client.delete(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-users",
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.sources.scim.constants import SCIM_URN_USER_ENTERPRISE
|
||||
from authentik.sources.scim.models import SCIMSource, SCIMSourceUser
|
||||
from authentik.sources.scim.patch.processor import SCIMPatchProcessor
|
||||
|
||||
|
||||
class TestSCIMUsersPatch(APITestCase):
|
||||
"""Test SCIM User Patch"""
|
||||
|
||||
def test_add(self):
|
||||
req = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{"op": "Add", "path": "name.givenName", "value": "aqwer"},
|
||||
{"op": "Add", "path": "name.familyName", "value": "qwerqqqq"},
|
||||
{"op": "Add", "path": "name.formatted", "value": "aqwer qwerqqqq"},
|
||||
],
|
||||
}
|
||||
user = create_test_user()
|
||||
source = SCIMSource.objects.create(slug=generate_id())
|
||||
connection = SCIMSourceUser.objects.create(
|
||||
user=user,
|
||||
id=generate_id(),
|
||||
source=source,
|
||||
attributes={
|
||||
"meta": {"resourceType": "User"},
|
||||
"active": True,
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
SCIM_URN_USER_ENTERPRISE,
|
||||
],
|
||||
"userName": "test@t.goauthentik.io",
|
||||
"externalId": "test",
|
||||
"displayName": "Test MS",
|
||||
},
|
||||
)
|
||||
updated = SCIMPatchProcessor().apply_patches(connection.attributes, req["Operations"])
|
||||
self.assertEqual(
|
||||
updated,
|
||||
{
|
||||
"meta": {"resourceType": "User"},
|
||||
"active": True,
|
||||
"name": {
|
||||
"givenName": "aqwer",
|
||||
"familyName": "qwerqqqq",
|
||||
"formatted": "aqwer qwerqqqq",
|
||||
},
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
SCIM_URN_USER_ENTERPRISE,
|
||||
],
|
||||
"userName": "test@t.goauthentik.io",
|
||||
"externalId": "test",
|
||||
"displayName": "Test MS",
|
||||
},
|
||||
)
|
||||
|
||||
def test_add_no_path(self):
|
||||
"""Test add patch with no path set"""
|
||||
req = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{"op": "Add", "value": {"externalId": "aqwer"}},
|
||||
],
|
||||
}
|
||||
user = create_test_user()
|
||||
source = SCIMSource.objects.create(slug=generate_id())
|
||||
connection = SCIMSourceUser.objects.create(
|
||||
user=user,
|
||||
id=generate_id(),
|
||||
source=source,
|
||||
attributes={
|
||||
"meta": {"resourceType": "User"},
|
||||
"active": True,
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
SCIM_URN_USER_ENTERPRISE,
|
||||
],
|
||||
"userName": "test@t.goauthentik.io",
|
||||
"displayName": "Test MS",
|
||||
},
|
||||
)
|
||||
updated = SCIMPatchProcessor().apply_patches(connection.attributes, req["Operations"])
|
||||
self.assertEqual(
|
||||
updated,
|
||||
{
|
||||
"meta": {"resourceType": "User"},
|
||||
"active": True,
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
SCIM_URN_USER_ENTERPRISE,
|
||||
],
|
||||
"userName": "test@t.goauthentik.io",
|
||||
"externalId": "aqwer",
|
||||
"displayName": "Test MS",
|
||||
},
|
||||
)
|
||||
|
||||
def test_replace(self):
|
||||
req = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{"op": "Replace", "path": "name", "value": {"givenName": "aqwer"}},
|
||||
],
|
||||
}
|
||||
user = create_test_user()
|
||||
source = SCIMSource.objects.create(slug=generate_id())
|
||||
connection = SCIMSourceUser.objects.create(
|
||||
user=user,
|
||||
id=generate_id(),
|
||||
source=source,
|
||||
attributes={
|
||||
"meta": {"resourceType": "User"},
|
||||
"active": True,
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
SCIM_URN_USER_ENTERPRISE,
|
||||
],
|
||||
"userName": "test@t.goauthentik.io",
|
||||
"externalId": "test",
|
||||
"displayName": "Test MS",
|
||||
},
|
||||
)
|
||||
updated = SCIMPatchProcessor().apply_patches(connection.attributes, req["Operations"])
|
||||
self.assertEqual(
|
||||
updated,
|
||||
{
|
||||
"meta": {"resourceType": "User"},
|
||||
"active": True,
|
||||
"name": {
|
||||
"givenName": "aqwer",
|
||||
},
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
SCIM_URN_USER_ENTERPRISE,
|
||||
],
|
||||
"userName": "test@t.goauthentik.io",
|
||||
"externalId": "test",
|
||||
"displayName": "Test MS",
|
||||
},
|
||||
)
|
||||
|
||||
def test_replace_no_path(self):
|
||||
"""Test value replace with no path"""
|
||||
req = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{"op": "Replace", "value": {"externalId": "aqwer"}},
|
||||
],
|
||||
}
|
||||
user = create_test_user()
|
||||
source = SCIMSource.objects.create(slug=generate_id())
|
||||
connection = SCIMSourceUser.objects.create(
|
||||
user=user,
|
||||
id=generate_id(),
|
||||
source=source,
|
||||
attributes={
|
||||
"meta": {"resourceType": "User"},
|
||||
"active": True,
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
SCIM_URN_USER_ENTERPRISE,
|
||||
],
|
||||
"userName": "test@t.goauthentik.io",
|
||||
"externalId": "test",
|
||||
"displayName": "Test MS",
|
||||
},
|
||||
)
|
||||
updated = SCIMPatchProcessor().apply_patches(connection.attributes, req["Operations"])
|
||||
self.assertEqual(
|
||||
updated,
|
||||
{
|
||||
"meta": {"resourceType": "User"},
|
||||
"active": True,
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
SCIM_URN_USER_ENTERPRISE,
|
||||
],
|
||||
"userName": "test@t.goauthentik.io",
|
||||
"externalId": "aqwer",
|
||||
"displayName": "Test MS",
|
||||
},
|
||||
)
|
||||
|
||||
def test_remove(self):
|
||||
req = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{"op": "Remove", "path": "name", "value": {"givenName": "aqwer"}},
|
||||
],
|
||||
}
|
||||
user = create_test_user()
|
||||
source = SCIMSource.objects.create(slug=generate_id())
|
||||
connection = SCIMSourceUser.objects.create(
|
||||
user=user,
|
||||
id=generate_id(),
|
||||
source=source,
|
||||
attributes={
|
||||
"meta": {"resourceType": "User"},
|
||||
"active": True,
|
||||
"name": {
|
||||
"givenName": "aqwer",
|
||||
},
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
SCIM_URN_USER_ENTERPRISE,
|
||||
],
|
||||
"userName": "test@t.goauthentik.io",
|
||||
"externalId": "test",
|
||||
"displayName": "Test MS",
|
||||
},
|
||||
)
|
||||
updated = SCIMPatchProcessor().apply_patches(connection.attributes, req["Operations"])
|
||||
self.assertEqual(
|
||||
updated,
|
||||
{
|
||||
"meta": {"resourceType": "User"},
|
||||
"active": True,
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
SCIM_URN_USER_ENTERPRISE,
|
||||
],
|
||||
"userName": "test@t.goauthentik.io",
|
||||
"externalId": "test",
|
||||
"displayName": "Test MS",
|
||||
},
|
||||
)
|
||||
|
||||
def test_large(self):
|
||||
"""Large amount of patch operations"""
|
||||
req = {
|
||||
"Operations": [
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "emails[primary eq true].value",
|
||||
"value": "dandre_kling@wintheiser.info",
|
||||
},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "phoneNumbers[primary eq true].value",
|
||||
"value": "72-634-1548",
|
||||
},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "phoneNumbers[primary eq true].display",
|
||||
"value": "72-634-1548",
|
||||
},
|
||||
{"op": "replace", "path": "ims[primary eq true].value", "value": "GXSGJKWGHVVS"},
|
||||
{"op": "replace", "path": "ims[primary eq true].display", "value": "IMCHDKUQIPYB"},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "photos[primary eq true].display",
|
||||
"value": "TWAWLHHSUNIV",
|
||||
},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "addresses[primary eq true].formatted",
|
||||
"value": "TMINZQAJQDCL",
|
||||
},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "addresses[primary eq true].streetAddress",
|
||||
"value": "081 Wisoky Key",
|
||||
},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "addresses[primary eq true].locality",
|
||||
"value": "DPFASBZRPMDP",
|
||||
},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "addresses[primary eq true].region",
|
||||
"value": "WHSTJSPIPTCF",
|
||||
},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "addresses[primary eq true].postalCode",
|
||||
"value": "ko28 1qa",
|
||||
},
|
||||
{"op": "replace", "path": "addresses[primary eq true].country", "value": "Taiwan"},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "entitlements[primary eq true].value",
|
||||
"value": "NGBJMUYZVVBX",
|
||||
},
|
||||
{"op": "replace", "path": "roles[primary eq true].value", "value": "XEELVFMMWCVM"},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "x509Certificates[primary eq true].value",
|
||||
"value": "UYISMEDOXUZY",
|
||||
},
|
||||
{
|
||||
"op": "replace",
|
||||
"value": {
|
||||
"externalId": "7faaefb0-0774-4d8e-8f6d-863c361bc72c",
|
||||
"name.formatted": "Dell",
|
||||
"name.familyName": "Gay",
|
||||
"name.givenName": "Kyler",
|
||||
"name.middleName": "Hannah",
|
||||
"name.honorificPrefix": "Cassie",
|
||||
"name.honorificSuffix": "Yolanda",
|
||||
"displayName": "DPRLIJSFQMTL",
|
||||
"nickName": "BKSPMIRMFBTI",
|
||||
"title": "NBZCOAXVYJUY",
|
||||
"userType": "ZGJMYZRUORZE",
|
||||
"preferredLanguage": "as-IN",
|
||||
"locale": "JLOJHLPWZODG",
|
||||
"timezone": "America/Argentina/Rio_Gallegos",
|
||||
"active": True,
|
||||
f"{SCIM_URN_USER_ENTERPRISE}:employeeNumber": "PDFWRRZBQOHB",
|
||||
f"{SCIM_URN_USER_ENTERPRISE}:costCenter": "HACMZWSEDOTQ",
|
||||
f"{SCIM_URN_USER_ENTERPRISE}:organization": "LXVHJUOLNCLS",
|
||||
f"{SCIM_URN_USER_ENTERPRISE}:division": "JASVTPKPBPMG",
|
||||
f"{SCIM_URN_USER_ENTERPRISE}:department": "GMSBFLMNPABY",
|
||||
},
|
||||
},
|
||||
],
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
}
|
||||
user = create_test_user()
|
||||
source = SCIMSource.objects.create(slug=generate_id())
|
||||
connection = SCIMSourceUser.objects.create(
|
||||
user=user,
|
||||
id=generate_id(),
|
||||
source=source,
|
||||
attributes={
|
||||
"active": True,
|
||||
"addresses": [
|
||||
{
|
||||
"primary": "true",
|
||||
"formatted": "BLJMCNXHYLZK",
|
||||
"streetAddress": "7801 Jacobs Fork",
|
||||
"locality": "HZJBJWFAKXDD",
|
||||
"region": "GJXCXPMIIKWK",
|
||||
"postalCode": "pv82 8ua",
|
||||
"country": "India",
|
||||
}
|
||||
],
|
||||
"displayName": "KEFXCHKHAFOT",
|
||||
"emails": [{"primary": "true", "value": "scot@zemlak.uk"}],
|
||||
"entitlements": [{"primary": "true", "value": "FTTUXWYDAAQC"}],
|
||||
"externalId": "448d2786-7bf6-4e03-a4ef-64cbaf162fa7",
|
||||
"ims": [{"primary": "true", "value": "IGWZUUMCMKXS", "display": "PJVGMMKYYHRU"}],
|
||||
"locale": "PJNYJHWJILTI",
|
||||
"name": {
|
||||
"formatted": "Ladarius",
|
||||
"familyName": "Manley",
|
||||
"givenName": "Mazie",
|
||||
"middleName": "Vernon",
|
||||
"honorificPrefix": "Melyssa",
|
||||
"honorificSuffix": "Demarcus",
|
||||
},
|
||||
"nickName": "HTPKOXMWZKHL",
|
||||
"phoneNumbers": [
|
||||
{"primary": "true", "value": "50-608-7660", "display": "50-608-7660"}
|
||||
],
|
||||
"photos": [{"primary": "true", "display": "KCONLNLSYTBP"}],
|
||||
"preferredLanguage": "wae",
|
||||
"profileUrl": "HPSEOIPXMGOH",
|
||||
"roles": [{"primary": "true", "value": "TLGYITOIZGKP"}],
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
SCIM_URN_USER_ENTERPRISE,
|
||||
],
|
||||
"timezone": "America/Indiana/Petersburg",
|
||||
"title": "EJWFXLHNHMCD",
|
||||
SCIM_URN_USER_ENTERPRISE: {
|
||||
"employeeNumber": "XHDMEJUURJNR",
|
||||
"costCenter": "RXUYBXOTRCZH",
|
||||
"organization": "CEXWXMBRYAHN",
|
||||
"division": "XMPFMDCLRKCW",
|
||||
"department": "BKMNJVMCJUYS",
|
||||
"manager": "PNGSGXLYVWMV",
|
||||
},
|
||||
"userName": "imelda.auer@kshlerin.co.uk",
|
||||
"userType": "PZFXORVSUAPU",
|
||||
"x509Certificates": [{"primary": "true", "value": "KOVKWGIVVEHH"}],
|
||||
},
|
||||
)
|
||||
updated = SCIMPatchProcessor().apply_patches(connection.attributes, req["Operations"])
|
||||
self.assertEqual(
|
||||
updated,
|
||||
{
|
||||
"active": True,
|
||||
"addresses": [
|
||||
{
|
||||
"primary": "true",
|
||||
"formatted": "BLJMCNXHYLZK",
|
||||
"streetAddress": "7801 Jacobs Fork",
|
||||
"locality": "HZJBJWFAKXDD",
|
||||
"region": "GJXCXPMIIKWK",
|
||||
"postalCode": "pv82 8ua",
|
||||
"country": "India",
|
||||
}
|
||||
],
|
||||
"displayName": "DPRLIJSFQMTL",
|
||||
"emails": [{"primary": "true", "value": "scot@zemlak.uk"}],
|
||||
"entitlements": [{"primary": "true", "value": "FTTUXWYDAAQC"}],
|
||||
"externalId": "7faaefb0-0774-4d8e-8f6d-863c361bc72c",
|
||||
"ims": [{"primary": "true", "value": "IGWZUUMCMKXS", "display": "PJVGMMKYYHRU"}],
|
||||
"locale": "JLOJHLPWZODG",
|
||||
"name": {
|
||||
"formatted": "Dell",
|
||||
"familyName": "Gay",
|
||||
"givenName": "Kyler",
|
||||
"middleName": "Hannah",
|
||||
"honorificPrefix": "Cassie",
|
||||
"honorificSuffix": "Yolanda",
|
||||
},
|
||||
"nickName": "BKSPMIRMFBTI",
|
||||
"phoneNumbers": [
|
||||
{"primary": "true", "value": "50-608-7660", "display": "50-608-7660"}
|
||||
],
|
||||
"photos": [{"primary": "true", "display": "KCONLNLSYTBP"}],
|
||||
"preferredLanguage": "as-IN",
|
||||
"profileUrl": "HPSEOIPXMGOH",
|
||||
"roles": [{"primary": "true", "value": "TLGYITOIZGKP"}],
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
SCIM_URN_USER_ENTERPRISE,
|
||||
],
|
||||
"timezone": "America/Argentina/Rio_Gallegos",
|
||||
"title": "NBZCOAXVYJUY",
|
||||
SCIM_URN_USER_ENTERPRISE: {
|
||||
"employeeNumber": "PDFWRRZBQOHB",
|
||||
"costCenter": "HACMZWSEDOTQ",
|
||||
"organization": "LXVHJUOLNCLS",
|
||||
"division": "JASVTPKPBPMG",
|
||||
"department": "GMSBFLMNPABY",
|
||||
"manager": "PNGSGXLYVWMV",
|
||||
},
|
||||
"userName": "imelda.auer@kshlerin.co.uk",
|
||||
"userType": "ZGJMYZRUORZE",
|
||||
"x509Certificates": [{"primary": "true", "value": "KOVKWGIVVEHH"}],
|
||||
},
|
||||
)
|
||||
|
||||
def test_schema_urn_manager(self):
|
||||
req = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{
|
||||
"op": "Add",
|
||||
"value": {
|
||||
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager": "foo"
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
user = create_test_user()
|
||||
source = SCIMSource.objects.create(slug=generate_id())
|
||||
connection = SCIMSourceUser.objects.create(
|
||||
user=user,
|
||||
id=generate_id(),
|
||||
source=source,
|
||||
attributes={
|
||||
"meta": {"resourceType": "User"},
|
||||
"active": True,
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
SCIM_URN_USER_ENTERPRISE,
|
||||
],
|
||||
"userName": "test@t.goauthentik.io",
|
||||
"externalId": "test",
|
||||
"displayName": "Test MS",
|
||||
},
|
||||
)
|
||||
updated = SCIMPatchProcessor().apply_patches(connection.attributes, req["Operations"])
|
||||
self.assertEqual(
|
||||
updated,
|
||||
{
|
||||
"meta": {"resourceType": "User"},
|
||||
"active": True,
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
SCIM_URN_USER_ENTERPRISE,
|
||||
],
|
||||
"userName": "test@t.goauthentik.io",
|
||||
"externalId": "test",
|
||||
"displayName": "Test MS",
|
||||
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
|
||||
"manager": {"value": "foo"}
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""SCIM Utils"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.paginator import Page, Paginator
|
||||
@@ -21,6 +22,7 @@ from authentik.core.sources.mapper import SourceMapper
|
||||
from authentik.lib.sync.mapper import PropertyMappingManager
|
||||
from authentik.sources.scim.models import SCIMSource
|
||||
from authentik.sources.scim.views.v2.auth import SCIMTokenAuth
|
||||
from authentik.sources.scim.views.v2.exceptions import SCIMNotFoundError
|
||||
|
||||
SCIM_CONTENT_TYPE = "application/scim+json"
|
||||
|
||||
@@ -54,6 +56,13 @@ class SCIMView(APIView):
|
||||
def get_authenticators(self):
|
||||
return [SCIMTokenAuth(self)]
|
||||
|
||||
def remove_excluded_attributes(self, data: dict):
|
||||
"""Remove attributes specified in excludedAttributes"""
|
||||
excluded: str = self.request.query_params.get("excludedAttributes", "")
|
||||
for key in excluded.split(","):
|
||||
data.pop(key.strip(), None)
|
||||
return data
|
||||
|
||||
def filter_parse(self, request: Request):
|
||||
"""Parse the path of a Patch Operation"""
|
||||
path = request.query_params.get("filter")
|
||||
@@ -103,6 +112,12 @@ class SCIMObjectView(SCIMView):
|
||||
# a source attribute before
|
||||
self.mapper = SourceMapper(self.source)
|
||||
self.manager = self.mapper.get_manager(self.model, ["data"])
|
||||
for key, value in kwargs.items():
|
||||
if key.endswith("_id"):
|
||||
try:
|
||||
UUID(value)
|
||||
except ValueError:
|
||||
raise SCIMNotFoundError("Invalid ID") from None
|
||||
|
||||
def build_object_properties(self, data: dict[str, Any]) -> dict[str, Any | dict[str, Any]]:
|
||||
return self.mapper.build_object_properties(
|
||||
|
||||
@@ -17,6 +17,7 @@ from authentik.core.models import Group, User
|
||||
from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOp, PatchOperation
|
||||
from authentik.providers.scim.clients.schema import Group as SCIMGroupModel
|
||||
from authentik.sources.scim.models import SCIMSourceGroup
|
||||
from authentik.sources.scim.patch.processor import SCIMPatchProcessor
|
||||
from authentik.sources.scim.views.v2.base import SCIMObjectView
|
||||
from authentik.sources.scim.views.v2.exceptions import (
|
||||
SCIMConflictError,
|
||||
@@ -35,11 +36,12 @@ class GroupsView(SCIMObjectView):
|
||||
payload = SCIMGroupModel(
|
||||
schemas=[SCIM_GROUP_SCHEMA],
|
||||
id=str(scim_group.group.pk),
|
||||
externalId=scim_group.id,
|
||||
externalId=scim_group.external_id,
|
||||
displayName=scim_group.group.name,
|
||||
members=[],
|
||||
meta={
|
||||
"resourceType": "Group",
|
||||
"lastModified": scim_group.last_update,
|
||||
"location": self.request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
@@ -54,7 +56,11 @@ class GroupsView(SCIMObjectView):
|
||||
for member in scim_group.group.users.order_by("pk"):
|
||||
member: User
|
||||
payload.members.append(GroupMember(value=str(member.uuid)))
|
||||
return payload.model_dump(mode="json", exclude_unset=True)
|
||||
final_payload = payload.model_dump(mode="json", exclude_unset=True)
|
||||
final_payload.update(scim_group.attributes)
|
||||
return self.remove_excluded_attributes(
|
||||
SCIMGroupModel.model_validate(final_payload).model_dump(mode="json", exclude_unset=True)
|
||||
)
|
||||
|
||||
def get(self, request: Request, group_id: str | None = None, **kwargs) -> Response:
|
||||
"""List Group handler"""
|
||||
@@ -81,7 +87,7 @@ class GroupsView(SCIMObjectView):
|
||||
)
|
||||
|
||||
@atomic
|
||||
def update_group(self, connection: SCIMSourceGroup | None, data: QueryDict):
|
||||
def update_group(self, connection: SCIMSourceGroup | None, data: QueryDict, apply_members=True):
|
||||
"""Partial update a group"""
|
||||
properties = self.build_object_properties(data)
|
||||
|
||||
@@ -94,7 +100,7 @@ class GroupsView(SCIMObjectView):
|
||||
|
||||
group.update_attributes(properties)
|
||||
|
||||
if "members" in data:
|
||||
if "members" in data and apply_members:
|
||||
query = Q()
|
||||
for _member in data.get("members", []):
|
||||
try:
|
||||
@@ -105,14 +111,18 @@ class GroupsView(SCIMObjectView):
|
||||
query |= Q(uuid=member.value)
|
||||
if query:
|
||||
group.users.set(User.objects.filter(query))
|
||||
data["members"] = self._convert_members(group)
|
||||
if not connection:
|
||||
connection, _ = SCIMSourceGroup.objects.get_or_create(
|
||||
connection, _ = SCIMSourceGroup.objects.update_or_create(
|
||||
external_id=data.get("externalId") or str(uuid4()),
|
||||
source=self.source,
|
||||
group=group,
|
||||
attributes=data,
|
||||
id=data.get("externalId") or str(uuid4()),
|
||||
defaults={
|
||||
"attributes": data,
|
||||
},
|
||||
)
|
||||
else:
|
||||
connection.external_id = data.get("externalId", connection.external_id)
|
||||
connection.attributes = data
|
||||
connection.save()
|
||||
return connection
|
||||
@@ -139,6 +149,12 @@ class GroupsView(SCIMObjectView):
|
||||
connection = self.update_group(connection, request.data)
|
||||
return Response(self.group_to_scim(connection), status=200)
|
||||
|
||||
def _convert_members(self, group: Group):
|
||||
users = []
|
||||
for user in group.users.all().order_by("uuid"):
|
||||
users.append({"value": str(user.uuid)})
|
||||
return sorted(users, key=lambda u: u["value"])
|
||||
|
||||
@atomic
|
||||
def patch(self, request: Request, group_id: str, **kwargs) -> Response:
|
||||
"""Patch group handler"""
|
||||
@@ -171,6 +187,13 @@ class GroupsView(SCIMObjectView):
|
||||
query |= Q(uuid=member["value"])
|
||||
if query:
|
||||
connection.group.users.remove(*User.objects.filter(query))
|
||||
patcher = SCIMPatchProcessor()
|
||||
patched_data = patcher.apply_patches(
|
||||
connection.attributes, request.data.get("Operations", [])
|
||||
)
|
||||
patched_data["members"] = self._convert_members(connection.group)
|
||||
if patched_data != connection.attributes:
|
||||
self.update_group(connection, patched_data, apply_members=False)
|
||||
return Response(self.group_to_scim(connection), status=200)
|
||||
|
||||
@atomic
|
||||
|
||||
@@ -33,9 +33,7 @@ class ServiceProviderConfigView(SCIMView):
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
||||
"authenticationSchemes": auth_schemas,
|
||||
# We only support patch for groups currently, so don't broadly advertise it.
|
||||
# Implementations that require Group patch will use it regardless of this flag.
|
||||
"patch": {"supported": False},
|
||||
"patch": {"supported": True},
|
||||
"bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0},
|
||||
"filter": {
|
||||
"supported": True,
|
||||
|
||||
@@ -15,6 +15,7 @@ from authentik.core.models import User
|
||||
from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA
|
||||
from authentik.providers.scim.clients.schema import User as SCIMUserModel
|
||||
from authentik.sources.scim.models import SCIMSourceUser
|
||||
from authentik.sources.scim.patch.processor import SCIMPatchProcessor
|
||||
from authentik.sources.scim.views.v2.base import SCIMObjectView
|
||||
from authentik.sources.scim.views.v2.exceptions import SCIMConflictError, SCIMNotFoundError
|
||||
|
||||
@@ -29,7 +30,7 @@ class UsersView(SCIMObjectView):
|
||||
payload = SCIMUserModel(
|
||||
schemas=[SCIM_USER_SCHEMA],
|
||||
id=str(scim_user.user.uuid),
|
||||
externalId=scim_user.id,
|
||||
externalId=scim_user.external_id,
|
||||
userName=scim_user.user.username,
|
||||
name=Name(
|
||||
formatted=scim_user.user.name,
|
||||
@@ -44,8 +45,7 @@ class UsersView(SCIMObjectView):
|
||||
meta={
|
||||
"resourceType": "User",
|
||||
"created": scim_user.user.date_joined,
|
||||
# TODO: use events to find last edit?
|
||||
"lastModified": scim_user.user.date_joined,
|
||||
"lastModified": scim_user.last_update,
|
||||
"location": self.request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-users",
|
||||
@@ -59,7 +59,9 @@ class UsersView(SCIMObjectView):
|
||||
)
|
||||
final_payload = payload.model_dump(mode="json", exclude_unset=True)
|
||||
final_payload.update(scim_user.attributes)
|
||||
return final_payload
|
||||
return self.remove_excluded_attributes(
|
||||
SCIMUserModel.model_validate(final_payload).model_dump(mode="json", exclude_unset=True)
|
||||
)
|
||||
|
||||
def get(self, request: Request, user_id: str | None = None, **kwargs) -> Response:
|
||||
"""List User handler"""
|
||||
@@ -101,13 +103,16 @@ class UsersView(SCIMObjectView):
|
||||
user.update_attributes(properties)
|
||||
|
||||
if not connection:
|
||||
connection, _ = SCIMSourceUser.objects.get_or_create(
|
||||
connection, _ = SCIMSourceUser.objects.update_or_create(
|
||||
external_id=data.get("externalId") or str(uuid4()),
|
||||
source=self.source,
|
||||
user=user,
|
||||
attributes=data,
|
||||
id=data.get("externalId") or str(uuid4()),
|
||||
defaults={
|
||||
"attributes": data,
|
||||
},
|
||||
)
|
||||
else:
|
||||
connection.external_id = data.get("externalId", connection.external_id)
|
||||
connection.attributes = data
|
||||
connection.save()
|
||||
return connection
|
||||
@@ -127,6 +132,18 @@ class UsersView(SCIMObjectView):
|
||||
connection = self.update_user(None, request.data)
|
||||
return Response(self.user_to_scim(connection), status=201)
|
||||
|
||||
def patch(self, request: Request, user_id: str, **kwargs):
|
||||
connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first()
|
||||
if not connection:
|
||||
raise SCIMNotFoundError("User not found.")
|
||||
patcher = SCIMPatchProcessor()
|
||||
patched_data = patcher.apply_patches(
|
||||
connection.attributes, request.data.get("Operations", [])
|
||||
)
|
||||
if patched_data != connection.attributes:
|
||||
self.update_user(connection, patched_data)
|
||||
return Response(self.user_to_scim(connection), status=200)
|
||||
|
||||
def put(self, request: Request, user_id: str, **kwargs) -> Response:
|
||||
"""Update user handler"""
|
||||
connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first()
|
||||
|
||||
+20
@@ -55232,6 +55232,9 @@ components:
|
||||
id:
|
||||
type: string
|
||||
minLength: 1
|
||||
external_id:
|
||||
type: string
|
||||
minLength: 1
|
||||
group:
|
||||
type: string
|
||||
format: uuid
|
||||
@@ -55296,6 +55299,9 @@ components:
|
||||
id:
|
||||
type: string
|
||||
minLength: 1
|
||||
external_id:
|
||||
type: string
|
||||
minLength: 1
|
||||
user:
|
||||
type: integer
|
||||
source:
|
||||
@@ -58946,6 +58952,8 @@ components:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
external_id:
|
||||
type: string
|
||||
group:
|
||||
type: string
|
||||
format: uuid
|
||||
@@ -58960,6 +58968,7 @@ components:
|
||||
type: object
|
||||
additionalProperties: {}
|
||||
required:
|
||||
- external_id
|
||||
- group
|
||||
- group_obj
|
||||
- id
|
||||
@@ -58971,6 +58980,9 @@ components:
|
||||
id:
|
||||
type: string
|
||||
minLength: 1
|
||||
external_id:
|
||||
type: string
|
||||
minLength: 1
|
||||
group:
|
||||
type: string
|
||||
format: uuid
|
||||
@@ -58981,6 +58993,7 @@ components:
|
||||
type: object
|
||||
additionalProperties: {}
|
||||
required:
|
||||
- external_id
|
||||
- group
|
||||
- id
|
||||
- source
|
||||
@@ -59089,6 +59102,8 @@ components:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
external_id:
|
||||
type: string
|
||||
user:
|
||||
type: integer
|
||||
user_obj:
|
||||
@@ -59102,6 +59117,7 @@ components:
|
||||
type: object
|
||||
additionalProperties: {}
|
||||
required:
|
||||
- external_id
|
||||
- id
|
||||
- source
|
||||
- user
|
||||
@@ -59113,6 +59129,9 @@ components:
|
||||
id:
|
||||
type: string
|
||||
minLength: 1
|
||||
external_id:
|
||||
type: string
|
||||
minLength: 1
|
||||
user:
|
||||
type: integer
|
||||
source:
|
||||
@@ -59122,6 +59141,7 @@ components:
|
||||
type: object
|
||||
additionalProperties: {}
|
||||
required:
|
||||
- external_id
|
||||
- id
|
||||
- source
|
||||
- user
|
||||
|
||||
@@ -42,7 +42,7 @@ export class SCIMSourceGroupList extends Table<SCIMSourceGroup> {
|
||||
html`<a href="#/identity/groups/${item.groupObj.pk}">
|
||||
<div>${item.groupObj.name}</div>
|
||||
</a>`,
|
||||
html`${item.id}`,
|
||||
html`${item.externalId}`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export class SCIMSourceUserList extends Table<SCIMSourceUser> {
|
||||
<div>${item.userObj.username}</div>
|
||||
<small>${item.userObj.name}</small>
|
||||
</a>`,
|
||||
html`${item.id}`,
|
||||
html`${item.externalId}`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user