Coverage for src/edwh_auth_rbac/model.py: 76%

1 import copy

2 import datetime

3 import hashlib

4 import hmac

5 import uuid

6

7 import dateutil.parser

8 from pydal import Field

9 from pydal import SQLCustomType

10 from pydal.objects import SQLALL, Query

11

12 from .helpers import IS_IN_LIST

13

14

15 class DEFAULT:

16 pass

17

18

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

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

21

22

23 def 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

28 class 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

52 def is_uuid(s):

53 try:

54 uuid.UUID(s)

55 return True

56 except Exception:

57 return False

58

59

60 def 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

76 def 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

89 my_datetime = SQLCustomType(type='string',

90 native='char(35)',

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

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

93

94

95 def 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

151 def 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

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

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

177

178

179 def 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

186 def 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

197 def 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

206 def 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

216 def 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

224 def 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

241 def 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

251 def 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

259 def 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

267 def 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

288 def 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

307 def 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

324 def 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