b1f73fee0a7fb28cab8c317fedb9eb68a24a98bc
[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 authconn import AuthException, AuthExceptionUnauthorized
43 from authconn_keystone import AuthconnKeystone
44 from authconn_internal import AuthconnInternal # Comment out for testing&debugging, uncomment when ready
45 from osm_common import dbmongo
46 from osm_common import dbmemory
47 from osm_common.dbbase import DbException
48
49 from uuid import uuid4 # For Role _id with internal authentication backend
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.roles_to_operations_table = None
76 self.resources_to_operations_mapping = {}
77 self.operation_to_allowed_roles = {}
78 self.logger = logging.getLogger("nbi.authenticator")
79 self.operations = []
80
81 def start(self, config):
82 """
83 Method to configure the Authenticator object. This method should be called
84 after object creation. It is responsible by initializing the selected backend,
85 as well as the initialization of the database connection.
86
87 :param config: dictionary containing the relevant parameters for this object.
88 """
89 self.config = config
90
91 try:
92 if not self.db:
93 if config["database"]["driver"] == "mongo":
94 self.db = dbmongo.DbMongo()
95 self.db.db_connect(config["database"])
96 elif config["database"]["driver"] == "memory":
97 self.db = dbmemory.DbMemory()
98 self.db.db_connect(config["database"])
99 else:
100 raise AuthException("Invalid configuration param '{}' at '[database]':'driver'"
101 .format(config["database"]["driver"]))
102 if not self.backend:
103 if config["authentication"]["backend"] == "keystone":
104 self.backend = AuthconnKeystone(self.config["authentication"])
105 elif config["authentication"]["backend"] == "internal":
106 self.backend = AuthconnInternal(self.config["authentication"], self.db, self.tokens_cache)
107 self._internal_tokens_prune()
108 else:
109 raise AuthException("Unknown authentication backend: {}"
110 .format(config["authentication"]["backend"]))
111 if not self.resources_to_operations_file:
112 if "resources_to_operations" in config["rbac"]:
113 self.resources_to_operations_file = config["rbac"]["resources_to_operations"]
114 else:
115 possible_paths = (
116 __file__[:__file__.rfind("auth.py")] + "resources_to_operations.yml",
117 "./resources_to_operations.yml"
118 )
119 for config_file in possible_paths:
120 if path.isfile(config_file):
121 self.resources_to_operations_file = config_file
122 break
123 if not self.resources_to_operations_file:
124 raise AuthException("Invalid permission configuration: resources_to_operations file missing")
125 if not self.roles_to_operations_file:
126 if "roles_to_operations" in config["rbac"]:
127 self.roles_to_operations_file = config["rbac"]["roles_to_operations"]
128 else:
129 possible_paths = (
130 __file__[:__file__.rfind("auth.py")] + "roles_to_operations.yml",
131 "./roles_to_operations.yml"
132 )
133 for config_file in possible_paths:
134 if path.isfile(config_file):
135 self.roles_to_operations_file = config_file
136 break
137 if not self.roles_to_operations_file:
138 raise AuthException("Invalid permission configuration: roles_to_operations file missing")
139 if not self.roles_to_operations_table: # PROVISIONAL ?
140 self.roles_to_operations_table = "roles_operations" \
141 if config["authentication"]["backend"] == "keystone" \
142 else "roles"
143 except Exception as e:
144 raise AuthException(str(e))
145
146 def stop(self):
147 try:
148 if self.db:
149 self.db.db_disconnect()
150 except DbException as e:
151 raise AuthException(str(e), http_code=e.http_code)
152
153 def init_db(self, target_version='1.0'):
154 """
155 Check if the database has been initialized, with at least one user. If not, create the required tables
156 and insert the predefined mappings between roles and permissions.
157
158 :param target_version: schema version that should be present in the database.
159 :return: None if OK, exception if error or version is different.
160 """
161 # Always reads operation to resource mapping from file (this is static, no need to store it in MongoDB)
162 # Operations encoding: "<METHOD> <URL>"
163 # Note: it is faster to rewrite the value than to check if it is already there or not
164
165 # PCR 28/05/2019 Commented out to allow initialization for internal backend
166 # if self.config["authentication"]["backend"] == "internal":
167 # return
168
169 with open(self.resources_to_operations_file, "r") as stream:
170 resources_to_operations_yaml = yaml.load(stream)
171
172 for resource, operation in resources_to_operations_yaml["resources_to_operations"].items():
173 if operation not in self.operations:
174 self.operations.append(operation)
175 self.resources_to_operations_mapping[resource] = operation
176
177 records = self.db.get_list(self.roles_to_operations_table)
178
179 # Loading permissions to MongoDB if there is not any permission.
180 if not records:
181 with open(self.roles_to_operations_file, "r") as stream:
182 roles_to_operations_yaml = yaml.load(stream)
183
184 role_names = []
185 for role_with_operations in roles_to_operations_yaml["roles"]:
186 # Verifying if role already exists. If it does, raise exception
187 if role_with_operations["name"] not in role_names:
188 role_names.append(role_with_operations["name"])
189 else:
190 raise AuthException("Duplicated role name '{}' at file '{}''"
191 .format(role_with_operations["name"], self.roles_to_operations_file))
192
193 if not role_with_operations["permissions"]:
194 continue
195
196 for permission, is_allowed in role_with_operations["permissions"].items():
197 if not isinstance(is_allowed, bool):
198 raise AuthException("Invalid value for permission '{}' at role '{}'; at file '{}'"
199 .format(permission, role_with_operations["name"],
200 self.roles_to_operations_file))
201
202 # TODO chek permission is ok
203 if permission[-1] == ":":
204 raise AuthException("Invalid permission '{}' terminated in ':' for role '{}'; at file {}"
205 .format(permission, role_with_operations["name"],
206 self.roles_to_operations_file))
207
208 if "default" not in role_with_operations["permissions"]:
209 role_with_operations["permissions"]["default"] = False
210 if "admin" not in role_with_operations["permissions"]:
211 role_with_operations["permissions"]["admin"] = False
212
213 now = time()
214 role_with_operations["_admin"] = {
215 "created": now,
216 "modified": now,
217 }
218
219 if self.config["authentication"]["backend"] == "keystone":
220 if role_with_operations["name"] != "anonymous":
221 backend_roles = self.backend.get_role_list(filter_q={"name": role_with_operations["name"]})
222 if backend_roles:
223 backend_id = backend_roles[0]["_id"]
224 else:
225 backend_id = self.backend.create_role(role_with_operations["name"])
226 role_with_operations["_id"] = backend_id
227 else:
228 role_with_operations["_id"] = str(uuid4())
229
230 self.db.create(self.roles_to_operations_table, role_with_operations)
231
232 if self.config["authentication"]["backend"] != "internal":
233 self.backend.assign_role_to_user("admin", "admin", "system_admin")
234
235 self.load_operation_to_allowed_roles()
236
237 def load_operation_to_allowed_roles(self):
238 """
239 Fills the internal self.operation_to_allowed_roles based on database role content and self.operations
240 It works in a shadow copy and replace at the end to allow other threads working with the old copy
241 :return: None
242 """
243
244 permissions = {oper: [] for oper in self.operations}
245 records = self.db.get_list(self.roles_to_operations_table)
246
247 ignore_fields = ["_id", "_admin", "name", "default"]
248 for record in records:
249 record_permissions = {oper: record["permissions"].get("default", False) for oper in self.operations}
250 operations_joined = [(oper, value) for oper, value in record["permissions"].items()
251 if oper not in ignore_fields]
252 operations_joined.sort(key=lambda x: x[0].count(":"))
253
254 for oper in operations_joined:
255 match = list(filter(lambda x: x.find(oper[0]) == 0, record_permissions.keys()))
256
257 for m in match:
258 record_permissions[m] = oper[1]
259
260 allowed_operations = [k for k, v in record_permissions.items() if v is True]
261
262 for allowed_op in allowed_operations:
263 permissions[allowed_op].append(record["name"])
264
265 self.operation_to_allowed_roles = permissions
266
267 def authorize(self):
268 token = None
269 user_passwd64 = None
270 try:
271 # 1. Get token Authorization bearer
272 auth = cherrypy.request.headers.get("Authorization")
273 if auth:
274 auth_list = auth.split(" ")
275 if auth_list[0].lower() == "bearer":
276 token = auth_list[-1]
277 elif auth_list[0].lower() == "basic":
278 user_passwd64 = auth_list[-1]
279 if not token:
280 if cherrypy.session.get("Authorization"):
281 # 2. Try using session before request a new token. If not, basic authentication will generate
282 token = cherrypy.session.get("Authorization")
283 if token == "logout":
284 token = None # force Unauthorized response to insert user password again
285 elif user_passwd64 and cherrypy.request.config.get("auth.allow_basic_authentication"):
286 # 3. Get new token from user password
287 user = None
288 passwd = None
289 try:
290 user_passwd = standard_b64decode(user_passwd64).decode()
291 user, _, passwd = user_passwd.partition(":")
292 except Exception:
293 pass
294 outdata = self.new_token(None, {"username": user, "password": passwd})
295 token = outdata["id"]
296 cherrypy.session['Authorization'] = token
297
298 if not token:
299 raise AuthException("Needed a token or Authorization http header",
300 http_code=HTTPStatus.UNAUTHORIZED)
301 token_info = self.backend.validate_token(token)
302 # TODO add to token info remote host, port
303
304 self.check_permissions(token_info, cherrypy.request.path_info,
305 cherrypy.request.method)
306 return token_info
307
308 except AuthException as e:
309 if not isinstance(e, AuthExceptionUnauthorized):
310 if cherrypy.session.get('Authorization'):
311 del cherrypy.session['Authorization']
312 cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e)
313 raise
314
315 def new_token(self, session, indata, remote):
316 current_token = None
317 if session:
318 # current_token = session.get("token")
319 current_token = session.get("_id") if self.config["authentication"]["backend"] == "keystone" \
320 else session
321 token_info = self.backend.authenticate(
322 user=indata.get("username"),
323 password=indata.get("password"),
324 token=current_token,
325 project=indata.get("project_id")
326 )
327
328 now = time()
329 new_session = {
330 "_id": token_info["_id"],
331 "id": token_info["_id"],
332 "issued_at": now,
333 "expires": token_info.get("expires", now + 3600),
334 "project_id": token_info["project_id"],
335 "username": token_info.get("username") or session.get("username"),
336 "remote_port": remote.port,
337 "admin": True if token_info.get("project_name") == "admin" else False # TODO put admin in RBAC
338 }
339
340 if remote.name:
341 new_session["remote_host"] = remote.name
342 elif remote.ip:
343 new_session["remote_host"] = remote.ip
344
345 # TODO: check if this can be avoided. Backend may provide enough information
346 self.tokens_cache[token_info["_id"]] = new_session
347
348 return deepcopy(new_session)
349
350 def get_token_list(self, session):
351 if self.config["authentication"]["backend"] == "internal":
352 return self._internal_get_token_list(session)
353 else:
354 # TODO: check if this can be avoided. Backend may provide enough information
355 return [deepcopy(token) for token in self.tokens_cache.values()
356 if token["username"] == session["username"]]
357
358 def get_token(self, session, token):
359 if self.config["authentication"]["backend"] == "internal":
360 return self._internal_get_token(session, token)
361 else:
362 # TODO: check if this can be avoided. Backend may provide enough information
363 token_value = self.tokens_cache.get(token)
364 if not token_value:
365 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
366 if token_value["username"] != session["username"] and not session["admin"]:
367 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
368 return token_value
369
370 def del_token(self, token):
371 try:
372 self.backend.revoke_token(token)
373 self.tokens_cache.pop(token, None)
374 return "token '{}' deleted".format(token)
375 except KeyError:
376 raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
377
378 def check_permissions(self, session, url, method):
379 self.logger.info("Session: {}".format(session))
380 self.logger.info("URL: {}".format(url))
381 self.logger.info("Method: {}".format(method))
382
383 key, parameters = self._normalize_url(url, method)
384
385 # TODO: Check if parameters might be useful for the decision
386
387 operation = self.resources_to_operations_mapping[key]
388 roles_required = self.operation_to_allowed_roles[operation]
389 roles_allowed = [role["name"] for role in session["roles"]]
390
391 # fills session["admin"] if some roles allows it
392 session["admin"] = False
393 for role in roles_allowed:
394 if role in self.operation_to_allowed_roles["admin"]:
395 session["admin"] = True
396 break
397
398 if "anonymous" in roles_required:
399 return
400
401 for role in roles_allowed:
402 if role in roles_required:
403 return
404
405 raise AuthExceptionUnauthorized("Access denied: lack of permissions.")
406
407 def get_user_list(self):
408 return self.backend.get_user_list()
409
410 def _normalize_url(self, url, method):
411 # Removing query strings
412 normalized_url = url if '?' not in url else url[:url.find("?")]
413 normalized_url_splitted = normalized_url.split("/")
414 parameters = {}
415
416 filtered_keys = [key for key in self.resources_to_operations_mapping.keys()
417 if method in key.split()[0]]
418
419 for idx, path_part in enumerate(normalized_url_splitted):
420 tmp_keys = []
421 for tmp_key in filtered_keys:
422 splitted = tmp_key.split()[1].split("/")
423 if idx >= len(splitted):
424 continue
425 elif "<" in splitted[idx] and ">" in splitted[idx]:
426 if splitted[idx] == "<artifactPath>":
427 tmp_keys.append(tmp_key)
428 continue
429 elif idx == len(normalized_url_splitted) - 1 and \
430 len(normalized_url_splitted) != len(splitted):
431 continue
432 else:
433 tmp_keys.append(tmp_key)
434 elif splitted[idx] == path_part:
435 if idx == len(normalized_url_splitted) - 1 and \
436 len(normalized_url_splitted) != len(splitted):
437 continue
438 else:
439 tmp_keys.append(tmp_key)
440 filtered_keys = tmp_keys
441 if len(filtered_keys) == 1 and \
442 filtered_keys[0].split("/")[-1] == "<artifactPath>":
443 break
444
445 if len(filtered_keys) == 0:
446 raise AuthException("Cannot make an authorization decision. URL not found. URL: {0}".format(url))
447 elif len(filtered_keys) > 1:
448 raise AuthException("Cannot make an authorization decision. Multiple URLs found. URL: {0}".format(url))
449
450 filtered_key = filtered_keys[0]
451
452 for idx, path_part in enumerate(filtered_key.split()[1].split("/")):
453 if "<" in path_part and ">" in path_part:
454 if path_part == "<artifactPath>":
455 parameters[path_part[1:-1]] = "/".join(normalized_url_splitted[idx:])
456 else:
457 parameters[path_part[1:-1]] = normalized_url_splitted[idx]
458
459 return filtered_key, parameters
460
461 def _internal_get_token_list(self, session):
462 now = time()
463 token_list = self.db.get_list("tokens", {"username": session["username"], "expires.gt": now})
464 return token_list
465
466 def _internal_get_token(self, session, token_id):
467 token_value = self.db.get_one("tokens", {"_id": token_id}, fail_on_empty=False)
468 if not token_value:
469 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
470 if token_value["username"] != session["username"] and not session["admin"]:
471 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
472 return token_value
473
474 def _internal_tokens_prune(self, now=None):
475 now = now or time()
476 if not self.next_db_prune_time or self.next_db_prune_time >= now:
477 self.db.del_list("tokens", {"expires.lt": now})
478 self.next_db_prune_time = self.periodin_db_pruning + now
479 self.tokens_cache.clear() # force to reload tokens from database