Skip to content

Commit cdf49c9

Browse files
committed
Merge branch 'issue-30-model-attributes' into 'main'
models attributes cardinality is closer to SCIM models See merge request yaal/canaille!156
2 parents 0ee374d + 1fd8af2 commit cdf49c9

39 files changed

+305
-424
lines changed

CHANGES.rst

+5
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ All notable changes to this project will be documented in this file.
33
The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.0.0/>`_,
44
and this project adheres to `Semantic Versioning <https://semver.org/spec/v2.0.0.html>`_.
55

6+
Changed
7+
*******
8+
9+
- Model attributes cardinality is closer to SCIM model. :pr:`155`
10+
611
[0.0.34] - 2023-10-02
712
=====================
813

canaille/app/__init__.py

+8
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,11 @@ def validate_uri(value):
5858
re.IGNORECASE,
5959
)
6060
return re.match(regex, value) is not None
61+
62+
63+
class classproperty:
64+
def __init__(self, f):
65+
self.f = f
66+
67+
def __get__(self, obj, owner):
68+
return self.f(owner)

canaille/backends/ldap/ldapobject.py

+61-44
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from canaille.backends.models import Model
77

88
from .backend import Backend
9+
from .utils import cardinalize_attribute
910
from .utils import ldap_to_python
1011
from .utils import listify
1112
from .utils import python_to_ldap
@@ -109,7 +110,7 @@ class LDAPObject(Model, metaclass=LDAPObjectMetaclass):
109110
base = None
110111
root_dn = None
111112
rdn_attribute = None
112-
attributes = None
113+
attribute_map = None
113114
ldap_object_class = None
114115

115116
def __init__(self, dn=None, **kwargs):
@@ -121,38 +122,38 @@ def __init__(self, dn=None, **kwargs):
121122
setattr(self, name, value)
122123

123124
def __repr__(self):
124-
reverse_attributes = {v: k for k, v in (self.attributes or {}).items()}
125-
attribute_name = reverse_attributes.get(self.rdn_attribute, self.rdn_attribute)
125+
attribute_name = self.ldap_attribute_to_python(self.rdn_attribute)
126126
return (
127127
f"<{self.__class__.__name__} {attribute_name}={self.rdn_value}>"
128128
if self.rdn_attribute
129129
else "<LDAPOBject>"
130130
)
131131

132132
def __eq__(self, other):
133+
ldap_attributes = self.may() + self.must()
133134
if not (
134135
isinstance(other, self.__class__)
135136
and self.may() == other.may()
136137
and self.must() == other.must()
137138
and all(
138-
hasattr(self, attr) == hasattr(other, attr)
139-
for attr in self.may() + self.must()
139+
self.has_ldap_attribute(attr) == other.has_ldap_attribute(attr)
140+
for attr in ldap_attributes
140141
)
141142
):
142143
return False
143144

144145
self_attributes = python_attrs_to_ldap(
145146
{
146-
attr: getattr(self, attr)
147-
for attr in self.may() + self.must()
148-
if hasattr(self, attr)
147+
attr: self.get_ldap_attribute(attr)
148+
for attr in ldap_attributes
149+
if self.has_ldap_attribute(attr)
149150
}
150151
)
151152
other_attributes = python_attrs_to_ldap(
152153
{
153-
attr: getattr(other, attr)
154-
for attr in self.may() + self.must()
155-
if hasattr(self, attr)
154+
attr: other.get_ldap_attribute(attr)
155+
for attr in ldap_attributes
156+
if other.has_ldap_attribute(attr)
156157
}
157158
)
158159
return self_attributes == other_attributes
@@ -161,17 +162,40 @@ def __hash__(self):
161162
return hash(self.id)
162163

163164
def __getattr__(self, name):
164-
name = self.attributes.get(name, name)
165-
166-
if name not in self.ldap_object_attributes():
165+
if name not in self.attributes:
167166
return super().__getattribute__(name)
168167

169-
single_value = self.ldap_object_attributes()[name].single_value
168+
ldap_name = self.python_attribute_to_ldap(name)
169+
170+
if ldap_name == "dn":
171+
return self.dn_for(self.rdn_value)
172+
173+
python_single_value = "List" not in str(self.__annotations__[name])
174+
ldap_value = self.get_ldap_attribute(ldap_name)
175+
return cardinalize_attribute(python_single_value, ldap_value)
176+
177+
def __setattr__(self, name, value):
178+
if name not in self.attributes:
179+
super().__setattr__(name, value)
180+
181+
ldap_name = self.python_attribute_to_ldap(name)
182+
self.set_ldap_attribute(ldap_name, value)
183+
184+
def __delattr__(self, name):
185+
ldap_name = self.python_attribute_to_ldap(name)
186+
self.delete_ldap_attribute(ldap_name)
187+
188+
def has_ldap_attribute(self, name):
189+
return name in self.ldap_object_attributes() and (
190+
name in self.changes or name in self.state
191+
)
192+
193+
def get_ldap_attribute(self, name):
170194
if name in self.changes:
171-
return self.changes[name][0] if single_value else self.changes[name]
195+
return self.changes[name]
172196

173197
if not self.state.get(name):
174-
return None if single_value else []
198+
return None
175199

176200
# Lazy conversion from ldap format to python format
177201
if any(isinstance(value, bytes) for value in self.state[name]):
@@ -180,35 +204,23 @@ def __getattr__(self, name):
180204
ldap_to_python(value, syntax) for value in self.state[name]
181205
]
182206

183-
if single_value:
184-
return self.state.get(name)[0]
185-
else:
186-
return [value for value in self.state.get(name) if value is not None]
207+
return self.state.get(name)
187208

188-
def __setattr__(self, name, value):
189-
if self.attributes:
190-
name = self.attributes.get(name, name)
209+
def set_ldap_attribute(self, name, value):
210+
if name not in self.ldap_object_attributes():
211+
return
191212

192-
if name in self.ldap_object_attributes():
193-
value = listify(value)
194-
self.changes[name] = value
213+
value = listify(value)
214+
self.changes[name] = value
195215

196-
else:
197-
super().__setattr__(name, value)
198-
199-
def __delattr__(self, name):
200-
name = self.attributes.get(name, name)
216+
def delete_ldap_attribute(self, name):
201217
self.changes[name] = [None]
202218

203219
@property
204220
def rdn_value(self):
205-
value = getattr(self, self.rdn_attribute)
221+
value = self.get_ldap_attribute(self.rdn_attribute)
206222
return (value[0] if isinstance(value, list) else value).strip()
207223

208-
@property
209-
def dn(self):
210-
return self.dn_for(self.rdn_value)
211-
212224
@classmethod
213225
def dn_for(cls, rdn):
214226
return f"{cls.rdn_attribute}={ldap.dn.escape_dn_chars(rdn)},{cls.base},{cls.root_dn}"
@@ -317,7 +329,7 @@ def query(cls, id=None, filter=None, conn=None, **kwargs):
317329
arg_filter = ""
318330
kwargs = python_attrs_to_ldap(
319331
{
320-
(cls.attributes or {}).get(name, name): values
332+
cls.python_attribute_to_ldap(name): values
321333
for name, values in kwargs.items()
322334
},
323335
encode=False,
@@ -350,7 +362,7 @@ def query(cls, id=None, filter=None, conn=None, **kwargs):
350362
def fuzzy(cls, query, attributes=None, **kwargs):
351363
query = ldap.filter.escape_filter_chars(query)
352364
attributes = attributes or cls.may() + cls.must()
353-
attributes = [cls.attributes.get(name, name) for name in attributes]
365+
attributes = [cls.python_attribute_to_ldap(name) for name in attributes]
354366
filter = (
355367
"(|" + "".join(f"({attribute}=*{query}*)" for attribute in attributes) + ")"
356368
)
@@ -380,6 +392,15 @@ def update_ldap_attributes(cls):
380392
cls._may = list(set(cls._may))
381393
cls._must = list(set(cls._must))
382394

395+
@classmethod
396+
def ldap_attribute_to_python(cls, name):
397+
reverse_attribute_map = {v: k for k, v in (cls.attribute_map or {}).items()}
398+
return reverse_attribute_map.get(name, name)
399+
400+
@classmethod
401+
def python_attribute_to_ldap(cls, name):
402+
return cls.attribute_map.get(name, name) if cls.attribute_map else None
403+
383404
def reload(self, conn=None):
384405
conn = conn or Backend.get().connection
385406
result = conn.search_s(self.id, ldap.SCOPE_SUBTREE, None, ["+", "*"])
@@ -389,7 +410,7 @@ def reload(self, conn=None):
389410
def save(self, conn=None):
390411
conn = conn or Backend.get().connection
391412

392-
setattr(self, "objectClass", self.ldap_object_class)
413+
self.set_ldap_attribute("objectClass", self.ldap_object_class)
393414

394415
# Object already exists in the LDAP database
395416
if self.exists:
@@ -429,10 +450,6 @@ def save(self, conn=None):
429450
self.state = {**self.state, **self.changes}
430451
self.changes = {}
431452

432-
def update(self, **kwargs):
433-
for k, v in kwargs.items():
434-
self.__setattr__(k, v)
435-
436453
def delete(self, conn=None):
437454
conn = conn or Backend.get().connection
438455
conn.delete_s(self.id)

canaille/backends/ldap/models.py

+8-11
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class User(canaille.core.models.User, LDAPObject):
1515
DEFAULT_FILTER = "(|(uid={{ login }})(mail={{ login }}))"
1616
DEFAULT_RDN = "cn"
1717

18-
attributes = {
18+
attribute_map = {
1919
"id": "dn",
2020
"user_name": "uid",
2121
"password": "userPassword",
@@ -66,7 +66,7 @@ def acl_filter_to_ldap_filter(cls, filter_):
6666
filter_["groups"] = Group.dn_for(filter_["groups"])
6767

6868
base = "".join(
69-
f"({cls.attributes.get(key, key)}={value})"
69+
f"({cls.python_attribute_to_ldap(key)}={value})"
7070
for key, value in filter_.items()
7171
)
7272
return f"(&{base})" if len(filter_) > 1 else base
@@ -147,7 +147,7 @@ def reload(self):
147147
self.load_permissions()
148148

149149
def save(self, *args, **kwargs):
150-
group_attr = self.attributes.get("groups", "groups")
150+
group_attr = self.python_attribute_to_ldap("groups")
151151
new_groups = self.changes.get(group_attr)
152152
if not new_groups:
153153
return super().save(*args, **kwargs)
@@ -194,7 +194,7 @@ class Group(canaille.core.models.Group, LDAPObject):
194194
DEFAULT_NAME_ATTRIBUTE = "cn"
195195
DEFAULT_USER_FILTER = "member={user.id}"
196196

197-
attributes = {
197+
attribute_map = {
198198
"id": "dn",
199199
"display_name": "cn",
200200
"members": "member",
@@ -243,9 +243,8 @@ class Client(canaille.oidc.models.Client, LDAPObject):
243243
"software_version": "oauthSoftwareVersion",
244244
}
245245

246-
attributes = {
246+
attribute_map = {
247247
"id": "dn",
248-
"description": "description",
249248
"preconsent": "oauthPreconsent",
250249
# post_logout_redirect_uris is not yet supported by authlib
251250
"post_logout_redirect_uris": "oauthPostLogoutRedirectURI",
@@ -263,10 +262,9 @@ class AuthorizationCode(canaille.oidc.models.AuthorizationCode, LDAPObject):
263262
ldap_object_class = ["oauthAuthorizationCode"]
264263
base = "ou=authorizations,ou=oauth"
265264
rdn_attribute = "oauthAuthorizationCodeID"
266-
attributes = {
265+
attribute_map = {
267266
"id": "dn",
268267
"authorization_code_id": "oauthAuthorizationCodeID",
269-
"description": "description",
270268
"code": "oauthCode",
271269
"client": "oauthClient",
272270
"subject": "oauthSubject",
@@ -290,11 +288,10 @@ class Token(canaille.oidc.models.Token, LDAPObject):
290288
ldap_object_class = ["oauthToken"]
291289
base = "ou=tokens,ou=oauth"
292290
rdn_attribute = "oauthTokenID"
293-
attributes = {
291+
attribute_map = {
294292
"id": "dn",
295293
"token_id": "oauthTokenID",
296294
"access_token": "oauthAccessToken",
297-
"description": "description",
298295
"client": "oauthClient",
299296
"subject": "oauthSubject",
300297
"type": "oauthTokenType",
@@ -315,7 +312,7 @@ class Consent(canaille.oidc.models.Consent, LDAPObject):
315312
ldap_object_class = ["oauthConsent"]
316313
base = "ou=consents,ou=oauth"
317314
rdn_attribute = "cn"
318-
attributes = {
315+
attribute_map = {
319316
"id": "dn",
320317
"consent_id": "cn",
321318
"subject": "oauthSubject",

canaille/backends/ldap/utils.py

+10
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,13 @@ def python_to_ldap(value, syntax, encode=True):
8585

8686
def listify(value):
8787
return value if isinstance(value, list) else [value]
88+
89+
90+
def cardinalize_attribute(python_unique, value):
91+
if not value:
92+
return None if python_unique else []
93+
94+
if python_unique:
95+
return value[0]
96+
97+
return [v for v in value if v is not None]

0 commit comments

Comments
 (0)