From c842d09195a6180b1f7db51c2ce20fa48d2fe128 Mon Sep 17 00:00:00 2001 From: Matt Scott Date: Fri, 14 Apr 2023 07:12:02 -0400 Subject: [PATCH 1/4] Overhauled app settings implementation to remove redundancy of definitions. Additionally, re-factored settings initialization code to allow for every setting to be defined by environment variable for both bare metal and Docker container deployments. --- configs/docker_config.py | 151 +------- powerdnsadmin/__init__.py | 5 +- powerdnsadmin/decorators.py | 65 +++- powerdnsadmin/default_config.py | 48 +-- powerdnsadmin/lib/settings.py | 629 ++++++++++++++++++++++++++++++++ powerdnsadmin/models/setting.py | 530 ++------------------------- powerdnsadmin/routes/admin.py | 6 +- powerdnsadmin/routes/domain.py | 4 +- 8 files changed, 734 insertions(+), 704 deletions(-) create mode 100644 powerdnsadmin/lib/settings.py diff --git a/configs/docker_config.py b/configs/docker_config.py index ae2e275..1668fc7 100644 --- a/configs/docker_config.py +++ b/configs/docker_config.py @@ -1,151 +1,2 @@ -# import everything from environment variables -import os -import sys -import json - -# Defaults for Docker image -BIND_ADDRESS = '0.0.0.0' PORT = 80 -SERVER_EXTERNAL_SSL = os.getenv('SERVER_EXTERNAL_SSL', True) -SQLALCHEMY_DATABASE_URI = 'sqlite:////data/powerdns-admin.db' -SESSION_COOKIE_SAMESITE = 'Lax' -CSRF_COOKIE_HTTPONLY = True -SESSION_TYPE = 'sqlalchemy' - -legal_envvars = ( - 'SECRET_KEY', - 'OIDC_OAUTH_ENABLED', - 'OIDC_OAUTH_KEY', - 'OIDC_OAUTH_SECRET', - 'OIDC_OAUTH_API_URL', - 'OIDC_OAUTH_TOKEN_URL', - 'OIDC_OAUTH_AUTHORIZE_URL', - 'OIDC_OAUTH_METADATA_URL', - 'OIDC_OAUTH_LOGOUT_URL', - 'OIDC_OAUTH_SCOPE', - 'OIDC_OAUTH_USERNAME', - 'OIDC_OAUTH_FIRSTNAME', - 'OIDC_OAUTH_LAST_NAME', - 'OIDC_OAUTH_EMAIL', - 'BIND_ADDRESS', - 'PORT', - 'SERVER_EXTERNAL_SSL', - 'LOG_LEVEL', - 'SALT', - 'SQLALCHEMY_TRACK_MODIFICATIONS', - 'SQLALCHEMY_DATABASE_URI', - 'SQLALCHEMY_ENGINE_OPTIONS', - 'MAIL_SERVER', - 'MAIL_PORT', - 'MAIL_DEBUG', - 'MAIL_USE_TLS', - 'MAIL_USE_SSL', - 'MAIL_USERNAME', - 'MAIL_PASSWORD', - 'MAIL_DEFAULT_SENDER', - 'SAML_ENABLED', - 'SAML_DEBUG', - 'SAML_PATH', - 'SAML_METADATA_URL', - 'SAML_METADATA_CACHE_LIFETIME', - 'SAML_IDP_SSO_BINDING', - 'SAML_IDP_ENTITY_ID', - 'SAML_NAMEID_FORMAT', - 'SAML_ATTRIBUTE_EMAIL', - 'SAML_ATTRIBUTE_GIVENNAME', - 'SAML_ATTRIBUTE_SURNAME', - 'SAML_ATTRIBUTE_NAME', - 'SAML_ATTRIBUTE_USERNAME', - 'SAML_ATTRIBUTE_ADMIN', - 'SAML_ATTRIBUTE_GROUP', - 'SAML_GROUP_ADMIN_NAME', - 'SAML_GROUP_TO_ACCOUNT_MAPPING', - 'SAML_ATTRIBUTE_ACCOUNT', - 'SAML_SP_ENTITY_ID', - 'SAML_SP_CONTACT_NAME', - 'SAML_SP_CONTACT_MAIL', - 'SAML_SIGN_REQUEST', - 'SAML_WANT_MESSAGE_SIGNED', - 'SAML_LOGOUT', - 'SAML_LOGOUT_URL', - 'SAML_ASSERTION_ENCRYPTED', - 'REMOTE_USER_LOGOUT_URL', - 'REMOTE_USER_COOKIES', - 'SIGNUP_ENABLED', - 'LOCAL_DB_ENABLED', - 'LDAP_ENABLED', - 'SAML_CERT', - 'SAML_KEY', - 'SESSION_TYPE', - 'SESSION_COOKIE_SECURE', - 'CSRF_COOKIE_SECURE', - 'CAPTCHA_ENABLE', -) - -legal_envvars_int = ('PORT', 'MAIL_PORT', 'SAML_METADATA_CACHE_LIFETIME') - -legal_envvars_bool = ( - 'SQLALCHEMY_TRACK_MODIFICATIONS', - 'HSTS_ENABLED', - 'MAIL_DEBUG', - 'MAIL_USE_TLS', - 'MAIL_USE_SSL', - 'OIDC_OAUTH_ENABLED', - 'SAML_ENABLED', - 'SAML_DEBUG', - 'SAML_SIGN_REQUEST', - 'SAML_WANT_MESSAGE_SIGNED', - 'SAML_LOGOUT', - 'SAML_ASSERTION_ENCRYPTED', - 'REMOTE_USER_ENABLED', - 'SIGNUP_ENABLED', - 'LOCAL_DB_ENABLED', - 'LDAP_ENABLED', - 'SESSION_COOKIE_SECURE', - 'CSRF_COOKIE_SECURE', - 'CAPTCHA_ENABLE', - 'SERVER_EXTERNAL_SSL', -) - -legal_envvars_dict = ( - 'SQLALCHEMY_ENGINE_OPTIONS', -) - -def str2bool(v): - return v.lower() in ("true", "yes", "1") - - -def dictfromstr(v, ret): - try: - return json.loads(ret) - except Exception as e: - print('Cannot parse json {} for variable {}'.format(ret, v)) - print(e) - raise ValueError - - -for v in legal_envvars: - - ret = None - # _FILE suffix will allow to read value from file, useful for Docker containers. - # secrets feature - if v + '_FILE' in os.environ: - if v in os.environ: - raise AttributeError( - "Both {} and {} are set but are exclusive.".format( - v, v + '_FILE')) - with open(os.environ[v + '_FILE']) as f: - ret = f.read() - f.close() - - elif v in os.environ: - ret = os.environ[v] - - if ret is not None: - if v in legal_envvars_bool: - ret = str2bool(ret) - if v in legal_envvars_int: - ret = int(ret) - if v in legal_envvars_dict: - ret = dictfromstr(v, ret) - sys.modules[__name__].__dict__[v] = ret +SQLALCHEMY_DATABASE_URI = 'sqlite:////data/powerdns-admin.db' \ No newline at end of file diff --git a/powerdnsadmin/__init__.py b/powerdnsadmin/__init__.py index d447a00..32ad19e 100755 --- a/powerdnsadmin/__init__.py +++ b/powerdnsadmin/__init__.py @@ -4,11 +4,11 @@ from flask import Flask from flask_mail import Mail from werkzeug.middleware.proxy_fix import ProxyFix from flask_session import Session - from .lib import utils def create_app(config=None): + from powerdnsadmin.lib.settings import AppSettings from . import models, routes, services from .assets import assets app = Flask(__name__) @@ -50,6 +50,9 @@ def create_app(config=None): elif config.endswith('.py'): app.config.from_pyfile(config) + # Load any settings defined with environment variables + AppSettings.load_environment(app) + # HSTS if app.config.get('HSTS_ENABLED'): from flask_sslify import SSLify diff --git a/powerdnsadmin/decorators.py b/powerdnsadmin/decorators.py index cfa5f9d..560ca45 100644 --- a/powerdnsadmin/decorators.py +++ b/powerdnsadmin/decorators.py @@ -13,6 +13,7 @@ def admin_role_required(f): """ Grant access if user is in Administrator role """ + @wraps(f) def decorated_function(*args, **kwargs): if current_user.role.name != 'Administrator': @@ -26,6 +27,7 @@ def operator_role_required(f): """ Grant access if user is in Operator role or higher """ + @wraps(f) def decorated_function(*args, **kwargs): if current_user.role.name not in ['Administrator', 'Operator']: @@ -39,6 +41,7 @@ def history_access_required(f): """ Grant access if user is in Operator role or higher, or Users can view history """ + @wraps(f) def decorated_function(*args, **kwargs): if current_user.role.name not in [ @@ -57,6 +60,7 @@ def can_access_domain(f): - user is in granted Account, or - user is in granted Domain """ + @wraps(f) def decorated_function(*args, **kwargs): if current_user.role.name not in ['Administrator', 'Operator']: @@ -83,10 +87,11 @@ def can_configure_dnssec(f): - user is in Operator role or higher, or - dnssec_admins_only is off """ + @wraps(f) def decorated_function(*args, **kwargs): if current_user.role.name not in [ - 'Administrator', 'Operator' + 'Administrator', 'Operator' ] and Setting().get('dnssec_admins_only'): abort(403) @@ -94,16 +99,18 @@ def can_configure_dnssec(f): return decorated_function + def can_remove_domain(f): """ Grant access if: - user is in Operator role or higher, or - allow_user_remove_domain is on """ + @wraps(f) def decorated_function(*args, **kwargs): if current_user.role.name not in [ - 'Administrator', 'Operator' + 'Administrator', 'Operator' ] and not Setting().get('allow_user_remove_domain'): abort(403) return f(*args, **kwargs) @@ -111,17 +118,17 @@ def can_remove_domain(f): return decorated_function - def can_create_domain(f): """ Grant access if: - user is in Operator role or higher, or - allow_user_create_domain is on """ + @wraps(f) def decorated_function(*args, **kwargs): if current_user.role.name not in [ - 'Administrator', 'Operator' + 'Administrator', 'Operator' ] and not Setting().get('allow_user_create_domain'): abort(403) return f(*args, **kwargs) @@ -144,11 +151,12 @@ def api_basic_auth(f): # Remove "Basic " from the header value auth_header = auth_header[6:] + auth_components = [] try: auth_header = str(base64.b64decode(auth_header), 'utf-8') - # NK: We use auth_components here as we don't know if we'll have a :, we split it maximum 1 times to grab the - # username, the rest of the string would be the password. + # NK: We use auth_components here as we don't know if we'll have a colon, + # we split it maximum 1 times to grab the username, the rest of the string would be the password. auth_components = auth_header.split(':', maxsplit=1) except (binascii.Error, UnicodeDecodeError) as e: current_app.logger.error( @@ -211,16 +219,19 @@ def callback_if_request_body_contains_key(callback, http_methods=[], keys=[]): If request body contains one or more of specified keys, call :param callback """ + def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): check_current_http_method = not http_methods or request.method in http_methods if (check_current_http_method and - set(request.get_json(force=True).keys()).intersection(set(keys)) + set(request.get_json(force=True).keys()).intersection(set(keys)) ): callback(*args, **kwargs) return f(*args, **kwargs) + return decorated_function + return decorator @@ -246,16 +257,18 @@ def api_role_can(action, roles=None, allow_self=False): except: username = None if ( - (current_user.role.name in roles) or - (allow_self and user_id and current_user.id == user_id) or - (allow_self and username and current_user.username == username) + (current_user.role.name in roles) or + (allow_self and user_id and current_user.id == user_id) or + (allow_self and username and current_user.username == username) ): return f(*args, **kwargs) msg = ( "User {} with role {} does not have enough privileges to {}" ).format(current_user.username, current_user.role.name, action) raise NotEnoughPrivileges(message=msg) + return decorated_function + return decorator @@ -265,15 +278,16 @@ def api_can_create_domain(f): - user is in Operator role or higher, or - allow_user_create_domain is on """ + @wraps(f) def decorated_function(*args, **kwargs): if current_user.role.name not in [ - 'Administrator', 'Operator' + 'Administrator', 'Operator' ] and not Setting().get('allow_user_create_domain'): msg = "User {0} does not have enough privileges to create zone" current_app.logger.error(msg.format(current_user.username)) raise NotEnoughPrivileges() - + if Setting().get('deny_domain_override'): req = request.get_json(force=True) domain = Domain() @@ -294,10 +308,11 @@ def apikey_can_create_domain(f): - deny_domain_override is off or - override_domain is true (from request) """ + @wraps(f) def decorated_function(*args, **kwargs): if g.apikey.role.name not in [ - 'Administrator', 'Operator' + 'Administrator', 'Operator' ] and not Setting().get('allow_user_create_domain'): msg = "ApiKey #{0} does not have enough privileges to create zone" current_app.logger.error(msg.format(g.apikey.id)) @@ -320,20 +335,23 @@ def apikey_can_remove_domain(http_methods=[]): - user is in Operator role or higher, or - allow_user_remove_domain is on """ + def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): check_current_http_method = not http_methods or request.method in http_methods if (check_current_http_method and - g.apikey.role.name not in ['Administrator', 'Operator'] and - not Setting().get('allow_user_remove_domain') + g.apikey.role.name not in ['Administrator', 'Operator'] and + not Setting().get('allow_user_remove_domain') ): msg = "ApiKey #{0} does not have enough privileges to remove zone" current_app.logger.error(msg.format(g.apikey.id)) raise NotEnoughPrivileges() return f(*args, **kwargs) + return decorated_function + return decorator @@ -341,6 +359,7 @@ def apikey_is_admin(f): """ Grant access if user is in Administrator role """ + @wraps(f) def decorated_function(*args, **kwargs): if g.apikey.role.name != 'Administrator': @@ -358,6 +377,7 @@ def apikey_can_access_domain(f): - user has Operator role or higher, or - user has explicitly been granted access to domain """ + @wraps(f) def decorated_function(*args, **kwargs): if g.apikey.role.name not in ['Administrator', 'Operator']: @@ -382,22 +402,26 @@ def apikey_can_configure_dnssec(http_methods=[]): - user is in Operator role or higher, or - dnssec_admins_only is off """ + def decorator(f=None): @wraps(f) def decorated_function(*args, **kwargs): check_current_http_method = not http_methods or request.method in http_methods if (check_current_http_method and - g.apikey.role.name not in ['Administrator', 'Operator'] and - Setting().get('dnssec_admins_only') + g.apikey.role.name not in ['Administrator', 'Operator'] and + Setting().get('dnssec_admins_only') ): msg = "ApiKey #{0} does not have enough privileges to configure dnssec" current_app.logger.error(msg.format(g.apikey.id)) raise DomainAccessForbidden(message=msg) return f(*args, **kwargs) if f else None + return decorated_function + return decorator + def allowed_record_types(f): @wraps(f) def decorated_function(*args, **kwargs): @@ -423,6 +447,7 @@ def allowed_record_types(f): return decorated_function + def allowed_record_ttl(f): @wraps(f) def decorated_function(*args, **kwargs): @@ -431,12 +456,12 @@ def allowed_record_ttl(f): if request.method == 'GET': return f(*args, **kwargs) - + if g.apikey.role.name in ['Administrator', 'Operator']: return f(*args, **kwargs) allowed_ttls = Setting().get_ttl_options() - allowed_numeric_ttls = [ ttl[0] for ttl in allowed_ttls ] + allowed_numeric_ttls = [ttl[0] for ttl in allowed_ttls] content = request.get_json() try: for record in content['rrsets']: @@ -497,6 +522,7 @@ def dyndns_login_required(f): return decorated_function + def apikey_or_basic_auth(f): @wraps(f) def decorated_function(*args, **kwargs): @@ -505,4 +531,5 @@ def apikey_or_basic_auth(f): return apikey_auth(f)(*args, **kwargs) else: return api_basic_auth(f)(*args, **kwargs) + return decorated_function diff --git a/powerdnsadmin/default_config.py b/powerdnsadmin/default_config.py index d9e2759..ff192b0 100644 --- a/powerdnsadmin/default_config.py +++ b/powerdnsadmin/default_config.py @@ -1,44 +1,32 @@ import os -import urllib.parse + basedir = os.path.abspath(os.path.dirname(__file__)) -### BASIC APP CONFIG -SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu' -SECRET_KEY = 'e951e5a1f4b94151b360f47edf596dd2' BIND_ADDRESS = '0.0.0.0' -PORT = 9191 -HSTS_ENABLED = False -SERVER_EXTERNAL_SSL = os.getenv('SERVER_EXTERNAL_SSL', True) - -SESSION_TYPE = 'sqlalchemy' -SESSION_COOKIE_SAMESITE = 'Lax' -CSRF_COOKIE_HTTPONLY = True - -#CAPTCHA Config CAPTCHA_ENABLE = True -CAPTCHA_LENGTH = 6 -CAPTCHA_WIDTH = 160 CAPTCHA_HEIGHT = 60 +CAPTCHA_LENGTH = 6 CAPTCHA_SESSION_KEY = 'captcha_image' - -### DATABASE CONFIG -SQLA_DB_USER = 'pda' -SQLA_DB_PASSWORD = 'changeme' -SQLA_DB_HOST = '127.0.0.1' -SQLA_DB_NAME = 'pda' +CAPTCHA_WIDTH = 160 +CSRF_COOKIE_HTTPONLY = True +HSTS_ENABLED = False +PORT = 9191 +SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu' +SAML_ASSERTION_ENCRYPTED = True +SAML_ENABLED = False +SECRET_KEY = 'e951e5a1f4b94151b360f47edf596dd2' +SERVER_EXTERNAL_SSL = os.getenv('SERVER_EXTERNAL_SSL', True) +SESSION_COOKIE_SAMESITE = 'Lax' +SESSION_TYPE = 'sqlalchemy' +SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db') SQLALCHEMY_TRACK_MODIFICATIONS = True - -### DATABASE - MySQL +# SQLA_DB_USER = 'pda' +# SQLA_DB_PASSWORD = 'changeme' +# SQLA_DB_HOST = '127.0.0.1' +# SQLA_DB_NAME = 'pda' # SQLALCHEMY_DATABASE_URI = 'mysql://{}:{}@{}/{}'.format( # urllib.parse.quote_plus(SQLA_DB_USER), # urllib.parse.quote_plus(SQLA_DB_PASSWORD), # SQLA_DB_HOST, # SQLA_DB_NAME # ) - -### DATABASE - SQLite -SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db') - -# SAML Authnetication -SAML_ENABLED = False -SAML_ASSERTION_ENCRYPTED = True diff --git a/powerdnsadmin/lib/settings.py b/powerdnsadmin/lib/settings.py new file mode 100644 index 0000000..c6d6c95 --- /dev/null +++ b/powerdnsadmin/lib/settings.py @@ -0,0 +1,629 @@ +import os +from pathlib import Path + +basedir = os.path.abspath(Path(os.path.dirname(__file__)).parent) + +class AppSettings(object): + + defaults = { + # Flask Settings + 'bind_address': '0.0.0.0', + 'csrf_cookie_secure': False, + 'log_level': 'WARNING', + 'port': 9191, + 'salt': '$2b$12$yLUMTIfl21FKJQpTkRQXCu', + 'secret_key': 'e951e5a1f4b94151b360f47edf596dd2', + 'session_cookie_secure': False, + 'session_type': 'sqlalchemy', + 'sqlalchemy_track_modifications': True, + 'sqlalchemy_database_uri': 'sqlite:///' + os.path.join(basedir, 'pdns.db'), + 'sqlalchemy_engine_options': {}, + + # General Settings + 'captcha_enable': True, + 'captcha_height': 60, + 'captcha_length': 6, + 'captcha_session_key': 'captcha_image', + 'captcha_width': 160, + 'mail_server': 'localhost', + 'mail_port': 25, + 'mail_debug': False, + 'mail_use_ssl': False, + 'mail_use_tls': False, + 'mail_username': '', + 'mail_password': '', + 'mail_default_sender': '', + 'remote_user_enabled': False, + 'remote_user_cookies': [], + 'remote_user_logout_url': '', + 'server_external_ssl': True, + 'maintenance': False, + 'fullscreen_layout': True, + 'record_helper': True, + 'login_ldap_first': True, + 'default_record_table_size': 15, + 'default_domain_table_size': 10, + 'auto_ptr': False, + 'record_quick_edit': True, + 'pretty_ipv6_ptr': False, + 'dnssec_admins_only': False, + 'allow_user_create_domain': False, + 'allow_user_remove_domain': False, + 'allow_user_view_history': False, + 'custom_history_header': '', + 'delete_sso_accounts': False, + 'bg_domain_updates': False, + 'enable_api_rr_history': True, + 'preserve_history': False, + 'site_name': 'PowerDNS-Admin', + 'site_url': 'http://localhost:9191', + 'session_timeout': 10, + 'warn_session_timeout': True, + 'pdns_api_url': '', + 'pdns_api_key': '', + 'pdns_api_timeout': 30, + 'pdns_version': '4.1.1', + 'verify_ssl_connections': True, + 'verify_user_email': False, + 'enforce_api_ttl': False, + 'ttl_options': '1 minute,5 minutes,30 minutes,60 minutes,24 hours', + 'otp_field_enabled': True, + 'custom_css': '', + 'otp_force': False, + 'max_history_records': 1000, + 'deny_domain_override': False, + 'account_name_extra_chars': False, + 'gravatar_enabled': False, + + # Local Authentication Settings + 'local_db_enabled': True, + 'signup_enabled': True, + 'pwd_enforce_characters': False, + 'pwd_min_len': 10, + 'pwd_min_lowercase': 3, + 'pwd_min_uppercase': 2, + 'pwd_min_digits': 2, + 'pwd_min_special': 1, + 'pwd_enforce_complexity': False, + 'pwd_min_complexity': 11, + + # LDAP Authentication Settings + 'ldap_enabled': False, + 'ldap_type': 'ldap', + 'ldap_uri': '', + 'ldap_base_dn': '', + 'ldap_admin_username': '', + 'ldap_admin_password': '', + 'ldap_domain': '', + 'ldap_filter_basic': '', + 'ldap_filter_username': '', + 'ldap_filter_group': '', + 'ldap_filter_groupname': '', + 'ldap_sg_enabled': False, + 'ldap_admin_group': '', + 'ldap_operator_group': '', + 'ldap_user_group': '', + 'autoprovisioning': False, + 'autoprovisioning_attribute': '', + 'urn_value': '', + 'purge': False, + + # Google OAuth Settings + 'google_oauth_enabled': False, + 'google_oauth_client_id': '', + 'google_oauth_client_secret': '', + 'google_oauth_scope': 'openid email profile', + 'google_base_url': 'https://www.googleapis.com/oauth2/v3/', + 'google_oauth_auto_configure': True, + 'google_oauth_metadata_url': 'https://accounts.google.com/.well-known/openid-configuration', + 'google_token_url': 'https://oauth2.googleapis.com/token', + 'google_authorize_url': 'https://accounts.google.com/o/oauth2/v2/auth', + + # GitHub OAuth Settings + 'github_oauth_enabled': False, + 'github_oauth_key': '', + 'github_oauth_secret': '', + 'github_oauth_scope': 'email', + 'github_oauth_api_url': 'https://api.github.com/user', + 'github_oauth_auto_configure': False, + 'github_oauth_metadata_url': '', + 'github_oauth_token_url': 'https://github.com/login/oauth/access_token', + 'github_oauth_authorize_url': 'https://github.com/login/oauth/authorize', + + # Azure OAuth Settings + 'azure_oauth_enabled': False, + 'azure_oauth_key': '', + 'azure_oauth_secret': '', + 'azure_oauth_scope': 'User.Read openid email profile', + 'azure_oauth_api_url': 'https://graph.microsoft.com/v1.0/', + 'azure_oauth_auto_configure': True, + 'azure_oauth_metadata_url': '', + 'azure_oauth_token_url': '', + 'azure_oauth_authorize_url': '', + 'azure_sg_enabled': False, + 'azure_admin_group': '', + 'azure_operator_group': '', + 'azure_user_group': '', + 'azure_group_accounts_enabled': False, + 'azure_group_accounts_name': 'displayName', + 'azure_group_accounts_name_re': '', + 'azure_group_accounts_description': 'description', + 'azure_group_accounts_description_re': '', + + # OIDC OAuth Settings + 'oidc_oauth_enabled': False, + 'oidc_oauth_key': '', + 'oidc_oauth_secret': '', + 'oidc_oauth_scope': 'email', + 'oidc_oauth_api_url': '', + 'oidc_oauth_auto_configure': True, + 'oidc_oauth_metadata_url': '', + 'oidc_oauth_token_url': '', + 'oidc_oauth_authorize_url': '', + 'oidc_oauth_logout_url': '', + 'oidc_oauth_username': 'preferred_username', + 'oidc_oauth_email': 'email', + 'oidc_oauth_firstname': 'given_name', + 'oidc_oauth_last_name': 'family_name', + 'oidc_oauth_account_name_property': '', + 'oidc_oauth_account_description_property': '', + + # SAML Authentication Settings + 'saml_enabled': False, + 'saml_debug': False, + 'saml_path': os.path.join(basedir, 'saml'), + 'saml_metadata_url': None, + 'saml_metadata_cache_lifetime': 1, + 'saml_idp_sso_binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + 'saml_idp_entity_id': None, + 'saml_nameid_format': None, + 'saml_attribute_account': None, + 'saml_attribute_email': 'email', + 'saml_attribute_givenname': 'givenname', + 'saml_attribute_surname': 'surname', + 'saml_attribute_name': None, + 'saml_attribute_username': None, + 'saml_attribute_admin': None, + 'saml_attribute_group': None, + 'saml_group_admin_name': None, + 'saml_group_operator_name': None, + 'saml_group_to_account_mapping': None, + 'saml_sp_entity_id': None, + 'saml_sp_contact_name': None, + 'saml_sp_contact_mail': None, + 'saml_sign_request': False, + 'saml_want_message_signed': True, + 'saml_logout': True, + 'saml_logout_url': None, + 'saml_assertion_encrypted': True, + 'saml_cert': None, + 'saml_key': None, + + # Zone Record Settings + 'forward_records_allow_edit': { + 'A': True, + 'AAAA': True, + 'AFSDB': False, + 'ALIAS': False, + 'CAA': True, + 'CERT': False, + 'CDNSKEY': False, + 'CDS': False, + 'CNAME': True, + 'DNSKEY': False, + 'DNAME': False, + 'DS': False, + 'HINFO': False, + 'KEY': False, + 'LOC': True, + 'LUA': False, + 'MX': True, + 'NAPTR': False, + 'NS': True, + 'NSEC': False, + 'NSEC3': False, + 'NSEC3PARAM': False, + 'OPENPGPKEY': False, + 'PTR': True, + 'RP': False, + 'RRSIG': False, + 'SOA': False, + 'SPF': True, + 'SSHFP': False, + 'SRV': True, + 'TKEY': False, + 'TSIG': False, + 'TLSA': False, + 'SMIMEA': False, + 'TXT': True, + 'URI': False + }, + 'reverse_records_allow_edit': { + 'A': False, + 'AAAA': False, + 'AFSDB': False, + 'ALIAS': False, + 'CAA': False, + 'CERT': False, + 'CDNSKEY': False, + 'CDS': False, + 'CNAME': False, + 'DNSKEY': False, + 'DNAME': False, + 'DS': False, + 'HINFO': False, + 'KEY': False, + 'LOC': True, + 'LUA': False, + 'MX': False, + 'NAPTR': False, + 'NS': True, + 'NSEC': False, + 'NSEC3': False, + 'NSEC3PARAM': False, + 'OPENPGPKEY': False, + 'PTR': True, + 'RP': False, + 'RRSIG': False, + 'SOA': False, + 'SPF': False, + 'SSHFP': False, + 'SRV': False, + 'TKEY': False, + 'TSIG': False, + 'TLSA': False, + 'SMIMEA': False, + 'TXT': True, + 'URI': False + }, + } + + types = { + # Flask Settings + 'bind_address': str, + 'csrf_cookie_secure': bool, + 'log_level': str, + 'port': int, + 'salt': str, + 'secret_key': str, + 'session_cookie_secure': bool, + 'session_type': str, + 'sqlalchemy_track_modifications': bool, + 'sqlalchemy_database_uri': str, + 'sqlalchemy_engine_options': dict, + + # General Settings + 'captcha_enable': bool, + 'captcha_height': int, + 'captcha_length': int, + 'captcha_session_key': str, + 'captcha_width': int, + 'mail_server': str, + 'mail_port': int, + 'mail_debug': bool, + 'mail_use_ssl': bool, + 'mail_use_tls': bool, + 'mail_username': str, + 'mail_password': str, + 'mail_default_sender': str, + 'remote_user_enabled': bool, + 'remote_user_cookies': list, + 'remote_user_logout_url': str, + 'maintenance': bool, + 'fullscreen_layout': bool, + 'record_helper': bool, + 'login_ldap_first': bool, + 'default_record_table_size': int, + 'default_domain_table_size': int, + 'auto_ptr': bool, + 'record_quick_edit': bool, + 'pretty_ipv6_ptr': bool, + 'dnssec_admins_only': bool, + 'allow_user_create_domain': bool, + 'allow_user_remove_domain': bool, + 'allow_user_view_history': bool, + 'custom_history_header': str, + 'delete_sso_accounts': bool, + 'bg_domain_updates': bool, + 'enable_api_rr_history': bool, + 'preserve_history': bool, + 'site_name': str, + 'site_url': str, + 'session_timeout': int, + 'warn_session_timeout': bool, + 'pdns_api_url': str, + 'pdns_api_key': str, + 'pdns_api_timeout': int, + 'pdns_version': str, + 'verify_ssl_connections': bool, + 'verify_user_email': bool, + 'enforce_api_ttl': bool, + 'ttl_options': str, + 'otp_field_enabled': bool, + 'custom_css': str, + 'otp_force': bool, + 'max_history_records': int, + 'deny_domain_override': bool, + 'account_name_extra_chars': bool, + 'gravatar_enabled': bool, + 'forward_records_allow_edit': dict, + 'reverse_records_allow_edit': dict, + + # Local Authentication Settings + 'local_db_enabled': bool, + 'signup_enabled': bool, + 'pwd_enforce_characters': bool, + 'pwd_min_len': int, + 'pwd_min_lowercase': int, + 'pwd_min_uppercase': int, + 'pwd_min_digits': int, + 'pwd_min_special': int, + 'pwd_enforce_complexity': bool, + 'pwd_min_complexity': int, + + # LDAP Authentication Settings + 'ldap_enabled': bool, + 'ldap_type': str, + 'ldap_uri': str, + 'ldap_base_dn': str, + 'ldap_admin_username': str, + 'ldap_admin_password': str, + 'ldap_domain': str, + 'ldap_filter_basic': str, + 'ldap_filter_username': str, + 'ldap_filter_group': str, + 'ldap_filter_groupname': str, + 'ldap_sg_enabled': bool, + 'ldap_admin_group': str, + 'ldap_operator_group': str, + 'ldap_user_group': str, + 'autoprovisioning': bool, + 'autoprovisioning_attribute': str, + 'urn_value': str, + 'purge': bool, + + # Google OAuth Settings + 'google_oauth_enabled': bool, + 'google_oauth_client_id': str, + 'google_oauth_client_secret': str, + 'google_oauth_scope': str, + 'google_base_url': str, + 'google_oauth_auto_configure': bool, + 'google_oauth_metadata_url': str, + 'google_token_url': str, + 'google_authorize_url': str, + + # GitHub OAuth Settings + 'github_oauth_enabled': bool, + 'github_oauth_key': str, + 'github_oauth_secret': str, + 'github_oauth_scope': str, + 'github_oauth_api_url': str, + 'github_oauth_auto_configure': bool, + 'github_oauth_metadata_url': str, + 'github_oauth_token_url': str, + 'github_oauth_authorize_url': str, + + # Azure OAuth Settings + 'azure_oauth_enabled': bool, + 'azure_oauth_key': str, + 'azure_oauth_secret': str, + 'azure_oauth_scope': str, + 'azure_oauth_api_url': str, + 'azure_oauth_auto_configure': bool, + 'azure_oauth_metadata_url': str, + 'azure_oauth_token_url': str, + 'azure_oauth_authorize_url': str, + 'azure_sg_enabled': bool, + 'azure_admin_group': str, + 'azure_operator_group': str, + 'azure_user_group': str, + 'azure_group_accounts_enabled': bool, + 'azure_group_accounts_name': str, + 'azure_group_accounts_name_re': str, + 'azure_group_accounts_description': str, + 'azure_group_accounts_description_re': str, + + # OIDC OAuth Settings + 'oidc_oauth_enabled': bool, + 'oidc_oauth_key': str, + 'oidc_oauth_secret': str, + 'oidc_oauth_scope': str, + 'oidc_oauth_api_url': str, + 'oidc_oauth_auto_configure': bool, + 'oidc_oauth_metadata_url': str, + 'oidc_oauth_token_url': str, + 'oidc_oauth_authorize_url': str, + 'oidc_oauth_logout_url': str, + 'oidc_oauth_username': str, + 'oidc_oauth_email': str, + 'oidc_oauth_firstname': str, + 'oidc_oauth_last_name': str, + 'oidc_oauth_account_name_property': str, + 'oidc_oauth_account_description_property': str, + + # SAML Authentication Settings + 'saml_enabled': bool, + 'saml_debug': bool, + 'saml_path': str, + 'saml_metadata_url': str, + 'saml_metadata_cache_lifetime': int, + 'saml_idp_sso_binding': str, + 'saml_idp_entity_id': str, + 'saml_nameid_format': str, + 'saml_attribute_account': str, + 'saml_attribute_email': str, + 'saml_attribute_givenname': str, + 'saml_attribute_surname': str, + 'saml_attribute_name': str, + 'saml_attribute_username': str, + 'saml_attribute_admin': str, + 'saml_attribute_group': str, + 'saml_group_admin_name': str, + 'saml_group_operator_name': str, + 'saml_group_to_account_mapping': str, + 'saml_sp_entity_id': str, + 'saml_sp_contact_name': str, + 'saml_sp_contact_mail': str, + 'saml_sign_request': bool, + 'saml_want_message_signed': bool, + 'saml_logout': bool, + 'saml_logout_url': str, + 'saml_assertion_encrypted': bool, + 'saml_cert': str, + 'saml_key': str, + } + + groups = { + 'authentication': [ + # Local Authentication Settings + 'local_db_enabled', + 'signup_enabled', + 'pwd_enforce_characters', + 'pwd_min_len', + 'pwd_min_lowercase', + 'pwd_min_uppercase', + 'pwd_min_digits', + 'pwd_min_special', + 'pwd_enforce_complexity', + 'pwd_min_complexity', + + # LDAP Authentication Settings + 'ldap_enabled', + 'ldap_type', + 'ldap_uri', + 'ldap_base_dn', + 'ldap_admin_username', + 'ldap_admin_password', + 'ldap_domain', + 'ldap_filter_basic', + 'ldap_filter_username', + 'ldap_filter_group', + 'ldap_filter_groupname', + 'ldap_sg_enabled', + 'ldap_admin_group', + 'ldap_operator_group', + 'ldap_user_group', + 'autoprovisioning', + 'autoprovisioning_attribute', + 'urn_value', + 'purge', + + # Google OAuth Settings + 'google_oauth_enabled', + 'google_oauth_client_id', + 'google_oauth_client_secret', + 'google_oauth_scope', + 'google_base_url', + 'google_oauth_auto_configure', + 'google_oauth_metadata_url', + 'google_token_url', + 'google_authorize_url', + + # GitHub OAuth Settings + 'github_oauth_enabled', + 'github_oauth_key', + 'github_oauth_secret', + 'github_oauth_scope', + 'github_oauth_api_url', + 'github_oauth_auto_configure', + 'github_oauth_metadata_url', + 'github_oauth_token_url', + 'github_oauth_authorize_url', + + # Azure OAuth Settings + 'azure_oauth_enabled', + 'azure_oauth_key', + 'azure_oauth_secret', + 'azure_oauth_scope', + 'azure_oauth_api_url', + 'azure_oauth_auto_configure', + 'azure_oauth_metadata_url', + 'azure_oauth_token_url', + 'azure_oauth_authorize_url', + 'azure_sg_enabled', + 'azure_admin_group', + 'azure_operator_group', + 'azure_user_group', + 'azure_group_accounts_enabled', + 'azure_group_accounts_name', + 'azure_group_accounts_name_re', + 'azure_group_accounts_description', + 'azure_group_accounts_description_re', + + # OIDC OAuth Settings + 'oidc_oauth_enabled', + 'oidc_oauth_key', + 'oidc_oauth_secret', + 'oidc_oauth_scope', + 'oidc_oauth_api_url', + 'oidc_oauth_auto_configure', + 'oidc_oauth_metadata_url', + 'oidc_oauth_token_url', + 'oidc_oauth_authorize_url', + 'oidc_oauth_logout_url', + 'oidc_oauth_username', + 'oidc_oauth_email', + 'oidc_oauth_firstname', + 'oidc_oauth_last_name', + 'oidc_oauth_account_name_property', + 'oidc_oauth_account_description_property', + ] + } + + @staticmethod + def convert_type(name, value): + import json + from json import JSONDecodeError + if name in AppSettings.types: + var_type = AppSettings.types[name] + + # Handle boolean values + if var_type == bool and isinstance(value, str): + if value.lower() in ['True', 'true', '1'] or value is True: + return True + else: + return False + + # Handle float values + if var_type == float: + return float(value) + + # Handle integer values + if var_type == int: + return int(value) + + if (var_type == dict or var_type == list) and isinstance(value, str) and len(value) > 0: + try: + return json.loads(value) + except JSONDecodeError as e: + raise ValueError('Cannot parse json {} for variable {}'.format(value, name)) + + if var_type == str: + return str(value) + + return value + + @staticmethod + def load_environment(app): + """ Load app settings from environment variables when defined. """ + import os + + for var_name, default_value in AppSettings.defaults.items(): + env_name = var_name.upper() + current_value = None + + if env_name + '_FILE' in os.environ: + if env_name in os.environ: + raise AttributeError( + "Both {} and {} are set but are exclusive.".format( + env_name, env_name + '_FILE')) + with open(os.environ[env_name + '_FILE']) as f: + current_value = f.read() + f.close() + + elif env_name in os.environ: + current_value = os.environ[env_name] + + if current_value is not None: + app.config[env_name] = AppSettings.convert_type(var_name, current_value) diff --git a/powerdnsadmin/models/setting.py b/powerdnsadmin/models/setting.py index 1932a9c..214d7ac 100644 --- a/powerdnsadmin/models/setting.py +++ b/powerdnsadmin/models/setting.py @@ -1,450 +1,19 @@ import sys import traceback - import pytimeparse from ast import literal_eval -from distutils.util import strtobool from flask import current_app - from .base import db +from powerdnsadmin.lib.settings import AppSettings class Setting(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(64), unique=True, index=True) value = db.Column(db.Text()) - - types = { - 'maintenance': bool, - 'fullscreen_layout': bool, - 'record_helper': bool, - 'login_ldap_first': bool, - 'default_record_table_size': int, - 'default_domain_table_size': int, - 'auto_ptr': bool, - 'record_quick_edit': bool, - 'pretty_ipv6_ptr': bool, - 'dnssec_admins_only': bool, - 'allow_user_create_domain': bool, - 'allow_user_remove_domain': bool, - 'allow_user_view_history': bool, - 'custom_history_header': str, - 'delete_sso_accounts': bool, - 'bg_domain_updates': bool, - 'enable_api_rr_history': bool, - 'preserve_history': bool, - 'site_name': str, - 'site_url': str, - 'session_timeout': int, - 'warn_session_timeout': bool, - 'pdns_api_url': str, - 'pdns_api_key': str, - 'pdns_api_timeout': int, - 'pdns_version': str, - 'verify_ssl_connections': bool, - 'verify_user_email': bool, - 'enforce_api_ttl': bool, - 'ttl_options': str, - 'otp_field_enabled': bool, - 'custom_css': str, - 'otp_force': bool, - 'max_history_records': int, - 'deny_domain_override': bool, - 'account_name_extra_chars': bool, - 'gravatar_enabled': bool, - 'forward_records_allow_edit': dict, - 'reverse_records_allow_edit': dict, - 'local_db_enabled': bool, - 'signup_enabled': bool, - 'pwd_enforce_characters': bool, - 'pwd_min_len': int, - 'pwd_min_lowercase': int, - 'pwd_min_uppercase': int, - 'pwd_min_digits': int, - 'pwd_min_special': int, - 'pwd_enforce_complexity': bool, - 'pwd_min_complexity': int, - 'ldap_enabled': bool, - 'ldap_type': str, - 'ldap_uri': str, - 'ldap_base_dn': str, - 'ldap_admin_username': str, - 'ldap_admin_password': str, - 'ldap_domain': str, - 'ldap_filter_basic': str, - 'ldap_filter_username': str, - 'ldap_filter_group': str, - 'ldap_filter_groupname': str, - 'ldap_sg_enabled': bool, - 'ldap_admin_group': str, - 'ldap_operator_group': str, - 'ldap_user_group': str, - 'autoprovisioning': bool, - 'autoprovisioning_attribute': str, - 'urn_value': str, - 'purge': bool, - 'google_oauth_enabled': bool, - 'google_oauth_client_id': str, - 'google_oauth_client_secret': str, - 'google_oauth_scope': str, - 'google_base_url': str, - 'google_oauth_auto_configure': bool, - 'google_oauth_metadata_url': str, - 'google_token_url': str, - 'google_authorize_url': str, - 'github_oauth_enabled': bool, - 'github_oauth_key': str, - 'github_oauth_secret': str, - 'github_oauth_scope': str, - 'github_oauth_api_url': str, - 'github_oauth_auto_configure': bool, - 'github_oauth_metadata_url': str, - 'github_oauth_token_url': str, - 'github_oauth_authorize_url': str, - 'azure_oauth_enabled': bool, - 'azure_oauth_key': str, - 'azure_oauth_secret': str, - 'azure_oauth_scope': str, - 'azure_oauth_api_url': str, - 'azure_oauth_auto_configure': bool, - 'azure_oauth_metadata_url': str, - 'azure_oauth_token_url': str, - 'azure_oauth_authorize_url': str, - 'azure_sg_enabled': bool, - 'azure_admin_group': str, - 'azure_operator_group': str, - 'azure_user_group': str, - 'azure_group_accounts_enabled': bool, - 'azure_group_accounts_name': str, - 'azure_group_accounts_name_re': str, - 'azure_group_accounts_description': str, - 'azure_group_accounts_description_re': str, - 'oidc_oauth_enabled': bool, - 'oidc_oauth_key': str, - 'oidc_oauth_secret': str, - 'oidc_oauth_scope': str, - 'oidc_oauth_api_url': str, - 'oidc_oauth_auto_configure': bool, - 'oidc_oauth_metadata_url': str, - 'oidc_oauth_token_url': str, - 'oidc_oauth_authorize_url': str, - 'oidc_oauth_logout_url': str, - 'oidc_oauth_username': str, - 'oidc_oauth_email': str, - 'oidc_oauth_firstname': str, - 'oidc_oauth_last_name': str, - 'oidc_oauth_account_name_property': str, - 'oidc_oauth_account_description_property': str, - } - - defaults = { - # General Settings - 'maintenance': False, - 'fullscreen_layout': True, - 'record_helper': True, - 'login_ldap_first': True, - 'default_record_table_size': 15, - 'default_domain_table_size': 10, - 'auto_ptr': False, - 'record_quick_edit': True, - 'pretty_ipv6_ptr': False, - 'dnssec_admins_only': False, - 'allow_user_create_domain': False, - 'allow_user_remove_domain': False, - 'allow_user_view_history': False, - 'custom_history_header': '', - 'delete_sso_accounts': False, - 'bg_domain_updates': False, - 'enable_api_rr_history': True, - 'preserve_history': False, - 'site_name': 'PowerDNS-Admin', - 'site_url': 'http://localhost:9191', - 'session_timeout': 10, - 'warn_session_timeout': True, - 'pdns_api_url': '', - 'pdns_api_key': '', - 'pdns_api_timeout': 30, - 'pdns_version': '4.1.1', - 'verify_ssl_connections': True, - 'verify_user_email': False, - 'enforce_api_ttl': False, - 'ttl_options': '1 minute,5 minutes,30 minutes,60 minutes,24 hours', - 'otp_field_enabled': True, - 'custom_css': '', - 'otp_force': False, - 'max_history_records': 1000, - 'deny_domain_override': False, - 'account_name_extra_chars': False, - 'gravatar_enabled': False, - # Local Authentication Settings - 'local_db_enabled': True, - 'signup_enabled': True, - 'pwd_enforce_characters': False, - 'pwd_min_len': 10, - 'pwd_min_lowercase': 3, - 'pwd_min_uppercase': 2, - 'pwd_min_digits': 2, - 'pwd_min_special': 1, - 'pwd_enforce_complexity': False, - 'pwd_min_complexity': 11, - - # LDAP Authentication Settings - 'ldap_enabled': False, - 'ldap_type': 'ldap', - 'ldap_uri': '', - 'ldap_base_dn': '', - 'ldap_admin_username': '', - 'ldap_admin_password': '', - 'ldap_domain': '', - 'ldap_filter_basic': '', - 'ldap_filter_username': '', - 'ldap_filter_group': '', - 'ldap_filter_groupname': '', - 'ldap_sg_enabled': False, - 'ldap_admin_group': '', - 'ldap_operator_group': '', - 'ldap_user_group': '', - 'autoprovisioning': False, - 'autoprovisioning_attribute': '', - 'urn_value': '', - 'purge': False, - - # Google OAuth2 Settings - 'google_oauth_enabled': False, - 'google_oauth_client_id': '', - 'google_oauth_client_secret': '', - 'google_oauth_scope': 'openid email profile', - 'google_base_url': 'https://www.googleapis.com/oauth2/v3/', - 'google_oauth_auto_configure': True, - 'google_oauth_metadata_url': 'https://accounts.google.com/.well-known/openid-configuration', - 'google_token_url': 'https://oauth2.googleapis.com/token', - 'google_authorize_url': 'https://accounts.google.com/o/oauth2/v2/auth', - - # GitHub OAuth2 Settings - 'github_oauth_enabled': False, - 'github_oauth_key': '', - 'github_oauth_secret': '', - 'github_oauth_scope': 'email', - 'github_oauth_api_url': 'https://api.github.com/user', - 'github_oauth_auto_configure': False, - 'github_oauth_metadata_url': '', - 'github_oauth_token_url': 'https://github.com/login/oauth/access_token', - 'github_oauth_authorize_url': 'https://github.com/login/oauth/authorize', - - # Azure OAuth2 Settings - 'azure_oauth_enabled': False, - 'azure_oauth_key': '', - 'azure_oauth_secret': '', - 'azure_oauth_scope': 'User.Read openid email profile', - 'azure_oauth_api_url': 'https://graph.microsoft.com/v1.0/', - 'azure_oauth_auto_configure': True, - 'azure_oauth_metadata_url': '', - 'azure_oauth_token_url': '', - 'azure_oauth_authorize_url': '', - 'azure_sg_enabled': False, - 'azure_admin_group': '', - 'azure_operator_group': '', - 'azure_user_group': '', - 'azure_group_accounts_enabled': False, - 'azure_group_accounts_name': 'displayName', - 'azure_group_accounts_name_re': '', - 'azure_group_accounts_description': 'description', - 'azure_group_accounts_description_re': '', - - # OIDC OAuth2 Settings - 'oidc_oauth_enabled': False, - 'oidc_oauth_key': '', - 'oidc_oauth_secret': '', - 'oidc_oauth_scope': 'email', - 'oidc_oauth_api_url': '', - 'oidc_oauth_auto_configure': True, - 'oidc_oauth_metadata_url': '', - 'oidc_oauth_token_url': '', - 'oidc_oauth_authorize_url': '', - 'oidc_oauth_logout_url': '', - 'oidc_oauth_username': 'preferred_username', - 'oidc_oauth_email': 'email', - 'oidc_oauth_firstname': 'given_name', - 'oidc_oauth_last_name': 'family_name', - 'oidc_oauth_account_name_property': '', - 'oidc_oauth_account_description_property': '', - - # Zone Record Settings - 'forward_records_allow_edit': { - 'A': True, - 'AAAA': True, - 'AFSDB': False, - 'ALIAS': False, - 'CAA': True, - 'CERT': False, - 'CDNSKEY': False, - 'CDS': False, - 'CNAME': True, - 'DNSKEY': False, - 'DNAME': False, - 'DS': False, - 'HINFO': False, - 'KEY': False, - 'LOC': True, - 'LUA': False, - 'MX': True, - 'NAPTR': False, - 'NS': True, - 'NSEC': False, - 'NSEC3': False, - 'NSEC3PARAM': False, - 'OPENPGPKEY': False, - 'PTR': True, - 'RP': False, - 'RRSIG': False, - 'SOA': False, - 'SPF': True, - 'SSHFP': False, - 'SRV': True, - 'TKEY': False, - 'TSIG': False, - 'TLSA': False, - 'SMIMEA': False, - 'TXT': True, - 'URI': False - }, - 'reverse_records_allow_edit': { - 'A': False, - 'AAAA': False, - 'AFSDB': False, - 'ALIAS': False, - 'CAA': False, - 'CERT': False, - 'CDNSKEY': False, - 'CDS': False, - 'CNAME': False, - 'DNSKEY': False, - 'DNAME': False, - 'DS': False, - 'HINFO': False, - 'KEY': False, - 'LOC': True, - 'LUA': False, - 'MX': False, - 'NAPTR': False, - 'NS': True, - 'NSEC': False, - 'NSEC3': False, - 'NSEC3PARAM': False, - 'OPENPGPKEY': False, - 'PTR': True, - 'RP': False, - 'RRSIG': False, - 'SOA': False, - 'SPF': False, - 'SSHFP': False, - 'SRV': False, - 'TKEY': False, - 'TSIG': False, - 'TLSA': False, - 'SMIMEA': False, - 'TXT': True, - 'URI': False - }, - } - - groups = { - 'authentication': [ - # Local Authentication Settings - 'local_db_enabled', - 'signup_enabled', - 'pwd_enforce_characters', - 'pwd_min_len', - 'pwd_min_lowercase', - 'pwd_min_uppercase', - 'pwd_min_digits', - 'pwd_min_special', - 'pwd_enforce_complexity', - 'pwd_min_complexity', - - # LDAP Authentication Settings - 'ldap_enabled', - 'ldap_type', - 'ldap_uri', - 'ldap_base_dn', - 'ldap_admin_username', - 'ldap_admin_password', - 'ldap_domain', - 'ldap_filter_basic', - 'ldap_filter_username', - 'ldap_filter_group', - 'ldap_filter_groupname', - 'ldap_sg_enabled', - 'ldap_admin_group', - 'ldap_operator_group', - 'ldap_user_group', - 'autoprovisioning', - 'autoprovisioning_attribute', - 'urn_value', - 'purge', - - # Google OAuth2 Settings - 'google_oauth_enabled', - 'google_oauth_client_id', - 'google_oauth_client_secret', - 'google_oauth_scope', - 'google_base_url', - 'google_oauth_auto_configure', - 'google_oauth_metadata_url', - 'google_token_url', - 'google_authorize_url', - - # GitHub OAuth2 Settings - 'github_oauth_enabled', - 'github_oauth_key', - 'github_oauth_secret', - 'github_oauth_scope', - 'github_oauth_api_url', - 'github_oauth_auto_configure', - 'github_oauth_metadata_url', - 'github_oauth_token_url', - 'github_oauth_authorize_url', - - # Azure OAuth2 Settings - 'azure_oauth_enabled', - 'azure_oauth_key', - 'azure_oauth_secret', - 'azure_oauth_scope', - 'azure_oauth_api_url', - 'azure_oauth_auto_configure', - 'azure_oauth_metadata_url', - 'azure_oauth_token_url', - 'azure_oauth_authorize_url', - 'azure_sg_enabled', - 'azure_admin_group', - 'azure_operator_group', - 'azure_user_group', - 'azure_group_accounts_enabled', - 'azure_group_accounts_name', - 'azure_group_accounts_name_re', - 'azure_group_accounts_description', - 'azure_group_accounts_description_re', - - # OIDC OAuth2 Settings - 'oidc_oauth_enabled', - 'oidc_oauth_key', - 'oidc_oauth_secret', - 'oidc_oauth_scope', - 'oidc_oauth_api_url', - 'oidc_oauth_auto_configure', - 'oidc_oauth_metadata_url', - 'oidc_oauth_token_url', - 'oidc_oauth_authorize_url', - 'oidc_oauth_logout_url', - 'oidc_oauth_username', - 'oidc_oauth_email', - 'oidc_oauth_firstname', - 'oidc_oauth_last_name', - 'oidc_oauth_account_name_property', - 'oidc_oauth_account_description_property', - ] - } + ZONE_TYPE_FORWARD = 'forward' + ZONE_TYPE_REVERSE = 'reverse' def __init__(self, id=None, name=None, value=None): self.id = id @@ -457,44 +26,12 @@ class Setting(db.Model): self.name = name self.value = value - def convert_type(self, name, value): - import json - from json import JSONDecodeError - if name in self.types: - var_type = self.types[name] - - # Handle boolean values - if var_type == bool: - if value == 'True' or value == 'true' or value == '1' or value is True: - return True - else: - return False - - # Handle float values - if var_type == float: - return float(value) - - # Handle integer values - if var_type == int: - return int(value) - - if (var_type == dict or var_type == list) and isinstance(value, str) and len(value) > 0: - try: - return json.loads(value) - except JSONDecodeError as e: - pass - - if var_type == str: - return str(value) - - return value - def set_maintenance(self, mode): maintenance = Setting.query.filter( Setting.name == 'maintenance').first() if maintenance is None: - value = self.defaults['maintenance'] + value = AppSettings.defaults['maintenance'] maintenance = Setting(name='maintenance', value=str(value)) db.session.add(maintenance) @@ -516,7 +53,7 @@ class Setting(db.Model): current_setting = Setting.query.filter(Setting.name == setting).first() if current_setting is None: - value = self.defaults[setting] + value = AppSettings.defaults[setting] current_setting = Setting(name=setting, value=str(value)) db.session.add(current_setting) @@ -541,21 +78,20 @@ class Setting(db.Model): current_setting = Setting(name=setting, value=None) db.session.add(current_setting) - value = str(self.convert_type(setting, value)) + value = str(AppSettings.convert_type(setting, value)) try: current_setting.value = value db.session.commit() return True except Exception as e: - current_app.logger.error('Cannot edit setting {0}. DETAIL: {1}'.format( - setting, e)) + current_app.logger.error('Cannot edit setting {0}. DETAIL: {1}'.format(setting, e)) current_app.logger.debug(traceback.format_exec()) db.session.rollback() return False def get(self, setting): - if setting in self.defaults: + if setting in AppSettings.defaults: if setting.upper() in current_app.config: result = current_app.config[setting.upper()] @@ -566,51 +102,45 @@ class Setting(db.Model): if hasattr(result, 'value'): result = result.value - return self.convert_type(setting, result) + return AppSettings.convert_type(setting, result) else: - return self.defaults[setting] + return AppSettings.defaults[setting] else: current_app.logger.error('Unknown setting queried: {0}'.format(setting)) def get_group(self, group): if not isinstance(group, list): - group = self.groups[group] + group = AppSettings.groups[group] result = {} - records = self.query.all() - for record in records: - if record.name in group: - result[record.name] = self.convert_type(record.name, record.value) + for var_name, default_value in AppSettings.defaults.items(): + if var_name in group: + result[var_name] = self.get(var_name) return result def get_records_allow_to_edit(self): return list( - set(self.get_forward_records_allow_to_edit() + - self.get_reverse_records_allow_to_edit())) + set(self.get_supported_record_types(self.ZONE_TYPE_FORWARD) + + self.get_supported_record_types(self.ZONE_TYPE_REVERSE))) - def get_forward_records_allow_to_edit(self): - records = self.get('forward_records_allow_edit') - f_records = literal_eval(records) if isinstance(records, - str) else records - r_name = [r for r in f_records if f_records[r]] - # Sort alphabetically if python version is smaller than 3.6 - if sys.version_info[0] < 3 or (sys.version_info[0] == 3 - and sys.version_info[1] < 6): - r_name.sort() - return r_name + def get_supported_record_types(self, zone_type): + setting_value = [] + + if zone_type == self.ZONE_TYPE_FORWARD: + setting_value = self.get('forward_records_allow_edit') + elif zone_type == self.ZONE_TYPE_REVERSE: + setting_value = self.get('reverse_records_allow_edit') + + records = literal_eval(setting_value) if isinstance(setting_value, str) else setting_value + types = [r for r in records if records[r]] - def get_reverse_records_allow_to_edit(self): - records = self.get('reverse_records_allow_edit') - r_records = literal_eval(records) if isinstance(records, - str) else records - r_name = [r for r in r_records if r_records[r]] # Sort alphabetically if python version is smaller than 3.6 - if sys.version_info[0] < 3 or (sys.version_info[0] == 3 - and sys.version_info[1] < 6): - r_name.sort() - return r_name + if sys.version_info[0] < 3 or (sys.version_info[0] == 3 and sys.version_info[1] < 6): + types.sort() + + return types def get_ttl_options(self): return [(pytimeparse.parse(ttl), ttl) diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index 5b96b18..3ed5f9e 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -1460,6 +1460,7 @@ def setting_pdns(): @login_required @operator_role_required def setting_records(): + from powerdnsadmin.lib.settings import AppSettings if request.method == 'GET': _fr = Setting().get('forward_records_allow_edit') _rr = Setting().get('reverse_records_allow_edit') @@ -1472,7 +1473,7 @@ def setting_records(): elif request.method == 'POST': fr = {} rr = {} - records = Setting().defaults['forward_records_allow_edit'] + records = AppSettings.defaults['forward_records_allow_edit'] for r in records: fr[r] = True if request.form.get('fr_{0}'.format( r.lower())) else False @@ -1517,6 +1518,7 @@ def setting_authentication(): @login_required @admin_role_required def setting_authentication_api(): + from powerdnsadmin.lib.settings import AppSettings result = {'status': 1, 'messages': [], 'data': {}} if request.form.get('commit') == '1': @@ -1524,7 +1526,7 @@ def setting_authentication_api(): data = json.loads(request.form.get('data')) for key, value in data.items(): - if key in model.groups['authentication']: + if key in AppSettings.groups['authentication']: model.set(key, value) result['data'] = Setting().get_group('authentication') diff --git a/powerdnsadmin/routes/domain.py b/powerdnsadmin/routes/domain.py index b1734fc..56ca405 100644 --- a/powerdnsadmin/routes/domain.py +++ b/powerdnsadmin/routes/domain.py @@ -72,9 +72,9 @@ def domain(domain_name): quick_edit = Setting().get('record_quick_edit') records_allow_to_edit = Setting().get_records_allow_to_edit() forward_records_allow_to_edit = Setting( - ).get_forward_records_allow_to_edit() + ).get_supported_record_types(Setting().ZONE_TYPE_FORWARD) reverse_records_allow_to_edit = Setting( - ).get_reverse_records_allow_to_edit() + ).get_supported_record_types(Setting().ZONE_TYPE_REVERSE) ttl_options = Setting().get_ttl_options() records = [] From ccd7373efe85c09a494a05666a20963c2b4f6e9f Mon Sep 17 00:00:00 2001 From: Matt Scott Date: Fri, 14 Apr 2023 18:52:27 -0400 Subject: [PATCH 2/4] Corrected issue with encoding / decoding of dictionary and list type settings values. Updated zone record settings management to use valid JSON format with backwards compatibility support for the non-JSON literal format. --- powerdnsadmin/lib/settings.py | 7 ++++- powerdnsadmin/models/setting.py | 6 ++++- powerdnsadmin/routes/admin.py | 46 ++++++++++++++++++--------------- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/powerdnsadmin/lib/settings.py b/powerdnsadmin/lib/settings.py index c6d6c95..bebc9de 100644 --- a/powerdnsadmin/lib/settings.py +++ b/powerdnsadmin/lib/settings.py @@ -597,7 +597,12 @@ class AppSettings(object): try: return json.loads(value) except JSONDecodeError as e: - raise ValueError('Cannot parse json {} for variable {}'.format(value, name)) + # Provide backwards compatibility for legacy non-JSON format + value = value.replace("'", '"').replace('True', 'true').replace('False', 'false') + try: + return json.loads(value) + except JSONDecodeError as e: + raise ValueError('Cannot parse json {} for variable {}'.format(value, name)) if var_type == str: return str(value) diff --git a/powerdnsadmin/models/setting.py b/powerdnsadmin/models/setting.py index 214d7ac..2bcd8c5 100644 --- a/powerdnsadmin/models/setting.py +++ b/powerdnsadmin/models/setting.py @@ -72,13 +72,17 @@ class Setting(db.Model): return False def set(self, setting, value): + import json current_setting = Setting.query.filter(Setting.name == setting).first() if current_setting is None: current_setting = Setting(name=setting, value=None) db.session.add(current_setting) - value = str(AppSettings.convert_type(setting, value)) + value = AppSettings.convert_type(setting, value) + + if isinstance(value, dict) or isinstance(value, list): + value = json.dumps(value) try: current_setting.value = value diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index 3ed5f9e..b563555 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -128,14 +128,17 @@ def extract_changelogs_from_history(histories, record_name=None, record_type=Non # filter only the records with the specific record_name, record_type if record_name != None and record_type != None: - details['add_rrsets'] = list(filter_rr_list_by_name_and_type(details['add_rrsets'], record_name, record_type)) - details['del_rrsets'] = list(filter_rr_list_by_name_and_type(details['del_rrsets'], record_name, record_type)) + details['add_rrsets'] = list( + filter_rr_list_by_name_and_type(details['add_rrsets'], record_name, record_type)) + details['del_rrsets'] = list( + filter_rr_list_by_name_and_type(details['del_rrsets'], record_name, record_type)) if not details['add_rrsets'] and not details['del_rrsets']: continue # same record name and type RR are being deleted and created in same entry. - del_add_changes = set([(r['name'], r['type']) for r in details['add_rrsets']]).intersection([(r['name'], r['type']) for r in details['del_rrsets']]) + del_add_changes = set([(r['name'], r['type']) for r in details['add_rrsets']]).intersection( + [(r['name'], r['type']) for r in details['del_rrsets']]) for del_add_change in del_add_changes: changes.append(HistoryRecordEntry( entry, @@ -155,8 +158,8 @@ def extract_changelogs_from_history(histories, record_name=None, record_type=Non # sort changes by the record name if changes: changes.sort(key=lambda change: - change.del_rrset['name'] if change.del_rrset else change.add_rrset['name'] - ) + change.del_rrset['name'] if change.del_rrset else change.add_rrset['name'] + ) out_changes.extend(changes) return out_changes @@ -1149,10 +1152,10 @@ def history_table(): # ajax call data .outerjoin(Account, Domain.account_id == Account.id) \ .outerjoin(AccountUser, Account.id == AccountUser.account_id) \ .filter(db.or_( - DomainUser.user_id == current_user.id, - AccountUser.user_id == current_user.id - )) \ - .subquery() + DomainUser.user_id == current_user.id, + AccountUser.user_id == current_user.id + )) \ + .subquery() base_query = base_query.filter(History.domain_id.in_(allowed_domain_id_subquery)) domain_name = request.args.get('domain_name_filter') if request.args.get('domain_name_filter') != None \ @@ -1271,7 +1274,8 @@ def history_table(): # ajax call data ) ).order_by(History.created_on.desc()) \ .limit(lim).all() - elif user_name != None and current_user.role.name in ['Administrator', 'Operator']: # only admins can see the user login-logouts + elif user_name != None and current_user.role.name in ['Administrator', + 'Operator']: # only admins can see the user login-logouts histories = base_query.filter( db.and_( @@ -1296,7 +1300,8 @@ def history_table(): # ajax call data temp.append(h) break histories = temp - elif (changed_by != None or max_date != None) and current_user.role.name in ['Administrator', 'Operator']: # select changed by and date filters only + elif (changed_by != None or max_date != None) and current_user.role.name in ['Administrator', + 'Operator']: # select changed by and date filters only histories = base_query.filter( db.and_( History.created_on <= max_date if max_date != None else True, @@ -1305,7 +1310,8 @@ def history_table(): # ajax call data ) ) \ .order_by(History.created_on.desc()).limit(lim).all() - elif (changed_by != None or max_date != None): # special filtering for user because one user does not have access to log-ins logs + elif ( + changed_by != None or max_date != None): # special filtering for user because one user does not have access to log-ins logs histories = base_query.filter( db.and_( History.created_on <= max_date if max_date != None else True, @@ -1462,14 +1468,11 @@ def setting_pdns(): def setting_records(): from powerdnsadmin.lib.settings import AppSettings if request.method == 'GET': - _fr = Setting().get('forward_records_allow_edit') - _rr = Setting().get('reverse_records_allow_edit') - f_records = literal_eval(_fr) if isinstance(_fr, str) else _fr - r_records = literal_eval(_rr) if isinstance(_rr, str) else _rr - + forward_records = Setting().get('forward_records_allow_edit') + reverse_records = Setting().get('reverse_records_allow_edit') return render_template('admin_setting_records.html', - f_records=f_records, - r_records=r_records) + f_records=forward_records, + r_records=reverse_records) elif request.method == 'POST': fr = {} rr = {} @@ -1480,8 +1483,9 @@ def setting_records(): rr[r] = True if request.form.get('rr_{0}'.format( r.lower())) else False - Setting().set('forward_records_allow_edit', str(fr)) - Setting().set('reverse_records_allow_edit', str(rr)) + Setting().set('forward_records_allow_edit', json.dumps(fr)) + Setting().set('reverse_records_allow_edit', json.dumps(rr)) + return redirect(url_for('admin.setting_records')) From 601539f16e943fb2ed04eba0f25625a110f7baa6 Mon Sep 17 00:00:00 2001 From: Matt Scott Date: Fri, 14 Apr 2023 19:00:39 -0400 Subject: [PATCH 3/4] Removed unnecessary parenthesis. --- powerdnsadmin/routes/admin.py | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index b563555..a69e46a 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -1402,7 +1402,7 @@ def setting_basic_edit(setting): new_value = jdata['value'] result = Setting().set(setting, new_value) - if (result): + if result: return make_response( jsonify({ 'status': 'ok', @@ -1489,28 +1489,6 @@ def setting_records(): return redirect(url_for('admin.setting_records')) -def has_an_auth_method(local_db_enabled=None, - ldap_enabled=None, - google_oauth_enabled=None, - github_oauth_enabled=None, - oidc_oauth_enabled=None, - azure_oauth_enabled=None): - if local_db_enabled is None: - local_db_enabled = Setting().get('local_db_enabled') - if ldap_enabled is None: - ldap_enabled = Setting().get('ldap_enabled') - if google_oauth_enabled is None: - google_oauth_enabled = Setting().get('google_oauth_enabled') - if github_oauth_enabled is None: - github_oauth_enabled = Setting().get('github_oauth_enabled') - if oidc_oauth_enabled is None: - oidc_oauth_enabled = Setting().get('oidc_oauth_enabled') - if azure_oauth_enabled is None: - azure_oauth_enabled = Setting().get('azure_oauth_enabled') - return local_db_enabled or ldap_enabled or google_oauth_enabled or github_oauth_enabled or oidc_oauth_enabled \ - or azure_oauth_enabled - - @admin_bp.route('/setting/authentication', methods=['GET', 'POST']) @login_required @admin_role_required From c74c06c3271a1e834431d2b4fb1f4dc5ef307759 Mon Sep 17 00:00:00 2001 From: Matt Scott Date: Fri, 14 Apr 2023 19:26:02 -0400 Subject: [PATCH 4/4] Added some missing settings to the new `AppSettings` class. Corrected typo in app setup method. --- powerdnsadmin/__init__.py | 2 +- powerdnsadmin/lib/settings.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/powerdnsadmin/__init__.py b/powerdnsadmin/__init__.py index 32ad19e..660f96b 100755 --- a/powerdnsadmin/__init__.py +++ b/powerdnsadmin/__init__.py @@ -87,7 +87,7 @@ def create_app(config=None): app.jinja_env.filters['format_datetime_local'] = utils.format_datetime app.jinja_env.filters['format_zone_type'] = utils.format_zone_type - # Register context proccessors + # Register context processors from .models.setting import Setting @app.context_processor diff --git a/powerdnsadmin/lib/settings.py b/powerdnsadmin/lib/settings.py index bebc9de..b154112 100644 --- a/powerdnsadmin/lib/settings.py +++ b/powerdnsadmin/lib/settings.py @@ -36,6 +36,7 @@ class AppSettings(object): 'remote_user_enabled': False, 'remote_user_cookies': [], 'remote_user_logout_url': '', + 'hsts_enabled': False, 'server_external_ssl': True, 'maintenance': False, 'fullscreen_layout': True, @@ -74,6 +75,7 @@ class AppSettings(object): 'deny_domain_override': False, 'account_name_extra_chars': False, 'gravatar_enabled': False, + 'pdns_admin_log_level': 'WARNING', # Local Authentication Settings 'local_db_enabled': True, @@ -306,6 +308,7 @@ class AppSettings(object): 'mail_username': str, 'mail_password': str, 'mail_default_sender': str, + 'hsts_enabled': bool, 'remote_user_enabled': bool, 'remote_user_cookies': list, 'remote_user_logout_url': str, @@ -346,6 +349,7 @@ class AppSettings(object): 'deny_domain_override': bool, 'account_name_extra_chars': bool, 'gravatar_enabled': bool, + 'pdns_admin_log_level': str, 'forward_records_allow_edit': dict, 'reverse_records_allow_edit': dict,