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