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

1import copy 

2import datetime 

3import hashlib 

4import hmac 

5import uuid 

6 

7import dateutil.parser 

8from pydal import Field 

9from pydal import SQLCustomType 

10from pydal.objects import SQLALL, Query 

11 

12from .helpers import IS_IN_LIST 

13 

14 

15class DEFAULT: 

16 pass 

17 

18 

19DEFAULT_STARTS = datetime.datetime(2000, 1, 1) 

20DEFAULT_ENDS = datetime.datetime(3000, 1, 1) 

21 

22 

23def unstr_datetime(s): 

24 """json helper... might values arrive as str """ 

25 return dateutil.parser.parse(s) if isinstance(s, str) else s 

26 

27 

28class Password: 

29 """ 

30 Encode a password using: Password.encode('secret') 

31 """ 

32 

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() 

40 

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 

45 

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) 

50 

51 

52def is_uuid(s): 

53 try: 

54 uuid.UUID(s) 

55 return True 

56 except Exception: 

57 return False 

58 

59 

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 

69 

70 if object_type: 

71 query &= db.identity.object_type == object_type 

72 

73 return query 

74 

75 

76def key_lookup(db, identity_key, object_type=None) -> str | None: 

77 query = key_lookup_query(db, identity_key, object_type) 

78 

79 rowset = db(query).select(db.identity.object_id) 

80 

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))) 

85 

86 return rowset.first().object_id 

87 

88 

89my_datetime = SQLCustomType(type='string', 

90 native='char(35)', 

91 encoder=(lambda x: x.isoformat(' ')), 

92 decoder=(lambda x: dateutil.parser.parse(x))) 

93 

94 

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 ) 

107 

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 ) 

115 

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 ) 

126 

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 ) 

149 

150 

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 

173 

174 

175def add_group(db, email, name, member_of): 

176 return add_identity(db, email, None, member_of, name=name, object_type='group') 

177 

178 

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 

184 

185 

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() 

195 

196 

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') 

204 

205 

206def get_group(db, key): 

207 """ 

208 

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') 

214 

215 

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) 

222 

223 

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() 

239 

240 

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 

249 

250 

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) 

257 

258 

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) 

265 

266 

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() 

286 

287 

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 

305 

306 

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] 

317 

318 if source_id := getattr(source, "_id", None): 

319 other._id = other[source_id.name] 

320 db[alias] = other 

321 return other 

322 

323 

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') 

339 

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