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