From a3a58f16a5fa7104a4e78c3d57e557a734482c8e Mon Sep 17 00:00:00 2001 From: Thomas M Steenholdt Date: Mon, 4 Jun 2018 13:10:02 -0200 Subject: [PATCH 1/5] Initial support for Accounts This adds initial support for accounts a concept meant to signify a customer, a department or any other entity that somehow owns or manages one or more domains. The purpose is to be able to assign an account to any number of domains, making it easy to track who owns or manages a domain, significantly improving manageability in setups with a large number of domains. An account consists of a mandatory, unique `name` and optional `description`, `contact` name and `mail` address. The account `name` is stripped of spaces and symbols, and lower cased before getting stored in the database and in PowerDNS, to help ensure some type of predictability and uniqueness in the database. The term *account* is actually taken from the PowerDNS database, where the `domains.account` column is used to store the account relationship, in in the form of the account `name`. The link to a domain in PowerDNS-Admin is done through the `domain.account_id` FOREIGN KEY, that is linked to the `account.id` PRIMARY KEY. (cherry picked from commits 4e95f33dfb0676d1c401a033c28bca3be7d6ec26, da0d596bd019a339549e2c59630a8fdee65d0e22, 7f06e6aaf4fd8011c784f24b7bbbba5f52aef319, 1c624dad8749024033d1d15dd6242ca52b39f135) --- app/models.py | 179 ++++++++++++++++++++++++- app/templates/admin_editaccount.html | 98 ++++++++++++++ app/templates/admin_manageaccount.html | 127 ++++++++++++++++++ app/templates/base.html | 1 + app/templates/dashboard.html | 5 + app/templates/dashboard_domain.html | 6 + app/templates/domain_add.html | 6 + app/templates/domain_management.html | 26 ++++ app/views.py | 134 ++++++++++++++++-- 9 files changed, 568 insertions(+), 14 deletions(-) create mode 100644 app/templates/admin_editaccount.html create mode 100644 app/templates/admin_manageaccount.html diff --git a/app/models.py b/app/models.py index a3592c7..13ff831 100644 --- a/app/models.py +++ b/app/models.py @@ -426,6 +426,110 @@ class User(db.Model): return False +class Account(db.Model): + __tablename__ = 'account' + id = db.Column(db.Integer, primary_key = True) + name = db.Column(db.String(40), index=True, unique=True, nullable=False) + description = db.Column(db.String(128)) + contact = db.Column(db.String(128)) + mail = db.Column(db.String(128)) + domains = db.relationship("Domain", back_populates="account") + + def __init__(self, name=None, description=None, contact=None, mail=None): + self.name = name + self.description = description + self.contact = contact + self.mail = mail + + if self.name is not None: + self.name = ''.join(c for c in self.name.lower() if c in "abcdefghijklmnopqrstuvwxyz0123456789") + + def __repr__(self): + return ''.format(self.name) + + def get_name_by_id(self, account_id): + """ + Convert account_id to account_name + """ + account = Account.query.filter(Account.id == account_id).first() + if account is None: + return '' + + return account.name + + def get_id_by_name(self, account_name): + """ + Convert account_name to account_id + """ + account = Account.query.filter(Account.name == account_name).first() + if account is None: + return 0 + + return account.id + + def unassociate_domains(self): + """ + Remove associations to this account from all domains + """ + account = Account.query.filter(Account.name == self.name).first() + for domain in account.domains: + domain.assoc_account(None) + + def create_account(self): + """ + Create a new account + """ + # Sanity check - account name + if self.name == "": + return {'status': False, 'msg': 'No account name specified'} + + # check that account name is not already used + account = Account.query.filter(Account.name == self.name).first() + if account: + return {'status': False, 'msg': 'Account already exists'} + + db.session.add(self) + db.session.commit() + return {'status': True, 'msg': 'Account created successfully'} + + def update_account(self): + """ + Update an existing account + """ + # Sanity check - account name + if self.name == "": + return {'status': False, 'msg': 'No account name specified'} + + # read account and check that it exists + account = Account.query.filter(Account.name == self.name).first() + if not account: + return {'status': False, 'msg': 'Account does not exist'} + + account.description = self.description + account.contact = self.contact + account.mail = self.mail + + db.session.commit() + return {'status': True, 'msg': 'Account updated successfully'} + + def delete_account(self): + """ + Delete an account + """ + # unassociate all domains first + self.unassociate_domains() + + try: + Account.query.filter(Account.name == self.name).delete() + db.session.commit() + return True + + except: + db.session.rollback() + logging.error('Cannot delete account {0} from DB'.format(self.username)) + return False + + class Role(db.Model): id = db.Column(db.Integer, primary_key = True) name = db.Column(db.String(64), index=True, unique=True) @@ -446,6 +550,7 @@ class Role(db.Model): def __repr__(self): return ''.format(self.name) + class DomainSetting(db.Model): __tablename__ = 'domain_setting' id = db.Column(db.Integer, primary_key = True) @@ -485,9 +590,11 @@ class Domain(db.Model): notified_serial = db.Column(db.Integer) last_check = db.Column(db.Integer) dnssec = db.Column(db.Integer) + account_id = db.Column(db.Integer, db.ForeignKey('account.id'), nullable = False) + account = db.relationship("Account", back_populates="domains") settings = db.relationship('DomainSetting', back_populates='domain') - def __init__(self, id=None, name=None, master=None, type='NATIVE', serial=None, notified_serial=None, last_check=None, dnssec=None): + def __init__(self, id=None, name=None, master=None, type='NATIVE', serial=None, notified_serial=None, last_check=None, dnssec=None, account_id=None): self.id = id self.name = name self.master = master @@ -496,6 +603,7 @@ class Domain(db.Model): self.notified_serial = notified_serial self.last_check = last_check self.dnssec = dnssec + self.account_id = account_id def __repr__(self): return ''.format(self.name) @@ -575,6 +683,7 @@ class Domain(db.Model): # update/add new domain for data in jdata: + account_id = Account().get_id_by_name(data['account']) d = dict_db_domain.get(data['name'].rstrip('.'), None) changed = False if d: @@ -584,7 +693,8 @@ class Domain(db.Model): or d.serial != data['serial'] or d.notified_serial != data['notified_serial'] or d.last_check != ( 1 if data['last_check'] else 0 ) - or d.dnssec != data['dnssec'] ): + or d.dnssec != data['dnssec'] + or d.account_id != account_id ): d.master = str(data['masters']) d.type = data['kind'] @@ -592,6 +702,7 @@ class Domain(db.Model): d.notified_serial = data['notified_serial'] d.last_check = 1 if data['last_check'] else 0 d.dnssec = 1 if data['dnssec'] else 0 + d.account_id = account_id changed = True else: @@ -604,19 +715,20 @@ class Domain(db.Model): d.notified_serial = data['notified_serial'] d.last_check = data['last_check'] d.dnssec = 1 if data['dnssec'] else 0 + d.account_id = account_id db.session.add(d) changed = True if changed: try: db.session.commit() - except: + except Exception as e: db.session.rollback() return {'status': 'ok', 'msg': 'Domain table has been updated successfully'} except Exception as e: logging.error('Can not update domain table. Error: {0}'.format(e)) return {'status': 'error', 'msg': 'Can not update domain table'} - def add(self, domain_name, domain_type, soa_edit_api, domain_ns=[], domain_master_ips=[]): + def add(self, domain_name, domain_type, soa_edit_api, domain_ns=[], domain_master_ips=[], account_name=None): """ Add a domain to power dns """ @@ -638,7 +750,8 @@ class Domain(db.Model): "kind": domain_type, "masters": domain_master_ips, "nameservers": domain_ns, - "soa_edit_api": soa_edit_api + "soa_edit_api": soa_edit_api, + "account": account_name } try: @@ -647,6 +760,7 @@ class Domain(db.Model): logging.error(jdata['error']) return {'status': 'error', 'msg': jdata['error']} else: + self.update() logging.info('Added domain {0} successfully'.format(domain_name)) return {'status': 'ok', 'msg': 'Added domain successfully'} except Exception as e: @@ -798,7 +912,6 @@ class Domain(db.Model): db.session.rollback() logging.error('Cannot grant user privielges to domain {0}'.format(self.name)) - def update_from_master(self, domain_name): """ Update records from Master DNS server @@ -879,6 +992,60 @@ class Domain(db.Model): else: return {'status': 'error', 'msg': 'This domain doesnot exist'} + def assoc_account(self, account_id): + """ + Associate domain with a domain, specified by account id + """ + domain_name = self.name + + # Sanity check - domain name + if domain_name == "": + return {'status': False, 'msg': 'No domain name specified'} + + # read domain and check that it exists + domain = Domain.query.filter(Domain.name == domain_name).first() + if not domain: + return {'status': False, 'msg': 'Domain does not exist'} + + headers = {} + headers['X-API-Key'] = PDNS_API_KEY + + account_name = Account().get_name_by_id(account_id) + + post_data = { + "account": account_name + } + + try: + jdata = utils.fetch_json( + urljoin(PDNS_STATS_URL, API_EXTENDED_URL + '/servers/localhost/zones/{0}'.format(domain_name)), headers=headers, + method='PUT', data=post_data) + + if 'error' in jdata.keys(): + logging.error(jdata['error']) + return {'status': 'error', 'msg': jdata['error']} + + else: + self.update() + logging.info('account changed for domain {0} successfully'.format(domain_name)) + return {'status': 'ok', 'msg': 'account changed successfully'} + + except Exception as e: + logging.debug(e) + logging.debug(traceback.format_exc()) + logging.error('Cannot change account for domain {0}'.format(domain_name)) + return {'status': 'error', 'msg': 'Cannot change account for this domain.'} + + return {'status': True, 'msg': 'Domain association successful'} + + def get_account(self): + """ + Get current account associated with this domain + """ + domain = Domain.query.filter(Domain.name == self.name).first() + + return domain.account + class DomainUser(db.Model): __tablename__ = 'domain_user' diff --git a/app/templates/admin_editaccount.html b/app/templates/admin_editaccount.html new file mode 100644 index 0000000..04eaf97 --- /dev/null +++ b/app/templates/admin_editaccount.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} +{% block title %}DNS Control Panel - Edit Account{% endblock %} + +{% block dashboard_stat %} + +
+

+ Account + {% if create %}New account{% else %}{{ account.name }}{% endif %} +

+ +
+{% endblock %} + +{% block content %} +
+
+
+
+
+

{% if create %}Add{% else %}Edit{% endif %} account

+
+ + +
+ +
+ {% if error %} +
+ +

Error!

+ {{ error }} +
+ {{ error }} + {% endif %} +
+ + + + {% if invalid_accountname %} + Cannot be blank and must only contain alphanumeric characters. + {% elif duplicate_accountname %} + Account name already in use. + {% endif %} +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+
+
+
+
+

Help with creating a new account

+
+
+

+ An account allows grouping of domains belonging to a particular entity, such as a customer or department.
+ A domain can be assigned to an account upon domain creation or through the domain administration page. +

+

Fill in all the fields to the in the form to the left.

+

+ Name is an account identifier. It will be stored as all lowercase letters (no spaces, special characters etc).
+ Description is a user friendly name for this account.
+ Contact person is the name of a contact person at the account.
+ Mail Address is an e-mail address for the contact person. +

+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/admin_manageaccount.html b/app/templates/admin_manageaccount.html new file mode 100644 index 0000000..7af5125 --- /dev/null +++ b/app/templates/admin_manageaccount.html @@ -0,0 +1,127 @@ +{% extends "base.html" %} {% block title %} +DNS Control Panel - Account Management +{% endblock %} {% block dashboard_stat %} +
+

+ Accounts Manage accounts +

+ +
+{% endblock %} {% block content %} +
+
+
+
+
+

Account Management

+
+ +
+ + + + + + + + + + + + {% for account in accounts %} + + + + + + + + {% endfor %} + +
NameDescriptionContactMailAction
{{ account.name }}{{ account.description }}{{ account.contact }}{{ account.mail }} + + +
+
+ +
+ +
+ +
+ +
+{% endblock %} +{% block extrascripts %} + +{% endblock %} +{% block modals %} + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 1ba7b56..e1d1451 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -129,6 +129,7 @@
  • Admin Console
  • Domain Templates
  • Users
  • +
  • Accounts
  • History
  • Settings
  • {% endif %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 0f806df..204971d 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -134,6 +134,7 @@ Type Serial Master + Account Action @@ -178,6 +179,10 @@ "lengthChange" : true, "searching" : true, "ordering" : true, + "columnDefs": [ + { "orderable": false, "targets": [-1] } + {% if current_user.role.name != 'Administrator' %},{ "visible": false, "targets": [-2] }{% endif %} + ], "processing" : true, "serverSide" : true, "ajax" : "{{ url_for('dashboard_domains') }}", diff --git a/app/templates/dashboard_domain.html b/app/templates/dashboard_domain.html index 2834207..6d42edb 100644 --- a/app/templates/dashboard_domain.html +++ b/app/templates/dashboard_domain.html @@ -22,6 +22,12 @@ {% if domain.master == '[]'%}N/A{% else %}{{ domain.master|display_master_name }}{% endif %} {% endmacro %} +{% macro account(domain) %} + {% if current_user.role.name =='Administrator' %} + {% if domain.account_description != "" %}{{ domain.account.description }} {% endif %}[{{ domain.account.name }}] + {% endif %} +{% endmacro %} + {% macro actions(domain) %} {% if current_user.role.name =='Administrator' %} diff --git a/app/templates/domain_add.html b/app/templates/domain_add.html index d64beb1..64ed76c 100644 --- a/app/templates/domain_add.html +++ b/app/templates/domain_add.html @@ -31,6 +31,12 @@
    +
    diff --git a/app/templates/domain_management.html b/app/templates/domain_management.html index bf35849..aa99915 100644 --- a/app/templates/domain_management.html +++ b/app/templates/domain_management.html @@ -71,6 +71,32 @@
    +
    +
    +
    +
    +

    Account

    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    diff --git a/app/views.py b/app/views.py index 1c65ca1..c593bc0 100644 --- a/app/views.py +++ b/app/views.py @@ -17,7 +17,7 @@ from flask_login import login_user, logout_user, current_user, login_required from werkzeug import secure_filename from werkzeug.security import gen_salt -from .models import User, Domain, Record, Server, History, Anonymous, Setting, DomainSetting, DomainTemplate, DomainTemplateRecord +from .models import User, Account, Domain, Record, Server, History, Anonymous, Setting, DomainSetting, DomainTemplate, DomainTemplateRecord from app import app, login_manager, github, google from app.lib import utils from app.decorators import admin_role_required, can_access_domain @@ -486,7 +486,7 @@ def dashboard_domains(): template = app.jinja_env.get_template("dashboard_domain.html") render = template.make_module(vars={"current_user": current_user}) - columns = [Domain.name, Domain.dnssec, Domain.type, Domain.serial, Domain.master] + columns = [Domain.name, Domain.dnssec, Domain.type, Domain.serial, Domain.master, Domain.account] # History.created_on.desc() order_by = [] for i in range(len(columns)): @@ -509,7 +509,13 @@ def dashboard_domains(): if search: start = "" if search.startswith("^") else "%" end = "" if search.endswith("$") else "%" - domains = domains.filter(Domain.name.ilike(start + search.strip("^$") + end)) + + if current_user.role.name == 'Administrator': + domains = domains.outerjoin(Account).filter(Domain.name.ilike(start + search.strip("^$") + end) | + Account.name.ilike(start + search.strip("^$") + end) | + Account.description.ilike(start + search.strip("^$") + end)) + else: + domains = domains.filter(Domain.name.ilike(start + search.strip("^$") + end)) filtered_count = domains.count() @@ -530,6 +536,7 @@ def dashboard_domains(): render.type(domain), render.serial(domain), render.master(domain), + render.account(domain), render.actions(domain), ]) @@ -595,6 +602,7 @@ def domain_add(): domain_type = request.form.getlist('radio_type')[0] domain_template = request.form.getlist('domain_template')[0] soa_edit_api = request.form.getlist('radio_type_soa_edit_api')[0] + account_id = request.form.getlist('accountid')[0] if ' ' in domain_name or not domain_name or not domain_type: return render_template('errors/400.html', msg="Please correct your input"), 400 @@ -606,10 +614,13 @@ def domain_add(): domain_master_ips = domain_master_string.split(',') else: domain_master_ips = [] + + account_name = Account().get_name_by_id(account_id) + d = Domain() - result = d.add(domain_name=domain_name, domain_type=domain_type, soa_edit_api=soa_edit_api, domain_master_ips=domain_master_ips) + result = d.add(domain_name=domain_name, domain_type=domain_type, soa_edit_api=soa_edit_api, domain_master_ips=domain_master_ips, account_name=account_name) if result['status'] == 'ok': - history = History(msg='Add domain {0}'.format(domain_name), detail=str({'domain_type': domain_type, 'domain_master_ips': domain_master_ips}), created_by=current_user.username) + history = History(msg='Add domain {0}'.format(domain_name), detail=str({'domain_type': domain_type, 'domain_master_ips': domain_master_ips, 'account_id': account_id}), created_by=current_user.username) history.add() if domain_template != '0': template = DomainTemplate.query.filter(DomainTemplate.id == domain_template).first() @@ -632,7 +643,10 @@ def domain_add(): except: logging.error(traceback.print_exc()) return redirect(url_for('error', code=500)) - return render_template('domain_add.html', templates=templates) + + else: + accounts = Account.query.all() + return render_template('domain_add.html', templates=templates, accounts=accounts) @app.route('/admin/domain//delete', methods=['GET']) @@ -660,12 +674,14 @@ def domain_management(domain_name): if not domain: return redirect(url_for('error', code=404)) users = User.query.all() + accounts = Account.query.all() # get list of user ids to initilize selection data d = Domain(name=domain_name) domain_user_ids = d.get_user() + account = d.get_account() - return render_template('domain_management.html', domain=domain, users=users, domain_user_ids=domain_user_ids) + return render_template('domain_management.html', domain=domain, users=users, domain_user_ids=domain_user_ids, accounts=accounts, domain_account=account) if request.method == 'POST': # username in right column @@ -701,9 +717,32 @@ def domain_change_soa_edit_api(domain_name): status = d.update_soa_setting(domain_name=domain_name, soa_edit_api=new_setting) if status['status'] != None: users = User.query.all() + accounts = Account.query.all() d = Domain(name=domain_name) domain_user_ids = d.get_user() - return render_template('domain_management.html', domain=domain, users=users, domain_user_ids=domain_user_ids, status=status) + account = d.get_account() + return render_template('domain_management.html', domain=domain, users=users, domain_user_ids=domain_user_ids, accounts=accounts, domain_account=account) + else: + return redirect(url_for('error', code=500)) + + +@app.route('/admin/domain//change_account', methods=['POST']) +@login_required +@admin_role_required +def domain_change_account(domain_name): + domain = Domain.query.filter(Domain.name == domain_name).first() + if not domain: + return redirect(url_for('error', code=404)) + + account_id = request.form.get('accountid') + status = domain.assoc_account(account_id) + if status['status']: + users = User.query.all() + accounts = Account.query.all() + d = Domain(name=domain_name) + domain_user_ids = d.get_user() + account = d.get_account() + return render_template('domain_management.html', domain=domain, users=users, domain_user_ids=domain_user_ids, accounts=accounts, domain_account=account) else: return redirect(url_for('error', code=500)) @@ -1135,6 +1174,85 @@ def admin_manageuser(): return make_response(jsonify( { 'status': 'error', 'msg': 'There is something wrong, please contact Administrator.' } ), 400) +@app.route('/admin/account/edit/', methods=['GET', 'POST']) +@app.route('/admin/account/edit', methods=['GET', 'POST']) +@login_required +@admin_role_required +def admin_editaccount(account_name=None): + + if request.method == 'GET': + if account_name is None: + return render_template('admin_editaccount.html', create=1) + + else: + account = Account.query.filter(Account.name == account_name).first() + return render_template('admin_editaccount.html', account=account, create=0) + + if request.method == 'POST': + fdata = request.form + + if not account_name: + account_name = fdata['accountname'] + account = Account(name=account_name, description=fdata['accountdescription'], contact=fdata['accountcontact'], mail=fdata['accountmail']) + + create = int(fdata['create']) + if create: + # account __init__ sanitizes and lowercases the name, so to manage expectations + # we let the user reenter the name until it's not empty and it's valid (ignoring the case) + if account.name == "" or account.name != account_name.lower(): + return render_template('admin_editaccount.html', account=account, create=create, invalid_accountname=True) + + if Account.query.filter(Account.name == account_name).first(): + return render_template('admin_editaccount.html', account=account, create=create, duplicate_accountname=True) + + result = account.create_account() + history = History(msg='Create account {0}'.format(account.name), created_by=current_user.username) + + else: + result = account.update_account() + history = History(msg='Update account {0}'.format(account.name), created_by=current_user.username) + + if result['status']: + history.add() + return redirect(url_for('admin_manageaccount')) + + return render_template('admin_editaccount.html', account=account, create=create, error=result['msg']) + + +@app.route('/admin/manageaccount', methods=['GET', 'POST']) +@login_required +@admin_role_required +def admin_manageaccount(): + if request.method == 'GET': + accounts = Account.query.order_by(Account.name).all() + return render_template('admin_manageaccount.html', accounts=accounts) + + if request.method == 'POST': + # + # post data should in format + # {'action': 'delete_account', 'data': 'accountname'} + # + try: + jdata = request.json + data = jdata['data'] + + if jdata['action'] == 'delete_account': + account = Account(name=data) + result = account.delete_account() + if result: + history = History(msg='Delete account {0}'.format(data), created_by=current_user.username) + history.add() + return make_response(jsonify( { 'status': 'ok', 'msg': 'Account has been removed.' } ), 200) + else: + return make_response(jsonify( { 'status': 'error', 'msg': 'Cannot remove account.' } ), 500) + + else: + return make_response(jsonify( { 'status': 'error', 'msg': 'Action not supported.' } ), 400) + except: + logging.error(traceback.print_exc()) + return make_response(jsonify( { 'status': 'error', 'msg': 'There is something wrong, please contact Administrator.' } ), 400) + + @app.route('/admin/history', methods=['GET', 'POST']) @login_required @admin_role_required From a4af4ad4b3f820255cbe1ccc8dff37134672487c Mon Sep 17 00:00:00 2001 From: Thomas M Steenholdt Date: Tue, 5 Jun 2018 16:41:39 -0200 Subject: [PATCH 2/5] Implement per account domain access Added the possibility for assigning users to an account, providing access to all domains associated with that account automatically. This makes management easier, especially in installations with lots of domains and lots of managing entities. The old style per-domain permissions are still there and working as usual. The two methods work perfectly side-by-side and are analogous to "user" (per-domain) and "group" (account) permissions as we know them from Active Directory and such places. (cherry picked from commit 34fbc634d2848a7f76dc89a03dd8c0604068cc17) --- app/models.py | 102 +++++++++++++++++++++++++-- app/templates/admin_editaccount.html | 20 ++++++ app/views.py | 25 ++++--- 3 files changed, 131 insertions(+), 16 deletions(-) diff --git a/app/models.py b/app/models.py index 13ff831..c8d4244 100644 --- a/app/models.py +++ b/app/models.py @@ -355,25 +355,48 @@ class User(db.Model): db.session.rollback() return False + def get_account_query(self): + """ + Get query for account to which the user is associated. + """ + return db.session.query(Account) \ + .outerjoin(AccountUser, Account.id==AccountUser.account_id) \ + .filter(AccountUser.user_id==self.id) + + def get_account(self): + """ + Get all accounts to which the user is associated. + """ + return self.get_account_query() + def get_domain_query(self): - return db.session.query(User, DomainUser, Domain) \ - .filter(User.id == self.id) \ - .filter(User.id == DomainUser.user_id) \ - .filter(Domain.id == DomainUser.domain_id) + """ + Get query for domain to which the user has access permission. + This includes direct domain permission AND permission through + account membership + """ + return 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==User.id, AccountUser.user_id==User.id)) \ + .filter(User.id==self.id) def get_domain(self): """ Get domains which user has permission to access """ - return [q[2] for q in self.get_domain_query()] + return self.get_domain_query() def delete(self): """ Delete a user """ - # revoke all user privileges first + # revoke all user privileges and account associations first self.revoke_privilege() + for a in self.get_account(): + a.revoke_privileges_by_id(self.id) try: User.query.filter(User.username == self.username).delete() @@ -516,8 +539,9 @@ class Account(db.Model): """ Delete an account """ - # unassociate all domains first + # unassociate all domains and users first self.unassociate_domains() + self.grant_privileges([]) try: Account.query.filter(Account.name == self.name).delete() @@ -529,6 +553,56 @@ class Account(db.Model): logging.error('Cannot delete account {0} from DB'.format(self.username)) return False + def get_user(self): + """ + Get users (id) associated with this account + """ + user_ids = [] + query = db.session.query(AccountUser, Account).filter(User.id==AccountUser.user_id).filter(Account.id==AccountUser.account_id).filter(Account.name==self.name).all() + for q in query: + user_ids.append(q[0].user_id) + return user_ids + + def grant_privileges(self, new_user_list): + """ + Reconfigure account_user table + """ + account_id = self.get_id_by_name(self.name) + + account_user_ids = self.get_user() + new_user_ids = [u.id for u in User.query.filter(User.username.in_(new_user_list)).all()] if new_user_list else [] + + removed_ids = list(set(account_user_ids).difference(new_user_ids)) + added_ids = list(set(new_user_ids).difference(account_user_ids)) + + try: + for uid in removed_ids: + AccountUser.query.filter(AccountUser.user_id == uid).filter(AccountUser.account_id==account_id).delete() + db.session.commit() + except: + db.session.rollback() + logging.error('Cannot revoke user privielges on account {0}'.format(self.name)) + + try: + for uid in added_ids: + au = AccountUser(account_id, uid) + db.session.add(au) + db.session.commit() + except: + db.session.rollback() + logging.error('Cannot grant user privileges to account {0}'.format(self.name)) + + def revoke_privileges_by_id(self, user_id): + """ + Remove a single user from prigilege 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) + class Role(db.Model): id = db.Column(db.Integer, primary_key = True) @@ -1061,6 +1135,20 @@ class DomainUser(db.Model): return ''.format(self.domain_id, self.user_id) +class AccountUser(db.Model): + __tablename__ = 'account_user' + id = db.Column(db.Integer, primary_key = True) + account_id = db.Column(db.Integer, db.ForeignKey('account.id'), nullable = False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable = False) + + def __init__(self, account_id, user_id): + self.account_id = account_id + self.user_id = user_id + + def __repr__(self): + return ''.format(self.account_id, self.user_id) + + class Record(object): """ This is not a model, it's just an object diff --git a/app/templates/admin_editaccount.html b/app/templates/admin_editaccount.html index 04eaf97..1f31650 100644 --- a/app/templates/admin_editaccount.html +++ b/app/templates/admin_editaccount.html @@ -67,6 +67,21 @@
    +
    +

    Access Control

    +
    +
    +

    Users on the right have access to manage records in all domains + associated with the account.

    +

    Click on users to move between columns.

    +
    + +
    +
    @@ -95,4 +110,9 @@
    +{% endblock %} +{% block extrascripts %} + {% endblock %} \ No newline at end of file diff --git a/app/views.py b/app/views.py index c593bc0..d6160e5 100644 --- a/app/views.py +++ b/app/views.py @@ -525,9 +525,6 @@ def dashboard_domains(): if length != -1: domains = domains[start:start + length] - if current_user.role.name != 'Administrator': - domains = [d[2] for d in domains] - data = [] for domain in domains: data.append([ @@ -1179,31 +1176,40 @@ def admin_manageuser(): @login_required @admin_role_required def admin_editaccount(account_name=None): + users = User.query.all() if request.method == 'GET': if account_name is None: - return render_template('admin_editaccount.html', create=1) + return render_template('admin_editaccount.html', users=users, create=1) else: account = Account.query.filter(Account.name == account_name).first() - return render_template('admin_editaccount.html', account=account, create=0) + account_user_ids = account.get_user() + return render_template('admin_editaccount.html', account=account, account_user_ids=account_user_ids, users=users, create=0) if request.method == 'POST': fdata = request.form + new_user_list = request.form.getlist('account_multi_user') + # on POST, synthesize account and account_user_ids from form data if not account_name: account_name = fdata['accountname'] + account = Account(name=account_name, description=fdata['accountdescription'], contact=fdata['accountcontact'], mail=fdata['accountmail']) + account_user_ids = [] + for username in new_user_list: + userid = User(username=username).get_user_info_by_username().id + account_user_ids.append(userid) create = int(fdata['create']) if create: # account __init__ sanitizes and lowercases the name, so to manage expectations # we let the user reenter the name until it's not empty and it's valid (ignoring the case) if account.name == "" or account.name != account_name.lower(): - return render_template('admin_editaccount.html', account=account, create=create, invalid_accountname=True) + return render_template('admin_editaccount.html', account=account, account_user_ids=account_user_ids, users=users, create=create, invalid_accountname=True) - if Account.query.filter(Account.name == account_name).first(): - return render_template('admin_editaccount.html', account=account, create=create, duplicate_accountname=True) + if Account.query.filter(Account.name == account.name).first(): + return render_template('admin_editaccount.html', account=account, account_user_ids=account_user_ids, users=users, create=create, duplicate_accountname=True) result = account.create_account() history = History(msg='Create account {0}'.format(account.name), created_by=current_user.username) @@ -1213,10 +1219,11 @@ def admin_editaccount(account_name=None): history = History(msg='Update account {0}'.format(account.name), created_by=current_user.username) if result['status']: + account.grant_privileges(new_user_list) history.add() return redirect(url_for('admin_manageaccount')) - return render_template('admin_editaccount.html', account=account, create=create, error=result['msg']) + return render_template('admin_editaccount.html', account=account, account_user_ids=account_user_ids, users=users, create=create, error=result['msg']) @app.route('/admin/manageaccount', methods=['GET', 'POST']) From 2b3b67a3afda35431d205f336bfa3cacfc53b564 Mon Sep 17 00:00:00 2001 From: Thomas M Steenholdt Date: Wed, 6 Jun 2018 11:59:15 -0200 Subject: [PATCH 3/5] Fix foreign key constraint error on MySQL (cherry picked from commit 2a9108f90482a6be86d0b8af4dfcc30f6651ff28) --- app/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models.py b/app/models.py index c8d4244..f3ad5ac 100644 --- a/app/models.py +++ b/app/models.py @@ -486,7 +486,7 @@ class Account(db.Model): """ account = Account.query.filter(Account.name == account_name).first() if account is None: - return 0 + return None return account.id @@ -664,7 +664,7 @@ class Domain(db.Model): notified_serial = db.Column(db.Integer) last_check = db.Column(db.Integer) dnssec = db.Column(db.Integer) - account_id = db.Column(db.Integer, db.ForeignKey('account.id'), nullable = False) + account_id = db.Column(db.Integer, db.ForeignKey('account.id')) account = db.relationship("Account", back_populates="domains") settings = db.relationship('DomainSetting', back_populates='domain') From fb234e359253c649e2c420dc7b50c77dbf5f51c5 Mon Sep 17 00:00:00 2001 From: Khanh Ngo Date: Mon, 11 Jun 2018 17:12:52 +0700 Subject: [PATCH 4/5] Add .gitattributes --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b1bb66a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +app/static/* linguist-vendored + From daba67611b59102e6e60a911decb6b13c2fc5a8f Mon Sep 17 00:00:00 2001 From: Thomas M Steenholdt Date: Tue, 12 Jun 2018 14:00:21 -0200 Subject: [PATCH 5/5] Enable pool_pre_ping in DB connection To avoid problems with inactive DB connections, SQLAlchemy provides a `pool_pre_ping` option, that described in more detail here: http://docs.sqlalchemy.org/en/latest/core/pooling.html#disconnect-handling-pessimistic In flask environments, it's enabled by subclassing SQLAlchemy, which is what I've done here. Fixes errors like: sqlalchemy.exc.OperationalError: (_mysql_exceptions.OperationalError) (2006, 'MySQL server has gone away') which results in an Error 500 in the UI. --- app/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/__init__.py b/app/__init__.py index c309b78..c5a8bcc 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,7 +1,15 @@ from werkzeug.contrib.fixers import ProxyFix from flask import Flask, request, session, redirect, url_for from flask_login import LoginManager -from flask_sqlalchemy import SQLAlchemy +from flask_sqlalchemy import SQLAlchemy as SA + + +# subclass SQLAlchemy to enable pool_pre_ping +class SQLAlchemy(SA): + def apply_pool_defaults(self, app, options): + SA.apply_pool_defaults(self, app, options) + options["pool_pre_ping"] = True + app = Flask(__name__) app.config.from_object('config')