Fix 1504 - NBI-HA do not manage correctly new roles (RBAC)
[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 http import HTTPStatus
39 from time import time
40 from os import path
41
42 from osm_nbi.authconn import AuthException, AuthconnException, AuthExceptionUnauthorized
43 from osm_nbi.authconn_keystone import AuthconnKeystone
44 from osm_nbi.authconn_internal import AuthconnInternal
45 from osm_nbi.authconn_tacacs import AuthconnTacacs
46 from osm_common import dbmemory, dbmongo, msglocal, msgkafka
47 from osm_common.dbbase import DbException
48 from osm_nbi.validation import is_valid_uuid
49 from itertools import chain
50 from uuid import uuid4
51
52
53 class Authenticator:
54 """
55 This class should hold all the mechanisms for User Authentication and
56 Authorization. Initially it should support Openstack Keystone as a
57 backend through a plugin model where more backends can be added and a
58 RBAC model to manage permissions on operations.
59 This class must be threading safe
60 """
61
62 periodin_db_pruning = 60 * 30 # for the internal backend only. every 30 minutes expired tokens will be pruned
63 token_limit = 500 # when reached, the token cache will be cleared
64
65 def __init__(self, valid_methods, valid_query_string):
66 """
67 Authenticator initializer. Setup the initial state of the object,
68 while it waits for the config dictionary and database initialization.
69 """
70 self.backend = None
71 self.config = None
72 self.db = None
73 self.msg = None
74 self.tokens_cache = dict()
75 self.next_db_prune_time = 0 # time when next cleaning of expired tokens must be done
76 self.roles_to_operations_file = None
77 # self.roles_to_operations_table = None
78 self.resources_to_operations_mapping = {}
79 self.operation_to_allowed_roles = {}
80 self.logger = logging.getLogger("nbi.authenticator")
81 self.role_permissions = []
82 self.valid_methods = valid_methods
83 self.valid_query_string = valid_query_string
84 self.system_admin_role_id = None # system_role id
85 self.test_project_id = None # test_project_id
86
87 def start(self, config):
88 """
89 Method to configure the Authenticator object. This method should be called
90 after object creation. It is responsible by initializing the selected backend,
91 as well as the initialization of the database connection.
92
93 :param config: dictionary containing the relevant parameters for this object.
94 """
95 self.config = config
96
97 try:
98 if not self.db:
99 if config["database"]["driver"] == "mongo":
100 self.db = dbmongo.DbMongo()
101 self.db.db_connect(config["database"])
102 elif config["database"]["driver"] == "memory":
103 self.db = dbmemory.DbMemory()
104 self.db.db_connect(config["database"])
105 else:
106 raise AuthException("Invalid configuration param '{}' at '[database]':'driver'"
107 .format(config["database"]["driver"]))
108 if not self.msg:
109 if config["message"]["driver"] == "local":
110 self.msg = msglocal.MsgLocal()
111 self.msg.connect(config["message"])
112 elif config["message"]["driver"] == "kafka":
113 self.msg = msgkafka.MsgKafka()
114 self.msg.connect(config["message"])
115 else:
116 raise AuthException("Invalid configuration param '{}' at '[message]':'driver'"
117 .format(config["message"]["driver"]))
118 if not self.backend:
119 if config["authentication"]["backend"] == "keystone":
120 self.backend = AuthconnKeystone(self.config["authentication"], self.db, self.role_permissions)
121 elif config["authentication"]["backend"] == "internal":
122 self.backend = AuthconnInternal(self.config["authentication"], self.db, self.role_permissions)
123 self._internal_tokens_prune("tokens")
124 elif config["authentication"]["backend"] == "tacacs":
125 self.backend = AuthconnTacacs(self.config["authentication"], self.db, self.role_permissions)
126 self._internal_tokens_prune("tokens_tacacs")
127 else:
128 raise AuthException("Unknown authentication backend: {}"
129 .format(config["authentication"]["backend"]))
130
131 if not self.roles_to_operations_file:
132 if "roles_to_operations" in config["rbac"]:
133 self.roles_to_operations_file = config["rbac"]["roles_to_operations"]
134 else:
135 possible_paths = (
136 __file__[:__file__.rfind("auth.py")] + "roles_to_operations.yml",
137 "./roles_to_operations.yml"
138 )
139 for config_file in possible_paths:
140 if path.isfile(config_file):
141 self.roles_to_operations_file = config_file
142 break
143 if not self.roles_to_operations_file:
144 raise AuthException("Invalid permission configuration: roles_to_operations file missing")
145
146 # load role_permissions
147 def load_role_permissions(method_dict):
148 for k in method_dict:
149 if k == "ROLE_PERMISSION":
150 for method in chain(method_dict.get("METHODS", ()), method_dict.get("TODO", ())):
151 permission = method_dict["ROLE_PERMISSION"] + method.lower()
152 if permission not in self.role_permissions:
153 self.role_permissions.append(permission)
154 elif k in ("TODO", "METHODS"):
155 continue
156 elif method_dict[k]:
157 load_role_permissions(method_dict[k])
158
159 load_role_permissions(self.valid_methods)
160 for query_string in self.valid_query_string:
161 for method in ("get", "put", "patch", "post", "delete"):
162 permission = query_string.lower() + ":" + method
163 if permission not in self.role_permissions:
164 self.role_permissions.append(permission)
165
166 # get ids of role system_admin and test project
167 role_system_admin = self.db.get_one("roles", {"name": "system_admin"}, fail_on_empty=False)
168 if role_system_admin:
169 self.system_admin_role_id = role_system_admin["_id"]
170 test_project_name = self.config["authentication"].get("project_not_authorized", "admin")
171 test_project = self.db.get_one("projects", {"name": test_project_name}, fail_on_empty=False)
172 if test_project:
173 self.test_project_id = test_project["_id"]
174
175 except Exception as e:
176 raise AuthException(str(e))
177
178 def stop(self):
179 try:
180 if self.db:
181 self.db.db_disconnect()
182 except DbException as e:
183 raise AuthException(str(e), http_code=e.http_code)
184
185 def create_admin_project(self):
186 """
187 Creates a new project 'admin' into database if it doesn't exist. Useful for initialization.
188 :return: _id identity of the 'admin' project
189 """
190
191 # projects = self.db.get_one("projects", fail_on_empty=False, fail_on_more=False)
192 project_desc = {"name": "admin"}
193 projects = self.backend.get_project_list(project_desc)
194 if projects:
195 return projects[0]["_id"]
196 now = time()
197 project_desc["_id"] = str(uuid4())
198 project_desc["_admin"] = {"created": now, "modified": now}
199 pid = self.backend.create_project(project_desc)
200 self.logger.info("Project '{}' created at database".format(project_desc["name"]))
201 return pid
202
203 def create_admin_user(self, project_id):
204 """
205 Creates a new user admin/admin into database if database is empty. Useful for initialization
206 :return: _id identity of the inserted data, or None
207 """
208 # users = self.db.get_one("users", fail_on_empty=False, fail_on_more=False)
209 users = self.backend.get_user_list()
210 if users:
211 return None
212 # user_desc = {"username": "admin", "password": "admin", "projects": [project_id]}
213 now = time()
214 user_desc = {"username": "admin", "password": "admin", "_admin": {"created": now, "modified": now}}
215 if project_id:
216 pid = project_id
217 else:
218 # proj = self.db.get_one("projects", {"name": "admin"}, fail_on_empty=False, fail_on_more=False)
219 proj = self.backend.get_project_list({"name": "admin"})
220 pid = proj[0]["_id"] if proj else None
221 # role = self.db.get_one("roles", {"name": "system_admin"}, fail_on_empty=False, fail_on_more=False)
222 roles = self.backend.get_role_list({"name": "system_admin"})
223 if pid and roles:
224 user_desc["project_role_mappings"] = [{"project": pid, "role": roles[0]["_id"]}]
225 uid = self.backend.create_user(user_desc)
226 self.logger.info("User '{}' created at database".format(user_desc["username"]))
227 return uid
228
229 def init_db(self, target_version='1.0'):
230 """
231 Check if the database has been initialized, with at least one user. If not, create the required tables
232 and insert the predefined mappings between roles and permissions.
233
234 :param target_version: schema version that should be present in the database.
235 :return: None if OK, exception if error or version is different.
236 """
237
238 records = self.backend.get_role_list()
239
240 # Loading permissions to AUTH. At lease system_admin must be present.
241 if not records or not next((r for r in records if r["name"] == "system_admin"), None):
242 with open(self.roles_to_operations_file, "r") as stream:
243 roles_to_operations_yaml = yaml.load(stream, Loader=yaml.Loader)
244
245 role_names = []
246 for role_with_operations in roles_to_operations_yaml["roles"]:
247 # Verifying if role already exists. If it does, raise exception
248 if role_with_operations["name"] not in role_names:
249 role_names.append(role_with_operations["name"])
250 else:
251 raise AuthException("Duplicated role name '{}' at file '{}''"
252 .format(role_with_operations["name"], self.roles_to_operations_file))
253
254 if not role_with_operations["permissions"]:
255 continue
256
257 for permission, is_allowed in role_with_operations["permissions"].items():
258 if not isinstance(is_allowed, bool):
259 raise AuthException("Invalid value for permission '{}' at role '{}'; at file '{}'"
260 .format(permission, role_with_operations["name"],
261 self.roles_to_operations_file))
262
263 # TODO check permission is ok
264 if permission[-1] == ":":
265 raise AuthException("Invalid permission '{}' terminated in ':' for role '{}'; at file {}"
266 .format(permission, role_with_operations["name"],
267 self.roles_to_operations_file))
268
269 if "default" not in role_with_operations["permissions"]:
270 role_with_operations["permissions"]["default"] = False
271 if "admin" not in role_with_operations["permissions"]:
272 role_with_operations["permissions"]["admin"] = False
273
274 now = time()
275 role_with_operations["_admin"] = {
276 "created": now,
277 "modified": now,
278 }
279
280 # self.db.create(self.roles_to_operations_table, role_with_operations)
281 try:
282 self.backend.create_role(role_with_operations)
283 self.logger.info("Role '{}' created".format(role_with_operations["name"]))
284 except (AuthException, AuthconnException) as e:
285 if role_with_operations["name"] == "system_admin":
286 raise
287 self.logger.error("Role '{}' cannot be created: {}".format(role_with_operations["name"], e))
288
289 # Create admin project&user if required
290 pid = self.create_admin_project()
291 user_id = self.create_admin_user(pid)
292
293 # try to assign system_admin role to user admin if not any user has this role
294 if not user_id:
295 try:
296 users = self.backend.get_user_list()
297 roles = self.backend.get_role_list({"name": "system_admin"})
298 role_id = roles[0]["_id"]
299 user_with_system_admin = False
300 user_admin_id = None
301 for user in users:
302 if not user_admin_id:
303 user_admin_id = user["_id"]
304 if user["username"] == "admin":
305 user_admin_id = user["_id"]
306 for prm in user.get("project_role_mappings", ()):
307 if prm["role"] == role_id:
308 user_with_system_admin = True
309 break
310 if user_with_system_admin:
311 break
312 if not user_with_system_admin:
313 self.backend.update_user({"_id": user_admin_id,
314 "add_project_role_mappings": [{"project": pid, "role": role_id}]})
315 self.logger.info("Added role system admin to user='{}' project=admin".format(user_admin_id))
316 except Exception as e:
317 self.logger.error("Error in Authorization DataBase initialization: {}: {}".format(type(e).__name__, e))
318
319 self.load_operation_to_allowed_roles()
320
321 def load_operation_to_allowed_roles(self):
322 """
323 Fills the internal self.operation_to_allowed_roles based on database role content and self.role_permissions
324 It works in a shadow copy and replace at the end to allow other threads working with the old copy
325 :return: None
326 """
327
328 permissions = {oper: [] for oper in self.role_permissions}
329 # records = self.db.get_list(self.roles_to_operations_table)
330 records = self.backend.get_role_list()
331
332 ignore_fields = ["_id", "_admin", "name", "default"]
333 for record in records:
334 if not record.get("permissions"):
335 continue
336 record_permissions = {oper: record["permissions"].get("default", False) for oper in self.role_permissions}
337 operations_joined = [(oper, value) for oper, value in record["permissions"].items()
338 if oper not in ignore_fields]
339 operations_joined.sort(key=lambda x: x[0].count(":"))
340
341 for oper in operations_joined:
342 match = list(filter(lambda x: x.find(oper[0]) == 0, record_permissions.keys()))
343
344 for m in match:
345 record_permissions[m] = oper[1]
346
347 allowed_operations = [k for k, v in record_permissions.items() if v is True]
348
349 for allowed_op in allowed_operations:
350 permissions[allowed_op].append(record["name"])
351
352 self.operation_to_allowed_roles = permissions
353
354 def authorize(self, role_permission=None, query_string_operations=None, item_id=None):
355 token = None
356 user_passwd64 = None
357 try:
358 # 1. Get token Authorization bearer
359 auth = cherrypy.request.headers.get("Authorization")
360 if auth:
361 auth_list = auth.split(" ")
362 if auth_list[0].lower() == "bearer":
363 token = auth_list[-1]
364 elif auth_list[0].lower() == "basic":
365 user_passwd64 = auth_list[-1]
366 if not token:
367 if cherrypy.session.get("Authorization"):
368 # 2. Try using session before request a new token. If not, basic authentication will generate
369 token = cherrypy.session.get("Authorization")
370 if token == "logout":
371 token = None # force Unauthorized response to insert user password again
372 elif user_passwd64 and cherrypy.request.config.get("auth.allow_basic_authentication"):
373 # 3. Get new token from user password
374 user = None
375 passwd = None
376 try:
377 user_passwd = standard_b64decode(user_passwd64).decode()
378 user, _, passwd = user_passwd.partition(":")
379 except Exception:
380 pass
381 outdata = self.new_token(None, {"username": user, "password": passwd})
382 token = outdata["_id"]
383 cherrypy.session['Authorization'] = token
384
385 if not token:
386 raise AuthException("Needed a token or Authorization http header",
387 http_code=HTTPStatus.UNAUTHORIZED)
388
389 # try to get from cache first
390 now = time()
391 token_info = self.tokens_cache.get(token)
392 if token_info and token_info["expires"] < now:
393 # delete token. MUST be done with care, as another thread maybe already delete it. Do not use del
394 self.tokens_cache.pop(token, None)
395 token_info = None
396
397 # get from database if not in cache
398 if not token_info:
399 token_info = self.backend.validate_token(token)
400 # Clear cache if token limit reached
401 if len(self.tokens_cache) > self.token_limit:
402 self.tokens_cache.clear()
403 self.tokens_cache[token] = token_info
404 # TODO add to token info remote host, port
405
406 if role_permission:
407 RBAC_auth = self.check_permissions(token_info, cherrypy.request.method, role_permission,
408 query_string_operations, item_id)
409 token_info["allow_show_user_project_role"] = RBAC_auth
410
411 return token_info
412 except AuthException as e:
413 if not isinstance(e, AuthExceptionUnauthorized):
414 if cherrypy.session.get('Authorization'):
415 del cherrypy.session['Authorization']
416 cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e)
417 if self.config["authentication"].get("user_not_authorized"):
418 return {"id": "testing-token", "_id": "testing-token",
419 "project_id": self.test_project_id,
420 "username": self.config["authentication"]["user_not_authorized"],
421 "roles": [self.system_admin_role_id],
422 "admin": True, "allow_show_user_project_role": True}
423 raise
424
425 def new_token(self, token_info, indata, remote):
426 new_token_info = self.backend.authenticate(
427 credentials=indata,
428 token_info=token_info,
429 )
430
431 new_token_info["remote_port"] = remote.port
432 if not new_token_info.get("expires"):
433 new_token_info["expires"] = time() + 3600
434 if not new_token_info.get("admin"):
435 new_token_info["admin"] = True if new_token_info.get("project_name") == "admin" else False
436 # TODO put admin in RBAC
437
438 if remote.name:
439 new_token_info["remote_host"] = remote.name
440 elif remote.ip:
441 new_token_info["remote_host"] = remote.ip
442
443 # TODO call self._internal_tokens_prune(now) ?
444 return deepcopy(new_token_info)
445
446 def get_token_list(self, token_info):
447 if self.config["authentication"]["backend"] == "internal":
448 return self._internal_get_token_list(token_info)
449 else:
450 # TODO: check if this can be avoided. Backend may provide enough information
451 return [deepcopy(token) for token in self.tokens_cache.values()
452 if token["username"] == token_info["username"]]
453
454 def get_token(self, token_info, token):
455 if self.config["authentication"]["backend"] == "internal":
456 return self._internal_get_token(token_info, token)
457 else:
458 # TODO: check if this can be avoided. Backend may provide enough information
459 token_value = self.tokens_cache.get(token)
460 if not token_value:
461 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
462 if token_value["username"] != token_info["username"] and not token_info["admin"]:
463 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
464 return token_value
465
466 def del_token(self, token):
467 try:
468 self.backend.revoke_token(token)
469 # self.tokens_cache.pop(token, None)
470 self.remove_token_from_cache(token)
471 return "token '{}' deleted".format(token)
472 except KeyError:
473 raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
474
475 def check_permissions(self, token_info, method, role_permission=None, query_string_operations=None, item_id=None):
476 """
477 Checks that operation has permissions to be done, base on the assigned roles to this user project
478 :param token_info: Dictionary that contains "roles" with a list of assigned roles.
479 This method fills the token_info["admin"] with True or False based on assigned tokens, if any allows admin
480 This will be used among others to hide or not the _admin content of topics
481 :param method: GET,PUT, POST, ...
482 :param role_permission: role permission name of the operation required
483 :param query_string_operations: list of possible admin query strings provided by user. It is checked that the
484 assigned role allows this query string for this method
485 :param item_id: item identifier if included in the URL, None otherwise
486 :return: True if access granted by permission rules, False if access granted by default rules (Bug 853)
487 :raises: AuthExceptionUnauthorized if access denied
488 """
489 self.load_operation_to_allowed_roles()
490
491 roles_required = self.operation_to_allowed_roles[role_permission]
492 roles_allowed = [role["name"] for role in token_info["roles"]]
493
494 # fills token_info["admin"] if some roles allows it
495 token_info["admin"] = False
496 for role in roles_allowed:
497 if role in self.operation_to_allowed_roles["admin:" + method.lower()]:
498 token_info["admin"] = True
499 break
500
501 if "anonymous" in roles_required:
502 return True
503 operation_allowed = False
504 for role in roles_allowed:
505 if role in roles_required:
506 operation_allowed = True
507 # if query_string operations, check if this role allows it
508 if not query_string_operations:
509 return True
510 for query_string_operation in query_string_operations:
511 if role not in self.operation_to_allowed_roles[query_string_operation]:
512 break
513 else:
514 return True
515
516 # Bug 853 - Final Solution
517 # User/Project/Role whole listings are filtered elsewhere
518 # uid, pid, rid = ("user_id", "project_id", "id") if is_valid_uuid(id) else ("username", "project_name", "name")
519 uid = "user_id" if is_valid_uuid(item_id) else "username"
520 if (role_permission in ["projects:get", "projects:id:get", "roles:get", "roles:id:get", "users:get"]) \
521 or (role_permission == "users:id:get" and item_id == token_info[uid]):
522 # or (role_permission == "projects:id:get" and item_id == token_info[pid]) \
523 # or (role_permission == "roles:id:get" and item_id in [role[rid] for role in token_info["roles"]]):
524 return False
525
526 if not operation_allowed:
527 raise AuthExceptionUnauthorized("Access denied: lack of permissions.")
528 else:
529 raise AuthExceptionUnauthorized("Access denied: You have not permissions to use these admin query string")
530
531 def get_user_list(self):
532 return self.backend.get_user_list()
533
534 def _normalize_url(self, url, method):
535 # DEPRECATED !!!
536 # Removing query strings
537 normalized_url = url if '?' not in url else url[:url.find("?")]
538 normalized_url_splitted = normalized_url.split("/")
539 parameters = {}
540
541 filtered_keys = [key for key in self.resources_to_operations_mapping.keys()
542 if method in key.split()[0]]
543
544 for idx, path_part in enumerate(normalized_url_splitted):
545 tmp_keys = []
546 for tmp_key in filtered_keys:
547 splitted = tmp_key.split()[1].split("/")
548 if idx >= len(splitted):
549 continue
550 elif "<" in splitted[idx] and ">" in splitted[idx]:
551 if splitted[idx] == "<artifactPath>":
552 tmp_keys.append(tmp_key)
553 continue
554 elif idx == len(normalized_url_splitted) - 1 and \
555 len(normalized_url_splitted) != len(splitted):
556 continue
557 else:
558 tmp_keys.append(tmp_key)
559 elif splitted[idx] == path_part:
560 if idx == len(normalized_url_splitted) - 1 and \
561 len(normalized_url_splitted) != len(splitted):
562 continue
563 else:
564 tmp_keys.append(tmp_key)
565 filtered_keys = tmp_keys
566 if len(filtered_keys) == 1 and \
567 filtered_keys[0].split("/")[-1] == "<artifactPath>":
568 break
569
570 if len(filtered_keys) == 0:
571 raise AuthException("Cannot make an authorization decision. URL not found. URL: {0}".format(url))
572 elif len(filtered_keys) > 1:
573 raise AuthException("Cannot make an authorization decision. Multiple URLs found. URL: {0}".format(url))
574
575 filtered_key = filtered_keys[0]
576
577 for idx, path_part in enumerate(filtered_key.split()[1].split("/")):
578 if "<" in path_part and ">" in path_part:
579 if path_part == "<artifactPath>":
580 parameters[path_part[1:-1]] = "/".join(normalized_url_splitted[idx:])
581 else:
582 parameters[path_part[1:-1]] = normalized_url_splitted[idx]
583
584 return filtered_key, parameters
585
586 def _internal_get_token_list(self, token_info):
587 now = time()
588 token_list = self.db.get_list("tokens", {"username": token_info["username"], "expires.gt": now})
589 return token_list
590
591 def _internal_get_token(self, token_info, token_id):
592 token_value = self.db.get_one("tokens", {"_id": token_id}, fail_on_empty=False)
593 if not token_value:
594 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
595 if token_value["username"] != token_info["username"] and not token_info["admin"]:
596 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
597 return token_value
598
599 def _internal_tokens_prune(self, token_collection, now=None):
600 now = now or time()
601 if not self.next_db_prune_time or self.next_db_prune_time >= now:
602 self.db.del_list(token_collection, {"expires.lt": now})
603 self.next_db_prune_time = self.periodin_db_pruning + now
604 # self.tokens_cache.clear() # not required any more
605
606 def remove_token_from_cache(self, token=None):
607 if token:
608 self.tokens_cache.pop(token, None)
609 else:
610 self.tokens_cache.clear()
611 self.msg.write("admin", "revoke_token", {"_id": token} if token else None)