diff --git a/powerdnsadmin/models/domain.py b/powerdnsadmin/models/domain.py index 4f225a9..e30421d 100644 --- a/powerdnsadmin/models/domain.py +++ b/powerdnsadmin/models/domain.py @@ -582,6 +582,33 @@ class Domain(db.Model): format(self.name, e)) current_app.logger.debug(print(traceback.format_exc())) + def revoke_privileges_by_id(self, user_id): + """ + Remove a single user from privilege list based on user_id + """ + new_uids = [u for u in self.get_user() if u != user_id] + users = [] + for uid in new_uids: + users.append(User(id=uid).get_user_info_by_id().username) + + self.grant_privileges(users) + + def add_user(self, user): + """ + Add a single user to Domain by User + """ + try: + du = DomainUser(self.id, user.id) + db.session.add(du) + db.session.commit() + return True + except Exception as e: + db.session.rollback() + current_app.logger.error( + 'Cannot add user privileges on domain {0}. DETAIL: {1}'. + format(self.name, e)) + return False + def update_from_master(self, domain_name): """ Update records from Master DNS server diff --git a/powerdnsadmin/models/setting.py b/powerdnsadmin/models/setting.py index a9973c3..bc9871d 100644 --- a/powerdnsadmin/models/setting.py +++ b/powerdnsadmin/models/setting.py @@ -40,6 +40,10 @@ class Setting(db.Model): 'verify_ssl_connections': True, 'local_db_enabled': True, 'signup_enabled': True, + 'autoprovisioning': False, + 'urn_value':'', + 'autoprovisioning_attribute': '', + 'purge': False, 'verify_user_email': False, 'ldap_enabled': False, 'ldap_type': 'ldap', diff --git a/powerdnsadmin/models/user.py b/powerdnsadmin/models/user.py index eebc42b..a904f47 100644 --- a/powerdnsadmin/models/user.py +++ b/powerdnsadmin/models/user.py @@ -14,6 +14,8 @@ from .role import Role from .setting import Setting from .domain_user import DomainUser from .user_permissions import UserPermissions +from .account_user import AccountUser + class Anonymous(AnonymousUserMixin): @@ -131,9 +133,8 @@ class User(db.Model): conn.protocol_version = ldap.VERSION3 return conn - def ldap_search(self, searchFilter, baseDN): + def ldap_search(self, searchFilter, baseDN, retrieveAttributes=None): searchScope = ldap.SCOPE_SUBTREE - retrieveAttributes = None try: conn = self.ldap_init_conn() @@ -498,7 +499,6 @@ class User(db.Model): """ Update user profile """ - user = User.query.filter(User.username == self.username).first() if not user: return False @@ -554,9 +554,26 @@ class User(db.Model): Note: This doesn't include the permission granting from Account which user belong to """ - return self.get_domain_query().all() + def get_user_domains(self): + from ..models.base import db + from .account import Account + from .domain import Domain + from .account_user import AccountUser + from .domain_user import DomainUser + + domains = db.session.query(Domain) \ + .outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \ + .outerjoin(Account, Domain.account_id == Account.id) \ + .outerjoin(AccountUser, Account.id == AccountUser.account_id) \ + .filter( + db.or_( + DomainUser.user_id == self.id, + AccountUser.user_id == self.id + )).all() + return domains + def delete(self): """ Delete a user @@ -574,7 +591,7 @@ class User(db.Model): self.username, e)) return False - def revoke_privilege(self): + def revoke_privilege(self, update_user=False): """ Revoke all privileges from a user """ @@ -584,6 +601,8 @@ class User(db.Model): user_id = user.id try: DomainUser.query.filter(DomainUser.user_id == user_id).delete() + if (update_user)==True: + AccountUser.query.filter(AccountUser.user_id == user_id).delete() db.session.commit() return True except Exception as e: @@ -625,3 +644,168 @@ class User(db.Model): for q in query: accounts.append(q[1]) return accounts + + + def read_entitlements(self, key): + """ + Get entitlements from ldap server associated with this user + """ + LDAP_BASE_DN = Setting().get('ldap_base_dn') + LDAP_FILTER_USERNAME = Setting().get('ldap_filter_username') + LDAP_FILTER_BASIC = Setting().get('ldap_filter_basic') + searchFilter = "(&({0}={1}){2})".format(LDAP_FILTER_USERNAME, + self.username, + LDAP_FILTER_BASIC) + current_app.logger.debug('Ldap searchFilter {0}'.format(searchFilter)) + ldap_result = self.ldap_search(searchFilter, LDAP_BASE_DN, [key]) + current_app.logger.debug('Ldap search result: {0}'.format(ldap_result)) + entitlements=[] + if ldap_result: + dict=ldap_result[0][0][1] + if len(dict)!=0: + for entitlement in dict[key]: + entitlements.append(entitlement.decode("utf-8")) + else: + e="Not found value in the autoprovisioning attribute field " + current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e)) + return entitlements + + def updateUser(self, Entitlements): + """ + Update user associations based on ldap attribute + """ + entitlements= getCorrectEntitlements(Entitlements) + if len(entitlements)!=0: + self.revoke_privilege(True) + for entitlement in entitlements: + arguments=entitlement.split(':') + entArgs=arguments[arguments.index('powerdns-admin')+1:] + role= entArgs[0] + self.set_role(role) + if (role=="User") and len(entArgs)>1: + current_domains=getUserInfo(self.get_user_domains()) + current_accounts=getUserInfo(self.get_accounts()) + domain=entArgs[1] + self.addMissingDomain(domain, current_domains) + if len(entArgs)>2: + account=entArgs[2] + self.addMissingAccount(account, current_accounts) + + def addMissingDomain(self, autoprovision_domain, current_domains): + """ + Add domain gathered by autoprovisioning to the current domains list of a user + """ + from ..models.domain import Domain + user = db.session.query(User).filter(User.username == self.username).first() + if autoprovision_domain not in current_domains: + domain= db.session.query(Domain).filter(Domain.name == autoprovision_domain).first() + if domain!=None: + domain.add_user(user) + + def addMissingAccount(self, autoprovision_account, current_accounts): + """ + Add account gathered by autoprovisioning to the current accounts list of a user + """ + from ..models.account import Account + user = db.session.query(User).filter(User.username == self.username).first() + if autoprovision_account not in current_accounts: + account= db.session.query(Account).filter(Account.name == autoprovision_account).first() + if account!=None: + account.add_user(user) + +def getCorrectEntitlements(Entitlements): + """ + Gather a list of valid records from the ldap attribute given + """ + from ..models.role import Role + urn_value=Setting().get('urn_value') + urnArgs=[x.lower() for x in urn_value.split(':')] + entitlements=[] + for Entitlement in Entitlements: + arguments=Entitlement.split(':') + + if ('powerdns-admin' in arguments): + prefix=arguments[0:arguments.index('powerdns-admin')] + prefix=[x.lower() for x in prefix] + if (prefix!=urnArgs): + e= "Typo in first part of urn value" + current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e)) + continue + + else: + e="Entry not a PowerDNS-Admin record" + current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e)) + continue + + if len(arguments)<=len(urnArgs)+1: #prefix:powerdns-admin + e="No value given after the prefix" + current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e)) + continue + + entArgs=arguments[arguments.index('powerdns-admin')+1:] + role=entArgs[0] + roles= Role.query.all() + role_names=get_role_names(roles) + + if role not in role_names: + e="Role given by entry not a role availabe in PowerDNS-Admin. Check for spelling errors" + current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e)) + continue + + if len(entArgs)>1: + if (role!="User"): + e="Too many arguments for Admin or Operator" + current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e)) + continue + else: + if len(entArgs)<=3: + if entArgs[1] and not checkIfDomainExists(entArgs[1]): + continue + if len(entArgs)==3: + if entArgs[2] and not checkIfAccountExists(entArgs[2]): + continue + else: + e="Too many arguments" + current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e)) + continue + + entitlements.append(Entitlement) + + return entitlements + + +def checkIfDomainExists(domainName): + from ..models.domain import Domain + domain= db.session.query(Domain).filter(Domain.name == domainName) + if len(domain.all())==0: + e= domainName + " is not found in the database" + current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e)) + return False + return True + +def checkIfAccountExists(accountName): + from ..models.account import Account + account= db.session.query(Account).filter(Account.name == accountName) + if len(account.all())==0: + e= accountName + " is not found in the database" + current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e)) + return False + return True + +def get_role_names(roles): + """ + returns all the roles available in database in string format + """ + roles_list=[] + for role in roles: + roles_list.append(role.name) + return roles_list + +def getUserInfo(DomainsOrAccounts): + current=[] + for DomainOrAccount in DomainsOrAccounts: + current.append(DomainOrAccount.name) + return current + + + diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index 251953d..7ccccd5 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -1,6 +1,7 @@ import json import datetime import traceback +import re from base64 import b64encode from ast import literal_eval from flask import Blueprint, render_template, make_response, url_for, current_app, request, redirect, jsonify, abort, flash, session @@ -479,6 +480,7 @@ def edit_account(account_name=None): if request.method == 'GET': if account_name is None: return render_template('admin_edit_account.html', + account_user_ids=[], users=users, create=1) @@ -868,6 +870,27 @@ def setting_authentication(): Setting().set('ldap_user_group', request.form.get('ldap_user_group')) Setting().set('ldap_domain', request.form.get('ldap_domain')) + Setting().set( + 'autoprovisioning', True + if request.form.get('autoprovisioning') == 'ON' else False) + Setting().set('autoprovisioning_attribute', + request.form.get('autoprovisioning_attribute')) + + if request.form.get('autoprovisioning')=='ON': + if validateURN(request.form.get('urn_value')): + Setting().set('urn_value', + request.form.get('urn_value')) + else: + return render_template('admin_setting_authentication.html', + error="Invalid urn") + else: + Setting().set('urn_value', + request.form.get('urn_value')) + + Setting().set('purge', True + if request.form.get('purge') == 'ON' else False) + + result = {'status': True, 'msg': 'Saved successfully'} elif conf_type == 'google': google_oauth_enabled = True if request.form.get( @@ -1325,3 +1348,29 @@ def global_search(): pass return render_template('admin_global_search.html', domains=domains, records=records, comments=comments) + +def validateURN(value): + NID_PATTERN = re.compile(r'^[0-9a-z][0-9a-z-]{1,31}$', flags=re.IGNORECASE) + NSS_PCHAR = '[a-z0-9-._~]|%[a-f0-9]{2}|[!$&\'()*+,;=]|:|@' + NSS_PATTERN = re.compile(fr'^({NSS_PCHAR})({NSS_PCHAR}|/|\?)*$', re.IGNORECASE) + + prefix=value.split(':') + if (len(prefix)<3): + current_app.logger.warning( "Too small urn prefix" ) + return False + + urn=prefix[0] + nid=prefix[1] + nss=value.replace(urn+":"+nid+":", "") + + if not urn.lower()=="urn": + current_app.logger.warning( urn + ' contains invalid characters ' ) + return False + if not re.match(NID_PATTERN, nid.lower()): + current_app.logger.warning( nid + ' contains invalid characters ' ) + return False + if not re.match(NSS_PATTERN, nss): + current_app.logger.warning( nss + ' contains invalid characters ' ) + return False + + return True diff --git a/powerdnsadmin/routes/api.py b/powerdnsadmin/routes/api.py index 6435261..0623c96 100644 --- a/powerdnsadmin/routes/api.py +++ b/powerdnsadmin/routes/api.py @@ -973,8 +973,9 @@ def api_zone_subpath_forward(server_id, zone_id, subpath): @apikey_can_access_domain def api_zone_forward(server_id, zone_id): resp = helper.forward_request() - domain = Domain() - domain.update() + if not Setting().get('bg_domain_updates'): + domain = Domain() + domain.update() status = resp.status_code if 200 <= status < 300: current_app.logger.debug("Request to powerdns API successful") diff --git a/powerdnsadmin/routes/domain.py b/powerdnsadmin/routes/domain.py index 75dd2f9..a90f367 100644 --- a/powerdnsadmin/routes/domain.py +++ b/powerdnsadmin/routes/domain.py @@ -149,6 +149,18 @@ def add(): 'errors/400.html', msg="Please enter a valid domain name"), 400 + # If User creates the domain, check some additional stuff + if current_user.role.name not in ['Administrator', 'Operator']: + # Get all the account_ids of the user + user_accounts_ids = current_user.get_accounts() + user_accounts_ids = [x.id for x in user_accounts_ids] + # User may not create domains without Account + if int(account_id) == 0 or int(account_id) not in user_accounts_ids: + return render_template( + 'errors/400.html', + msg="Please use a valid Account"), 400 + + #TODO: Validate ip addresses input # Encode domain name into punycode (IDN) @@ -250,13 +262,19 @@ def add(): current_app.logger.debug(traceback.format_exc()) abort(500) + # Get else: - accounts = Account.query.order_by(Account.name).all() + # Admins and Operators can set to any account + if current_user.role.name in ['Administrator', 'Operator']: + accounts = Account.query.order_by(Account.name).all() + else: + accounts = current_user.get_accounts() return render_template('domain_add.html', templates=templates, accounts=accounts) + @domain_bp.route('/setting//delete', methods=['POST']) @login_required @operator_role_required diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py index f1ddb6b..f646524 100644 --- a/powerdnsadmin/routes/index.py +++ b/powerdnsadmin/routes/index.py @@ -473,10 +473,39 @@ def login(): saml_enabled=SAML_ENABLED, error='Token required') + if Setting().get('autoprovisioning') and auth_method!='LOCAL': + urn_value=Setting().get('urn_value') + Entitlements=user.read_entitlements(Setting().get('autoprovisioning_attribute')) + if len(Entitlements)==0 and Setting().get('purge'): + user.set_role("User") + user.revoke_privilege(True) + + elif len(Entitlements)!=0: + if checkForPDAEntries(Entitlements, urn_value): + user.updateUser(Entitlements) + else: + current_app.logger.warning('Not a single powerdns-admin record was found, possibly a typo in the prefix') + if Setting().get('purge'): + user.set_role("User") + user.revoke_privilege(True) + current_app.logger.warning('Procceding to revoke every privilige from ' + user.username + '.' ) + login_user(user, remember=remember_me) signin_history(user.username, 'LOCAL', True) return redirect(session.get('next', url_for('index.index'))) +def checkForPDAEntries(Entitlements, urn_value): + """ + Run through every record located in the ldap attribute given and determine if there are any valid powerdns-admin records + """ + urnArguments=[x.lower() for x in urn_value.split(':')] + for Entitlement in Entitlements: + entArguments=Entitlement.split(':powerdns-admin') + entArguments=[x.lower() for x in entArguments[0].split(':')] + if (entArguments==urnArguments): + return True + return False + def clear_session(): session.pop('user_id', None) diff --git a/powerdnsadmin/templates/admin_edit_account.html b/powerdnsadmin/templates/admin_edit_account.html index 10344a8..65244fd 100644 --- a/powerdnsadmin/templates/admin_edit_account.html +++ b/powerdnsadmin/templates/admin_edit_account.html @@ -84,7 +84,7 @@ diff --git a/powerdnsadmin/templates/admin_setting_authentication.html b/powerdnsadmin/templates/admin_setting_authentication.html index c73cc2c..640c5ca 100644 --- a/powerdnsadmin/templates/admin_setting_authentication.html +++ b/powerdnsadmin/templates/admin_setting_authentication.html @@ -73,11 +73,19 @@
+
+ {% if error %} +
+ +

Error!

+ {{ error }} +
+ {% endif %}
@@ -186,6 +194,46 @@
+
+ ADVANCE +
+ +
+ +     +
+
+
+ + + +
+ +
+ + + {% if error %} + Please input the correct prefix for your urn value + {% endif %} +
+
+ +
+ +     +
+
+
@@ -261,6 +309,24 @@ +
ADVANCE
+
Provision PDA user privileges based on LDAP Object Attributes. Alternative to Group Security Role Management. +
    +
  • + Roles Autoprovisioning - If toggled on, the PDA Role and the associations of users found in the local db, will be instantly updated from the LDAP server every time they log in. +
  • +
  • + Roles provisioning field - The attribute in the ldap server populated by the urn values where PDA will look for a new Role and/or new associations to domains/accounts. +
  • +
  • + Urn prefix - The prefix used before the static keyword "powerdns-admin" for your entitlements in the ldap server. Must comply with RFC no.8141. +
  • +
  • + Purge Roles If Empty - If toggled on, ldap entries that have no valid "powerdns-admin" records to their autoprovisioning field, will lose all their associations with any domain or account, also reverting to a User in the process, despite their current role in the local db.
    If toggled off, in the same scenario they get to keep their existing associations and their current Role. + +
  • +
+
@@ -625,7 +691,7 @@ {%- endassets %} {% endblock %} + +{% block modals %} + + + + + + +{% endblock %}