Coverage for src/edwh_auth_rbac/model.py: 76%
183 statements
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-16 17:13 +0200
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-16 17:13 +0200
1import copy
2import datetime
3import hashlib
4import hmac
5import uuid
7import dateutil.parser
8from pydal import Field
9from pydal import SQLCustomType
10from pydal.objects import SQLALL, Query
12from .helpers import IS_IN_LIST
15class DEFAULT:
16 pass
19DEFAULT_STARTS = datetime.datetime(2000, 1, 1)
20DEFAULT_ENDS = datetime.datetime(3000, 1, 1)
23def unstr_datetime(s):
24 """json helper... might values arrive as str """
25 return dateutil.parser.parse(s) if isinstance(s, str) else s
28class Password:
29 """
30 Encode a password using: Password.encode('secret')
31 """
33 @classmethod
34 def hmac_hash(cls, value, key, salt=None):
35 digest_alg = hashlib.sha512
36 d = hmac.new(str(key).encode(), str(value).encode(), digest_alg)
37 if salt:
38 d.update(str(salt).encode())
39 return d.hexdigest()
41 @classmethod
42 def validate(cls, password, candidate):
43 salt, hashed = candidate.split(':', 1)
44 return cls.hmac_hash(value=password, key='secret_start', salt=salt) == hashed
46 @classmethod
47 def encode(cls, password):
48 salt = uuid.uuid4().hex
49 return salt + ':' + cls.hmac_hash(value=password, key='secret_start', salt=salt)
52def is_uuid(s):
53 try:
54 uuid.UUID(s)
55 return True
56 except Exception:
57 return False
60def key_lookup_query(db, identity_key: str | int | uuid.UUID, object_type=None) -> Query:
61 if '@' in str(identity_key):
62 query = db.identity.email == identity_key.lower()
63 elif isinstance(identity_key, int):
64 query = db.identity.id == identity_key
65 elif is_uuid(identity_key):
66 query = db.identity.object_id == identity_key.lower()
67 else:
68 query = db.identity.firstname == identity_key
70 if object_type:
71 query &= db.identity.object_type == object_type
73 return query
76def key_lookup(db, identity_key, object_type=None) -> str | None:
77 query = key_lookup_query(db, identity_key, object_type)
79 rowset = db(query).select(db.identity.object_id)
81 if not rowset:
82 return None
83 elif len(rowset) > 1:
84 raise ValueError('Keep lookup for {} returned {} results.'.format(identity_key, len(rowset)))
86 return rowset.first().object_id
89my_datetime = SQLCustomType(type='string',
90 native='char(35)',
91 encoder=(lambda x: x.isoformat(' ')),
92 decoder=(lambda x: dateutil.parser.parse(x)))
95def define_auth_rbac_model(db, other_args):
96 db.define_table('identity',
97 # std uuid from uuid libs are 36 chars long
98 Field('object_id', 'string', length=36, unique=True, notnull=True, default=str(uuid.uuid4())),
99 Field('object_type', 'string', requires=(IS_IN_LIST(other_args['allowed_types']))),
100 Field('created', 'datetime', default=datetime.datetime.now),
101 # email needn't be unique, groups can share email addresses, and with people too
102 Field('email', 'string'),
103 Field('firstname', 'string', comment='also used as short code for groups'),
104 Field('fullname', 'string'),
105 Field('encoded_password', 'string'),
106 )
108 db.define_table('membership',
109 # beide zijn eigenlijk: reference:identity.object_id
110 Field('subject', 'string', length=36, notnull=True),
111 Field('member_of', 'string', length=36, notnull=True),
112 # Field('starts','datetime', default=DEFAULT_STARTS),
113 # Field('ends','datetime', default=DEFAULT_ENDS),
114 )
116 db.define_table('permission',
117 Field('privilege', 'string', length=20),
118 # reference:identity.object_id
119 Field('identity_object_id', 'string', length=36),
120 Field('target_object_id', 'string', length=36),
121 # Field('scope'), lets bail scope for now. every one needs a rule for everything
122 # just to make sure, no 'wildcards' and 'every dossier for org x' etc ...
123 Field('starts', type=my_datetime, default=DEFAULT_STARTS),
124 Field('ends', type=my_datetime, default=DEFAULT_ENDS),
125 )
127 db.define_table('recursive_memberships',
128 Field('root'),
129 Field('object_id'),
130 Field('object_type'),
131 Field('level', 'integer'),
132 Field('email'),
133 Field('firstname'),
134 Field('fullname'),
135 migrate=False,
136 primarykey=['root', 'object_id'] # composed, no primary key
137 )
138 db.define_table('recursive_members',
139 Field('root'),
140 Field('object_id'),
141 Field('object_type'),
142 Field('level', 'integer'),
143 Field('email'),
144 Field('firstname'),
145 Field('fullname'),
146 migrate=False,
147 primarykey=['root', 'object_id'] # composed, no primary key
148 )
151def add_identity(db, email, password, member_of, name=None, firstname=None, fullname=None, gid=None, object_type=None):
152 """paramaters name and firstname are equal. """
153 email = email.lower().strip()
154 if object_type is None:
155 raise ValueError('object_type parameter expected')
156 object_id = gid if gid else str(uuid.uuid4())
157 db.identity.validate_and_insert(
158 object_id=object_id,
159 object_type=object_type,
160 email=email,
161 firstname=name or firstname or None,
162 fullname=fullname,
163 encoded_password=Password.encode(password)
164 )
165 db.commit()
166 for key in member_of:
167 group_id = key_lookup(db, key, 'group')
168 if get_group(db, group_id):
169 # check each group if it exists.
170 add_membership(db, identity_key=object_id, group_key=group_id)
171 db.commit()
172 return object_id
175def add_group(db, email, name, member_of):
176 return add_identity(db, email, None, member_of, name=name, object_type='group')
179def remove_identity(db, object_id):
180 removed = db(db.identity.object_id == object_id).delete()
181 # todo: remove permissions and group memberships
182 db.commit()
183 return removed > 0
186def get_identity(db, key, object_type=None):
187 """
188 :param db: dal db connection
189 :param key: can be the email, id, or object_id
190 :return: user record or None when not found
191 """
192 query = key_lookup_query(db, key, object_type)
193 rows = db(query).select()
194 return rows.first()
197def get_user(db, key):
198 """
199 :param db: dal db connection
200 :param key: can be the email, id, or object_id
201 :return: user record or None when not found
202 """
203 return get_identity(db, key, object_type='user')
206def get_group(db, key):
207 """
209 :param db: dal db connection
210 :param key: can be the name of the group, the id, object_id or email_address
211 :return: user record or None when not found
212 """
213 return get_identity(db, key, object_type='group')
216def authenticate_user(db, password=None, user=None, key=None):
217 if not password:
218 return False
219 if not user:
220 user = get_user(db, key)
221 return Password.validate(password, user.encoded_password)
224def add_membership(db, identity_key, group_key):
225 identity_oid = key_lookup(db, identity_key)
226 if identity_oid is None:
227 raise ValueError('invalid identity_oid key: ' + identity_key)
228 group = get_group(db, group_key)
229 if not group:
230 raise ValueError('invalid group key: ' + group_key)
231 query = db.membership.subject == identity_oid
232 query &= db.membership.member_of == group.object_id
233 if db(query).count() == 0:
234 db.membership.validate_and_insert(
235 subject=identity_oid,
236 member_of=group.object_id,
237 )
238 db.commit()
241def remove_membership(db, identity_key, group_key):
242 identity = get_identity(db, identity_key)
243 group = get_group(db, group_key)
244 query = db.membership.subject == identity.object_id
245 query &= db.membership.member_of == group.object_id
246 deleted = db(query).delete()
247 db.commit()
248 return deleted
251def get_memberships(db, object_id, bare=True):
252 query = db.recursive_memberships.root == object_id
253 fields = [db.recursive_memberships.object_id, db.recursive_memberships.object_type]
254 if not bare:
255 fields = []
256 return db(query).select(*fields)
259def get_members(db, object_id, bare=True):
260 query = db.recursive_members.root == object_id
261 fields = [db.recursive_members.object_id, db.recursive_members.object_type]
262 if not bare:
263 fields = []
264 return db(query).select(*fields)
267def add_permission(db, identity_key, target_oid, privilege, starts=DEFAULT_STARTS, ends=DEFAULT_ENDS):
268 identity_oid = key_lookup(db, identity_key)
269 starts = unstr_datetime(starts)
270 ends = unstr_datetime(ends)
271 if has_permission(db, identity_oid, target_oid, privilege, when=starts):
272 # permission already granted. just skip it
273 print(
274 '{privilege} permission already granted to {user_or_group_key} on {target_oid} @ {starts} '.format(
275 **locals()))
276 # print(db._lastsql)
277 return
278 db.permission.validate_and_insert(
279 privilege=privilege,
280 identity_object_id=identity_oid,
281 target_object_id=target_oid,
282 starts=starts,
283 ends=ends,
284 )
285 db.commit()
288def remove_permission(db, identity_key, target_oid, privilege, when=DEFAULT):
289 identity_oid = key_lookup(db, identity_key)
290 if when is DEFAULT:
291 when = datetime.datetime.now()
292 else:
293 when = unstr_datetime(when)
294 # base object is is the root to check for, user or group
295 permission = db.permission
296 query = permission.identity_object_id == identity_oid
297 query &= permission.target_object_id == target_oid
298 query &= permission.privilege == privilege
299 query &= permission.starts <= when
300 query &= permission.ends >= when
301 result = db(query).delete() > 0
302 db.commit()
303 # print(db._lastsql)
304 return result
307def with_alias(db, source, alias):
308 other = copy.copy(source)
309 other['ALL'] = SQLALL(other)
310 other['_tablename'] = alias
311 for fieldname in other.fields:
312 tmp = source[fieldname].clone()
313 tmp.bind(other)
314 other[fieldname] = tmp
315 if 'id' in source and 'id' not in other.fields:
316 other['id'] = other[source.id.name]
318 if source_id := getattr(source, "_id", None):
319 other._id = other[source_id.name]
320 db[alias] = other
321 return other
324def has_permission(db, user_or_group_key, target_oid, privilege, when=DEFAULT):
325 user_or_group_oid = key_lookup(db, user_or_group_key)
326 # the permission system
327 if when is DEFAULT:
328 when = datetime.datetime.now()
329 else:
330 when = unstr_datetime(when)
331 # base object is is the root to check for, user or group
332 root_oid = user_or_group_oid
333 permission = db.permission
334 # ugly hack to satisfy pydal aliasing keyed tables /views
335 left = with_alias(db, db.recursive_memberships, 'left')
336 right = with_alias(db, db.recursive_memberships, 'right')
337 # left = db.recursive_memberships.with_alias('left')
338 # right = db.recursive_memberships.with_alias('right')
340 # end of ugly hack
341 query = left.root == root_oid
342 query &= right.root == target_oid
343 query &= permission.identity_object_id == left.object_id
344 query &= permission.target_object_id == right.object_id
345 query &= permission.privilege == privilege
346 query &= permission.starts <= when
347 query &= permission.ends >= when
348 return db(query).count() > 0