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