bug 739 reload roles info when there is a change at roles
[osm/NBI.git] / osm_nbi / auth.py
1 # -*- coding: utf-8 -*-
2
3 # Copyright 2018 Whitestack, LLC
4 # Copyright 2018 Telefonica S.A.
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 alfonso.tiernosepulveda@telefonica.com
20 ##
21
22
23 """
24 Authenticator is responsible for authenticating the users,
25 create the tokens unscoped and scoped, retrieve the role
26 list inside the projects that they are inserted
27 """
28
29 __author__ = "Eduardo Sousa <esousa@whitestack.com>; Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
30 __date__ = "$27-jul-2018 23:59:59$"
31
32 import cherrypy
33 import logging
34 import yaml
35 from base64 import standard_b64decode
36 from copy import deepcopy
37 # from functools import reduce
38 from hashlib import sha256
39 from http import HTTPStatus
40 from random import choice as random_choice
41 from time import time
42 from os import path
43 from base_topic import BaseTopic # To allow project names in project_id
44
45 from authconn import AuthException
46 from authconn_keystone import AuthconnKeystone
47 from osm_common import dbmongo
48 from osm_common import dbmemory
49 from osm_common.dbbase import DbException
50
51
52 class Authenticator:
53 """
54 This class should hold all the mechanisms for User Authentication and
55 Authorization. Initially it should support Openstack Keystone as a
56 backend through a plugin model where more backends can be added and a
57 RBAC model to manage permissions on operations.
58 This class must be threading safe
59 """
60
61 periodin_db_pruning = 60 * 30 # for the internal backend only. every 30 minutes expired tokens will be pruned
62
63 def __init__(self):
64 """
65 Authenticator initializer. Setup the initial state of the object,
66 while it waits for the config dictionary and database initialization.
67 """
68 self.backend = None
69 self.config = None
70 self.db = None
71 self.tokens_cache = dict()
72 self.next_db_prune_time = 0 # time when next cleaning of expired tokens must be done
73 self.resources_to_operations_file = None
74 self.roles_to_operations_file = None
75 self.resources_to_operations_mapping = {}
76 self.operation_to_allowed_roles = {}
77 self.logger = logging.getLogger("nbi.authenticator")
78 self.operations = []
79
80 def start(self, config):
81 """
82 Method to configure the Authenticator object. This method should be called
83 after object creation. It is responsible by initializing the selected backend,
84 as well as the initialization of the database connection.
85
86 :param config: dictionary containing the relevant parameters for this object.
87 """
88 self.config = config
89
90 try:
91 if not self.db:
92 if config["database"]["driver"] == "mongo":
93 self.db = dbmongo.DbMongo()
94 self.db.db_connect(config["database"])
95 elif config["database"]["driver"] == "memory":
96 self.db = dbmemory.DbMemory()
97 self.db.db_connect(config["database"])
98 else:
99 raise AuthException("Invalid configuration param '{}' at '[database]':'driver'"
100 .format(config["database"]["driver"]))
101 if not self.backend:
102 if config["authentication"]["backend"] == "keystone":
103 self.backend = AuthconnKeystone(self.config["authentication"])
104 elif config["authentication"]["backend"] == "internal":
105 self._internal_tokens_prune()
106 else:
107 raise AuthException("Unknown authentication backend: {}"
108 .format(config["authentication"]["backend"]))
109 if not self.resources_to_operations_file:
110 if "resources_to_operations" in config["rbac"]:
111 self.resources_to_operations_file = config["rbac"]["resources_to_operations"]
112 else:
113 possible_paths = (
114 __file__[:__file__.rfind("auth.py")] + "resources_to_operations.yml",
115 "./resources_to_operations.yml"
116 )
117 for config_file in possible_paths:
118 if path.isfile(config_file):
119 self.resources_to_operations_file = config_file
120 break
121 if not self.resources_to_operations_file:
122 raise AuthException("Invalid permission configuration: resources_to_operations file missing")
123 if not self.roles_to_operations_file:
124 if "roles_to_operations" in config["rbac"]:
125 self.roles_to_operations_file = config["rbac"]["roles_to_operations"]
126 else:
127 possible_paths = (
128 __file__[:__file__.rfind("auth.py")] + "roles_to_operations.yml",
129 "./roles_to_operations.yml"
130 )
131 for config_file in possible_paths:
132 if path.isfile(config_file):
133 self.roles_to_operations_file = config_file
134 break
135 if not self.roles_to_operations_file:
136 raise AuthException("Invalid permission configuration: roles_to_operations file missing")
137 except Exception as e:
138 raise AuthException(str(e))
139
140 def stop(self):
141 try:
142 if self.db:
143 self.db.db_disconnect()
144 except DbException as e:
145 raise AuthException(str(e), http_code=e.http_code)
146
147 def init_db(self, target_version='1.0'):
148 """
149 Check if the database has been initialized, with at least one user. If not, create the required tables
150 and insert the predefined mappings between roles and permissions.
151
152 :param target_version: schema version that should be present in the database.
153 :return: None if OK, exception if error or version is different.
154 """
155 # Always reads operation to resource mapping from file (this is static, no need to store it in MongoDB)
156 # Operations encoding: "<METHOD> <URL>"
157 # Note: it is faster to rewrite the value than to check if it is already there or not
158 if self.config["authentication"]["backend"] == "internal":
159 return
160
161 with open(self.resources_to_operations_file, "r") as stream:
162 resources_to_operations_yaml = yaml.load(stream)
163
164 for resource, operation in resources_to_operations_yaml["resources_to_operations"].items():
165 if operation not in self.operations:
166 self.operations.append(operation)
167 self.resources_to_operations_mapping[resource] = operation
168
169 records = self.db.get_list("roles_operations")
170
171 # Loading permissions to MongoDB if there is not any permission.
172 if not records:
173 with open(self.roles_to_operations_file, "r") as stream:
174 roles_to_operations_yaml = yaml.load(stream)
175
176 role_names = []
177 for role_with_operations in roles_to_operations_yaml["roles"]:
178 # Verifying if role already exists. If it does, raise exception
179 if role_with_operations["name"] not in role_names:
180 role_names.append(role_with_operations["name"])
181 else:
182 raise AuthException("Duplicated role name '{}' at file '{}''"
183 .format(role_with_operations["name"], self.roles_to_operations_file))
184
185 if not role_with_operations["permissions"]:
186 continue
187
188 for permission, is_allowed in role_with_operations["permissions"].items():
189 if not isinstance(is_allowed, bool):
190 raise AuthException("Invalid value for permission '{}' at role '{}'; at file '{}'"
191 .format(permission, role_with_operations["name"],
192 self.roles_to_operations_file))
193
194 # TODO chek permission is ok
195 if permission[-1] == ":":
196 raise AuthException("Invalid permission '{}' terminated in ':' for role '{}'; at file {}"
197 .format(permission, role_with_operations["name"],
198 self.roles_to_operations_file))
199
200 if "default" not in role_with_operations["permissions"]:
201 role_with_operations["permissions"]["default"] = False
202 if "admin" not in role_with_operations["permissions"]:
203 role_with_operations["permissions"]["admin"] = False
204
205 now = time()
206 role_with_operations["_admin"] = {
207 "created": now,
208 "modified": now,
209 }
210
211 if self.config["authentication"]["backend"] != "internal" and \
212 role_with_operations["name"] != "anonymous":
213
214 backend_roles = self.backend.get_role_list(filter_q={"name": role_with_operations["name"]})
215
216 if backend_roles:
217 backend_id = backend_roles[0]["_id"]
218 else:
219 backend_id = self.backend.create_role(role_with_operations["name"])
220 role_with_operations["_id"] = backend_id
221
222 self.db.create("roles_operations", role_with_operations)
223
224 if self.config["authentication"]["backend"] != "internal":
225 self.backend.assign_role_to_user("admin", "admin", "system_admin")
226
227 self.load_operation_to_allowed_roles()
228
229 def load_operation_to_allowed_roles(self):
230 """
231 Fills the internal self.operation_to_allowed_roles based on database role content and self.operations
232 It works in a shadow copy and replace at the end to allow other threads working with the old copy
233 :return: None
234 """
235
236 permissions = {oper: [] for oper in self.operations}
237 records = self.db.get_list("roles_operations")
238
239 ignore_fields = ["_id", "_admin", "name", "default"]
240 for record in records:
241 record_permissions = {oper: record["permissions"].get("default", False) for oper in self.operations}
242 operations_joined = [(oper, value) for oper, value in record["permissions"].items()
243 if oper not in ignore_fields]
244 operations_joined.sort(key=lambda x: x[0].count(":"))
245
246 for oper in operations_joined:
247 match = list(filter(lambda x: x.find(oper[0]) == 0, record_permissions.keys()))
248
249 for m in match:
250 record_permissions[m] = oper[1]
251
252 allowed_operations = [k for k, v in record_permissions.items() if v is True]
253
254 for allowed_op in allowed_operations:
255 permissions[allowed_op].append(record["name"])
256
257 self.operation_to_allowed_roles = permissions
258
259 def authorize(self):
260 token = None
261 user_passwd64 = None
262 try:
263 # 1. Get token Authorization bearer
264 auth = cherrypy.request.headers.get("Authorization")
265 if auth:
266 auth_list = auth.split(" ")
267 if auth_list[0].lower() == "bearer":
268 token = auth_list[-1]
269 elif auth_list[0].lower() == "basic":
270 user_passwd64 = auth_list[-1]
271 if not token:
272 if cherrypy.session.get("Authorization"):
273 # 2. Try using session before request a new token. If not, basic authentication will generate
274 token = cherrypy.session.get("Authorization")
275 if token == "logout":
276 token = None # force Unauthorized response to insert user password again
277 elif user_passwd64 and cherrypy.request.config.get("auth.allow_basic_authentication"):
278 # 3. Get new token from user password
279 user = None
280 passwd = None
281 try:
282 user_passwd = standard_b64decode(user_passwd64).decode()
283 user, _, passwd = user_passwd.partition(":")
284 except Exception:
285 pass
286 outdata = self.new_token(None, {"username": user, "password": passwd})
287 token = outdata["id"]
288 cherrypy.session['Authorization'] = token
289 if self.config["authentication"]["backend"] == "internal":
290 return self._internal_authorize(token)
291 else:
292 if not token:
293 raise AuthException("Needed a token or Authorization http header",
294 http_code=HTTPStatus.UNAUTHORIZED)
295 try:
296 token_info = self.backend.validate_token(token)
297 # TODO add to token info remote host, port
298
299 self.check_permissions(token_info, cherrypy.request.path_info,
300 cherrypy.request.method)
301 return token_info
302 except AuthException:
303 self.del_token(token)
304 raise
305 except AuthException as e:
306 if cherrypy.session.get('Authorization'):
307 del cherrypy.session['Authorization']
308 cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e)
309 raise AuthException(str(e))
310
311 def new_token(self, session, indata, remote):
312 if self.config["authentication"]["backend"] == "internal":
313 return self._internal_new_token(session, indata, remote)
314 else:
315 current_token = None
316 if session:
317 current_token = session.get("token")
318 token_info = self.backend.authenticate(
319 user=indata.get("username"),
320 password=indata.get("username"),
321 token=current_token,
322 project=indata.get("project_id")
323 )
324
325 # if indata.get("username"):
326 # token, projects = self.backend.authenticate_with_user_password(
327 # indata.get("username"), indata.get("password"))
328 # elif session:
329 # token, projects = self.backend.authenticate_with_token(
330 # session.get("id"), indata.get("project_id"))
331 # else:
332 # raise AuthException("Provide credentials: username/password or Authorization Bearer token",
333 # http_code=HTTPStatus.UNAUTHORIZED)
334 #
335 # if indata.get("project_id"):
336 # project_id = indata.get("project_id")
337 # if project_id not in projects:
338 # raise AuthException("Project {} not allowed for this user".format(project_id),
339 # http_code=HTTPStatus.UNAUTHORIZED)
340 # else:
341 # project_id = projects[0]
342 #
343 # if not session:
344 # token, projects = self.backend.authenticate_with_token(token, project_id)
345 #
346 # if project_id == "admin":
347 # session_admin = True
348 # else:
349 # session_admin = reduce(lambda x, y: x or (True if y == "admin" else False),
350 # projects, False)
351
352 now = time()
353 new_session = {
354 "_id": token_info["_id"],
355 "id": token_info["_id"],
356 "issued_at": now,
357 "expires": token_info.get("expires", now + 3600),
358 "project_id": token_info["project_id"],
359 "username": token_info.get("username") or session.get("username"),
360 "remote_port": remote.port,
361 "admin": True if token_info.get("project_name") == "admin" else False # TODO put admin in RBAC
362 }
363
364 if remote.name:
365 new_session["remote_host"] = remote.name
366 elif remote.ip:
367 new_session["remote_host"] = remote.ip
368
369 # TODO: check if this can be avoided. Backend may provide enough information
370 self.tokens_cache[token_info["_id"]] = new_session
371
372 return deepcopy(new_session)
373
374 def get_token_list(self, session):
375 if self.config["authentication"]["backend"] == "internal":
376 return self._internal_get_token_list(session)
377 else:
378 # TODO: check if this can be avoided. Backend may provide enough information
379 return [deepcopy(token) for token in self.tokens_cache.values()
380 if token["username"] == session["username"]]
381
382 def get_token(self, session, token):
383 if self.config["authentication"]["backend"] == "internal":
384 return self._internal_get_token(session, token)
385 else:
386 # TODO: check if this can be avoided. Backend may provide enough information
387 token_value = self.tokens_cache.get(token)
388 if not token_value:
389 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
390 if token_value["username"] != session["username"] and not session["admin"]:
391 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
392 return token_value
393
394 def del_token(self, token):
395 if self.config["authentication"]["backend"] == "internal":
396 return self._internal_del_token(token)
397 else:
398 try:
399 self.backend.revoke_token(token)
400 del self.tokens_cache[token]
401 return "token '{}' deleted".format(token)
402 except KeyError:
403 raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
404
405 def check_permissions(self, session, url, method):
406 self.logger.info("Session: {}".format(session))
407 self.logger.info("URL: {}".format(url))
408 self.logger.info("Method: {}".format(method))
409
410 key, parameters = self._normalize_url(url, method)
411
412 # TODO: Check if parameters might be useful for the decision
413
414 operation = self.resources_to_operations_mapping[key]
415 roles_required = self.operation_to_allowed_roles[operation]
416 roles_allowed = [role["name"] for role in session["roles"]]
417
418 # fills session["admin"] if some roles allows it
419 session["admin"] = False
420 for role in roles_allowed:
421 if role in self.operation_to_allowed_roles["admin"]:
422 session["admin"] = True
423 break
424
425 if "anonymous" in roles_required:
426 return
427
428 for role in roles_allowed:
429 if role in roles_required:
430 return
431
432 raise AuthException("Access denied: lack of permissions.")
433
434 def get_user_list(self):
435 return self.backend.get_user_list()
436
437 def _normalize_url(self, url, method):
438 # Removing query strings
439 normalized_url = url if '?' not in url else url[:url.find("?")]
440 normalized_url_splitted = normalized_url.split("/")
441 parameters = {}
442
443 filtered_keys = [key for key in self.resources_to_operations_mapping.keys()
444 if method in key.split()[0]]
445
446 for idx, path_part in enumerate(normalized_url_splitted):
447 tmp_keys = []
448 for tmp_key in filtered_keys:
449 splitted = tmp_key.split()[1].split("/")
450 if idx >= len(splitted):
451 continue
452 elif "<" in splitted[idx] and ">" in splitted[idx]:
453 if splitted[idx] == "<artifactPath>":
454 tmp_keys.append(tmp_key)
455 continue
456 elif idx == len(normalized_url_splitted) - 1 and \
457 len(normalized_url_splitted) != len(splitted):
458 continue
459 else:
460 tmp_keys.append(tmp_key)
461 elif splitted[idx] == path_part:
462 if idx == len(normalized_url_splitted) - 1 and \
463 len(normalized_url_splitted) != len(splitted):
464 continue
465 else:
466 tmp_keys.append(tmp_key)
467 filtered_keys = tmp_keys
468 if len(filtered_keys) == 1 and \
469 filtered_keys[0].split("/")[-1] == "<artifactPath>":
470 break
471
472 if len(filtered_keys) == 0:
473 raise AuthException("Cannot make an authorization decision. URL not found. URL: {0}".format(url))
474 elif len(filtered_keys) > 1:
475 raise AuthException("Cannot make an authorization decision. Multiple URLs found. URL: {0}".format(url))
476
477 filtered_key = filtered_keys[0]
478
479 for idx, path_part in enumerate(filtered_key.split()[1].split("/")):
480 if "<" in path_part and ">" in path_part:
481 if path_part == "<artifactPath>":
482 parameters[path_part[1:-1]] = "/".join(normalized_url_splitted[idx:])
483 else:
484 parameters[path_part[1:-1]] = normalized_url_splitted[idx]
485
486 return filtered_key, parameters
487
488 def _internal_authorize(self, token_id):
489 try:
490 if not token_id:
491 raise AuthException("Needed a token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
492 # try to get from cache first
493 now = time()
494 session = self.tokens_cache.get(token_id)
495 if session and session["expires"] < now:
496 # delete token. MUST be done with care, as another thread maybe already delete it. Do not use del
497 self.tokens_cache.pop(token_id, None)
498 session = None
499 if session:
500 return session
501
502 # get from database if not in cache
503 session = self.db.get_one("tokens", {"_id": token_id})
504 if session["expires"] < now:
505 raise AuthException("Expired Token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
506 self.tokens_cache[token_id] = session
507 return session
508 except DbException as e:
509 if e.http_code == HTTPStatus.NOT_FOUND:
510 raise AuthException("Invalid Token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
511 else:
512 raise
513
514 except AuthException:
515 if self.config["global"].get("test.user_not_authorized"):
516 return {"id": "fake-token-id-for-test",
517 "project_id": self.config["global"].get("test.project_not_authorized", "admin"),
518 "username": self.config["global"]["test.user_not_authorized"], "admin": True}
519 else:
520 raise
521
522 def _internal_new_token(self, session, indata, remote):
523 now = time()
524 user_content = None
525
526 # Try using username/password
527 if indata.get("username"):
528 user_rows = self.db.get_list("users", {"username": indata.get("username")})
529 if user_rows:
530 user_content = user_rows[0]
531 salt = user_content["_admin"]["salt"]
532 shadow_password = sha256(indata.get("password", "").encode('utf-8') + salt.encode('utf-8')).hexdigest()
533 if shadow_password != user_content["password"]:
534 user_content = None
535 if not user_content:
536 raise AuthException("Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED)
537 elif session:
538 user_rows = self.db.get_list("users", {"username": session["username"]})
539 if user_rows:
540 user_content = user_rows[0]
541 else:
542 raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED)
543 else:
544 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
545 http_code=HTTPStatus.UNAUTHORIZED)
546
547 token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
548 for _ in range(0, 32))
549 project_id = indata.get("project_id")
550 if project_id:
551 if project_id != "admin":
552 # To allow project names in project_id
553 proj = self.db.get_one("projects", {BaseTopic.id_field("projects", project_id): project_id})
554 if proj["_id"] not in user_content["projects"] and proj["name"] not in user_content["projects"]:
555 raise AuthException("project {} not allowed for this user"
556 .format(project_id), http_code=HTTPStatus.UNAUTHORIZED)
557 else:
558 project_id = user_content["projects"][0]
559 if project_id == "admin":
560 session_admin = True
561 else:
562 # To allow project names in project_id
563 project = self.db.get_one("projects", {BaseTopic.id_field("projects", project_id): project_id})
564 session_admin = project.get("admin", False)
565 new_session = {"issued_at": now, "expires": now + 3600,
566 "_id": token_id, "id": token_id, "project_id": project_id, "username": user_content["username"],
567 "remote_port": remote.port, "admin": session_admin}
568 if remote.name:
569 new_session["remote_host"] = remote.name
570 elif remote.ip:
571 new_session["remote_host"] = remote.ip
572
573 self.tokens_cache[token_id] = new_session
574 self.db.create("tokens", new_session)
575 # check if database must be prune
576 self._internal_tokens_prune(now)
577 return deepcopy(new_session)
578
579 def _internal_get_token_list(self, session):
580 now = time()
581 token_list = self.db.get_list("tokens", {"username": session["username"], "expires.gt": now})
582 return token_list
583
584 def _internal_get_token(self, session, token_id):
585 token_value = self.db.get_one("tokens", {"_id": token_id}, fail_on_empty=False)
586 if not token_value:
587 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
588 if token_value["username"] != session["username"] and not session["admin"]:
589 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
590 return token_value
591
592 def _internal_del_token(self, token_id):
593 try:
594 self.tokens_cache.pop(token_id, None)
595 self.db.del_one("tokens", {"_id": token_id})
596 return "token '{}' deleted".format(token_id)
597 except DbException as e:
598 if e.http_code == HTTPStatus.NOT_FOUND:
599 raise AuthException("Token '{}' not found".format(token_id), http_code=HTTPStatus.NOT_FOUND)
600 else:
601 raise
602
603 def _internal_tokens_prune(self, now=None):
604 now = now or time()
605 if not self.next_db_prune_time or self.next_db_prune_time >= now:
606 self.db.del_list("tokens", {"expires.lt": now})
607 self.next_db_prune_time = self.periodin_db_pruning + now
608 self.tokens_cache.clear() # force to reload tokens from database