Feature-9904: Enhancing NG-UI to enable Juju operational view dashboard
[osm/NBI.git] / osm_nbi / authconn_internal.py
1 # -*- coding: utf-8 -*-
2
3 # Copyright 2018 Telefonica S.A.
4 # Copyright 2018 ALTRAN InnovaciĆ³n S.L.
5 #
6 # Licensed under the Apache License, Version 2.0 (the "License"); you may
7 # not use this file except in compliance with the License. You may obtain
8 # a copy of the License at
9 #
10 # http://www.apache.org/licenses/LICENSE-2.0
11 #
12 # Unless required by applicable law or agreed to in writing, software
13 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
15 # License for the specific language governing permissions and limitations
16 # under the License.
17 #
18 # For those usages not covered by the Apache License, Version 2.0 please
19 # contact: esousa@whitestack.com or glavado@whitestack.com
20 ##
21
22 """
23 AuthconnInternal implements implements the connector for
24 OSM Internal Authentication Backend and leverages the RBAC model
25 """
26
27 __author__ = (
28 "Pedro de la Cruz Ramos <pdelacruzramos@altran.com>, "
29 "Alfonso Tierno <alfonso.tiernosepulveda@telefoncia.com"
30 )
31 __date__ = "$06-jun-2019 11:16:08$"
32
33 import logging
34 import re
35
36 from osm_nbi.authconn import Authconn, AuthException # , AuthconnOperationException
37 from osm_common.dbbase import DbException
38 from osm_nbi.base_topic import BaseTopic
39 from osm_nbi.validation import is_valid_uuid
40 from time import time, sleep
41 from http import HTTPStatus
42 from uuid import uuid4
43 from hashlib import sha256
44 from copy import deepcopy
45 from random import choice as random_choice
46
47
48 class AuthconnInternal(Authconn):
49 token_time_window = 2 # seconds
50 token_delay = 1 # seconds to wait upon second request within time window
51
52 users_collection = "users"
53 roles_collection = "roles"
54 projects_collection = "projects"
55 tokens_collection = "tokens"
56
57 def __init__(self, config, db, role_permissions):
58 Authconn.__init__(self, config, db, role_permissions)
59 self.logger = logging.getLogger("nbi.authenticator.internal")
60
61 self.db = db
62 # self.msg = msg
63 # self.token_cache = token_cache
64
65 # To be Confirmed
66 self.sess = None
67
68 def validate_token(self, token):
69 """
70 Check if the token is valid.
71
72 :param token: token to validate
73 :return: dictionary with information associated with the token:
74 "_id": token id
75 "project_id": project id
76 "project_name": project name
77 "user_id": user id
78 "username": user name
79 "roles": list with dict containing {name, id}
80 "expires": expiration date
81 If the token is not valid an exception is raised.
82 """
83
84 try:
85 if not token:
86 raise AuthException(
87 "Needed a token or Authorization HTTP header",
88 http_code=HTTPStatus.UNAUTHORIZED,
89 )
90
91 now = time()
92
93 # get from database if not in cache
94 # if not token_info:
95 token_info = self.db.get_one(self.tokens_collection, {"_id": token})
96 if token_info["expires"] < now:
97 raise AuthException(
98 "Expired Token or Authorization HTTP header",
99 http_code=HTTPStatus.UNAUTHORIZED,
100 )
101
102 return token_info
103
104 except DbException as e:
105 if e.http_code == HTTPStatus.NOT_FOUND:
106 raise AuthException(
107 "Invalid Token or Authorization HTTP header",
108 http_code=HTTPStatus.UNAUTHORIZED,
109 )
110 else:
111 raise
112 except AuthException:
113 raise
114 except Exception:
115 self.logger.exception(
116 "Error during token validation using internal backend"
117 )
118 raise AuthException(
119 "Error during token validation using internal backend",
120 http_code=HTTPStatus.UNAUTHORIZED,
121 )
122
123 def revoke_token(self, token):
124 """
125 Invalidate a token.
126
127 :param token: token to be revoked
128 """
129 try:
130 # self.token_cache.pop(token, None)
131 self.db.del_one(self.tokens_collection, {"_id": token})
132 return True
133 except DbException as e:
134 if e.http_code == HTTPStatus.NOT_FOUND:
135 raise AuthException(
136 "Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND
137 )
138 else:
139 # raise
140 exmsg = "Error during token revocation using internal backend"
141 self.logger.exception(exmsg)
142 raise AuthException(exmsg, http_code=HTTPStatus.UNAUTHORIZED)
143
144 def validate_user(self, user, password):
145 """
146 Validate username and password via appropriate backend.
147 :param user: username of the user.
148 :param password: password to be validated.
149 """
150 user_rows = self.db.get_list(
151 self.users_collection, {BaseTopic.id_field("users", user): user}
152 )
153 user_content = None
154 if user_rows:
155 user_content = user_rows[0]
156 salt = user_content["_admin"]["salt"]
157 shadow_password = sha256(
158 password.encode("utf-8") + salt.encode("utf-8")
159 ).hexdigest()
160 if shadow_password != user_content["password"]:
161 user_content = None
162 return user_content
163
164 def authenticate(self, credentials, token_info=None):
165 """
166 Authenticate a user using username/password or previous token_info plus project; its creates a new token
167
168 :param credentials: dictionary that contains:
169 username: name, id or None
170 password: password or None
171 project_id: name, id, or None. If None first found project will be used to get an scope token
172 other items are allowed and ignored
173 :param token_info: previous token_info to obtain authorization
174 :return: the scoped token info or raises an exception. The token is a dictionary with:
175 _id: token string id,
176 username: username,
177 project_id: scoped_token project_id,
178 project_name: scoped_token project_name,
179 expires: epoch time when it expires,
180 """
181
182 now = time()
183 user_content = None
184 user = credentials.get("username")
185 password = credentials.get("password")
186 project = credentials.get("project_id")
187
188 # Try using username/password
189 if user:
190 user_content = self.validate_user(user, password)
191 if not user_content:
192 raise AuthException(
193 "Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED
194 )
195 if not user_content.get("_admin", None):
196 raise AuthException(
197 "No default project for this user.",
198 http_code=HTTPStatus.UNAUTHORIZED,
199 )
200 elif token_info:
201 user_rows = self.db.get_list(
202 self.users_collection, {"username": token_info["username"]}
203 )
204 if user_rows:
205 user_content = user_rows[0]
206 else:
207 raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED)
208 else:
209 raise AuthException(
210 "Provide credentials: username/password or Authorization Bearer token",
211 http_code=HTTPStatus.UNAUTHORIZED,
212 )
213 # Delay upon second request within time window
214 if (
215 now - user_content["_admin"].get("last_token_time", 0)
216 < self.token_time_window
217 ):
218 sleep(self.token_delay)
219 # user_content["_admin"]["last_token_time"] = now
220 # self.db.replace("users", user_content["_id"], user_content) # might cause race conditions
221 self.db.set_one(
222 self.users_collection,
223 {"_id": user_content["_id"]},
224 {"_admin.last_token_time": now},
225 )
226
227 token_id = "".join(
228 random_choice(
229 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
230 )
231 for _ in range(0, 32)
232 )
233
234 # projects = user_content.get("projects", [])
235 prm_list = user_content.get("project_role_mappings", [])
236
237 if not project:
238 project = prm_list[0]["project"] if prm_list else None
239 if not project:
240 raise AuthException(
241 "can't find a default project for this user",
242 http_code=HTTPStatus.UNAUTHORIZED,
243 )
244
245 projects = [prm["project"] for prm in prm_list]
246
247 proj = self.db.get_one(
248 self.projects_collection, {BaseTopic.id_field("projects", project): project}
249 )
250 project_name = proj["name"]
251 project_id = proj["_id"]
252 if project_name not in projects and project_id not in projects:
253 raise AuthException(
254 "project {} not allowed for this user".format(project),
255 http_code=HTTPStatus.UNAUTHORIZED,
256 )
257
258 # TODO remove admin, this vill be used by roles RBAC
259 if project_name == "admin":
260 token_admin = True
261 else:
262 token_admin = proj.get("admin", False)
263
264 # add token roles
265 roles = []
266 roles_list = []
267 for prm in prm_list:
268 if prm["project"] in [project_id, project_name]:
269 role = self.db.get_one(
270 self.roles_collection,
271 {BaseTopic.id_field("roles", prm["role"]): prm["role"]},
272 )
273 rid = role["_id"]
274 if rid not in roles:
275 rnm = role["name"]
276 roles.append(rid)
277 roles_list.append({"name": rnm, "id": rid})
278 if not roles_list:
279 rid = self.db.get_one(self.roles_collection, {"name": "project_admin"})[
280 "_id"
281 ]
282 roles_list = [{"name": "project_admin", "id": rid}]
283
284 new_token = {
285 "issued_at": now,
286 "expires": now + 3600,
287 "_id": token_id,
288 "id": token_id,
289 "project_id": proj["_id"],
290 "project_name": proj["name"],
291 "username": user_content["username"],
292 "user_id": user_content["_id"],
293 "admin": token_admin,
294 "roles": roles_list,
295 }
296
297 self.db.create(self.tokens_collection, new_token)
298 return deepcopy(new_token)
299
300 def get_role_list(self, filter_q={}):
301 """
302 Get role list.
303
304 :return: returns the list of roles.
305 """
306 return self.db.get_list(self.roles_collection, filter_q)
307
308 def create_role(self, role_info):
309 """
310 Create a role.
311
312 :param role_info: full role info.
313 :return: returns the role id.
314 :raises AuthconnOperationException: if role creation failed.
315 """
316 # TODO: Check that role name does not exist ?
317 rid = str(uuid4())
318 role_info["_id"] = rid
319 rid = self.db.create(self.roles_collection, role_info)
320 return rid
321
322 def delete_role(self, role_id):
323 """
324 Delete a role.
325
326 :param role_id: role identifier.
327 :raises AuthconnOperationException: if role deletion failed.
328 """
329 rc = self.db.del_one(self.roles_collection, {"_id": role_id})
330 self.db.del_list(self.tokens_collection, {"roles.id": role_id})
331 return rc
332
333 def update_role(self, role_info):
334 """
335 Update a role.
336
337 :param role_info: full role info.
338 :return: returns the role name and id.
339 :raises AuthconnOperationException: if user creation failed.
340 """
341 rid = role_info["_id"]
342 self.db.set_one(self.roles_collection, {"_id": rid}, role_info)
343 return {"_id": rid, "name": role_info["name"]}
344
345 def create_user(self, user_info):
346 """
347 Create a user.
348
349 :param user_info: full user info.
350 :return: returns the username and id of the user.
351 """
352 BaseTopic.format_on_new(user_info, make_public=False)
353 salt = uuid4().hex
354 user_info["_admin"]["salt"] = salt
355 if "password" in user_info:
356 user_info["password"] = sha256(
357 user_info["password"].encode("utf-8") + salt.encode("utf-8")
358 ).hexdigest()
359 # "projects" are not stored any more
360 if "projects" in user_info:
361 del user_info["projects"]
362 self.db.create(self.users_collection, user_info)
363 return {"username": user_info["username"], "_id": user_info["_id"]}
364
365 def update_user(self, user_info):
366 """
367 Change the user name and/or password.
368
369 :param user_info: user info modifications
370 """
371 uid = user_info["_id"]
372 user_data = self.db.get_one(
373 self.users_collection, {BaseTopic.id_field("users", uid): uid}
374 )
375 BaseTopic.format_on_edit(user_data, user_info)
376 # User Name
377 usnm = user_info.get("username")
378 if usnm:
379 user_data["username"] = usnm
380 # If password is given and is not already encripted
381 pswd = user_info.get("password")
382 if pswd and (
383 len(pswd) != 64 or not re.match("[a-fA-F0-9]*", pswd)
384 ): # TODO: Improve check?
385 salt = uuid4().hex
386 if "_admin" not in user_data:
387 user_data["_admin"] = {}
388 user_data["_admin"]["salt"] = salt
389 user_data["password"] = sha256(
390 pswd.encode("utf-8") + salt.encode("utf-8")
391 ).hexdigest()
392 # Project-Role Mappings
393 # TODO: Check that user_info NEVER includes "project_role_mappings"
394 if "project_role_mappings" not in user_data:
395 user_data["project_role_mappings"] = []
396 for prm in user_info.get("add_project_role_mappings", []):
397 user_data["project_role_mappings"].append(prm)
398 for prm in user_info.get("remove_project_role_mappings", []):
399 for pidf in ["project", "project_name"]:
400 for ridf in ["role", "role_name"]:
401 try:
402 user_data["project_role_mappings"].remove(
403 {"role": prm[ridf], "project": prm[pidf]}
404 )
405 except KeyError:
406 pass
407 except ValueError:
408 pass
409 idf = BaseTopic.id_field("users", uid)
410 self.db.set_one(self.users_collection, {idf: uid}, user_data)
411 if user_info.get("remove_project_role_mappings"):
412 idf = "user_id" if idf == "_id" else idf
413 self.db.del_list(self.tokens_collection, {idf: uid})
414
415 def delete_user(self, user_id):
416 """
417 Delete user.
418
419 :param user_id: user identifier.
420 :raises AuthconnOperationException: if user deletion failed.
421 """
422 self.db.del_one(self.users_collection, {"_id": user_id})
423 self.db.del_list(self.tokens_collection, {"user_id": user_id})
424 return True
425
426 def get_user_list(self, filter_q=None):
427 """
428 Get user list.
429
430 :param filter_q: dictionary to filter user list by:
431 name (username is also admitted). If a user id is equal to the filter name, it is also provided
432 other
433 :return: returns a list of users.
434 """
435 filt = filter_q or {}
436 if "name" in filt: # backward compatibility
437 filt["username"] = filt.pop("name")
438 if filt.get("username") and is_valid_uuid(filt["username"]):
439 # username cannot be a uuid. If this is the case, change from username to _id
440 filt["_id"] = filt.pop("username")
441 users = self.db.get_list(self.users_collection, filt)
442 project_id_name = {}
443 role_id_name = {}
444 for user in users:
445 prms = user.get("project_role_mappings")
446 projects = user.get("projects")
447 if prms:
448 projects = []
449 # add project_name and role_name. Generate projects for backward compatibility
450 for prm in prms:
451 project_id = prm["project"]
452 if project_id not in project_id_name:
453 pr = self.db.get_one(
454 self.projects_collection,
455 {BaseTopic.id_field("projects", project_id): project_id},
456 fail_on_empty=False,
457 )
458 project_id_name[project_id] = pr["name"] if pr else None
459 prm["project_name"] = project_id_name[project_id]
460 if prm["project_name"] not in projects:
461 projects.append(prm["project_name"])
462
463 role_id = prm["role"]
464 if role_id not in role_id_name:
465 role = self.db.get_one(
466 self.roles_collection,
467 {BaseTopic.id_field("roles", role_id): role_id},
468 fail_on_empty=False,
469 )
470 role_id_name[role_id] = role["name"] if role else None
471 prm["role_name"] = role_id_name[role_id]
472 user["projects"] = projects # for backward compatibility
473 elif projects:
474 # user created with an old version. Create a project_role mapping with role project_admin
475 user["project_role_mappings"] = []
476 role = self.db.get_one(
477 self.roles_collection,
478 {BaseTopic.id_field("roles", "project_admin"): "project_admin"},
479 )
480 for p_id_name in projects:
481 pr = self.db.get_one(
482 self.projects_collection,
483 {BaseTopic.id_field("projects", p_id_name): p_id_name},
484 )
485 prm = {
486 "project": pr["_id"],
487 "project_name": pr["name"],
488 "role_name": "project_admin",
489 "role": role["_id"],
490 }
491 user["project_role_mappings"].append(prm)
492 else:
493 user["projects"] = []
494 user["project_role_mappings"] = []
495
496 return users
497
498 def get_project_list(self, filter_q={}):
499 """
500 Get role list.
501
502 :return: returns the list of projects.
503 """
504 return self.db.get_list(self.projects_collection, filter_q)
505
506 def create_project(self, project_info):
507 """
508 Create a project.
509
510 :param project: full project info.
511 :return: the internal id of the created project
512 :raises AuthconnOperationException: if project creation failed.
513 """
514 pid = self.db.create(self.projects_collection, project_info)
515 return pid
516
517 def delete_project(self, project_id):
518 """
519 Delete a project.
520
521 :param project_id: project identifier.
522 :raises AuthconnOperationException: if project deletion failed.
523 """
524 idf = BaseTopic.id_field("projects", project_id)
525 r = self.db.del_one(self.projects_collection, {idf: project_id})
526 idf = "project_id" if idf == "_id" else "project_name"
527 self.db.del_list(self.tokens_collection, {idf: project_id})
528 return r
529
530 def update_project(self, project_id, project_info):
531 """
532 Change the name of a project
533
534 :param project_id: project to be changed
535 :param project_info: full project info
536 :return: None
537 :raises AuthconnOperationException: if project update failed.
538 """
539 self.db.set_one(
540 self.projects_collection,
541 {BaseTopic.id_field("projects", project_id): project_id},
542 project_info,
543 )