diff --git a/admin/audit.py b/admin/audit.py
new file mode 100644
index 00000000..fdd997f6
--- /dev/null
+++ b/admin/audit.py
@@ -0,0 +1,43 @@
+from freeposte import app
+
+import sys
+import tabulate
+
+
+# Known endpoints without permissions
+known_missing_permissions = [
+ "index",
+ "static", "bootstrap.static",
+ "admin.static", "admin.login"
+]
+
+
+# Compute the permission table
+missing_permissions = []
+permissions = {}
+for endpoint, function in app.view_functions.items():
+ audit = function.__dict__.get("_audit_permissions")
+ if audit:
+ handler, args = audit
+ if args:
+ model = args[0].__name__
+ key = args[1]
+ else:
+ model = key = None
+ permissions[endpoint] = [endpoint, handler.__name__, model, key]
+ elif endpoint not in known_missing_permissions:
+ missing_permissions.append(endpoint)
+
+
+# Fail if any endpoint is missing a permission check
+if missing_permissions:
+ print("The following endpoints are missing permission checks:")
+ print(missing_permissions.join(","))
+ sys.exit(1)
+
+
+# Display the permissions table
+print(tabulate.tabulate([
+ [route, *permissions[route.endpoint]]
+ for route in app.url_map.iter_rules() if route.endpoint in permissions
+]))
diff --git a/admin/freeposte/admin/access.py b/admin/freeposte/admin/access.py
new file mode 100644
index 00000000..34dbbfdd
--- /dev/null
+++ b/admin/freeposte/admin/access.py
@@ -0,0 +1,114 @@
+from freeposte.admin import db, models, forms
+
+import flask
+import flask_login
+import functools
+
+
+def permissions_wrapper(handler):
+ """ Decorator that produces a decorator for checking permissions.
+ """
+ def callback(function, args, kwargs, dargs, dkwargs):
+ authorized = handler(args, kwargs, *dargs, **dkwargs)
+ if not authorized:
+ flask.abort(403)
+ elif type(authorized) is int:
+ flask.abort(authorized)
+ else:
+ return function(*args, **kwargs)
+ # If the handler has no argument, declare a
+ # simple decorator, otherwise declare a nested decorator
+ # There are at least two mandatory arguments
+ if handler.__code__.co_argcount > 2:
+ def decorator(*dargs, **dkwargs):
+ def inner(function):
+ @functools.wraps(function)
+ def wrapper(*args, **kwargs):
+ return callback(function, args, kwargs, dargs, dkwargs)
+ wrapper._audit_permissions = handler, dargs
+ return flask_login.login_required(wrapper)
+ return inner
+ else:
+ def decorator(function):
+ @functools.wraps(function)
+ def wrapper(*args, **kwargs):
+ return callback(function, args, kwargs, (), {})
+ wrapper._audit_permissions = handler, []
+ return flask_login.login_required(wrapper)
+ return decorator
+
+
+@permissions_wrapper
+def global_admin(args, kwargs):
+ """ The view is only available to global administrators.
+ """
+ return flask_login.current_user.global_admin
+
+
+@permissions_wrapper
+def domain_admin(args, kwargs, model, key):
+ """ The view is only available to specific domain admins.
+ Global admins will still be able to access the resource.
+
+ A model and key must be provided. The model will be queries
+ based on the query parameter named after the key. The model may
+ either be Domain or an Email subclass (or any class with a
+ ``domain`` attribute which stores a related Domain instance).
+ """
+ obj = model.query.get(kwargs[key])
+ if not obj:
+ flask.abort(404)
+ else:
+ domain = obj if type(obj) is models.Domain else obj.domain
+ return domain in flask_login.current_user.get_managed_domains()
+
+
+@permissions_wrapper
+def owner(args, kwargs, model, key):
+ """ The view is only available to the resource owner or manager.
+
+ A model and key must be provided. The model will be queries
+ based on the query parameter named after the key. The model may
+ either be User or any model with a ``user`` attribute storing
+ a user instance (like Fetch).
+
+ If the query parameter is empty and the model is User, then
+ the resource being accessed is supposed to be the current
+ logged in user and access is obviously authorized.
+ """
+ if kwargs[key] is None and model == models.User:
+ return True
+ obj = model.query.get(kwargs[key])
+ if not obj:
+ flask.abort(404)
+ else:
+ user = obj if type(obj) is models.User else obj.user
+ return (
+ user.email == flask_login.current_user.email
+ or user.domain in flask_login.current_user.get_managed_domains()
+ )
+
+
+@permissions_wrapper
+def authenticated(args, kwargs):
+ """ The view is only available to logged in users.
+ """
+ return True
+
+
+
+def confirmation_required(action):
+ """ View decorator that asks for a confirmation first.
+ """
+ def inner(function):
+ @functools.wraps(function)
+ def wrapper(*args, **kwargs):
+ form = forms.ConfirmationForm()
+ if form.validate_on_submit():
+ return function(*args, **kwargs)
+ return flask.render_template(
+ "confirm.html", action=action.format(*args, **kwargs),
+ form=form
+ )
+ return wrapper
+ return inner
diff --git a/admin/freeposte/admin/templates/manager/list.html b/admin/freeposte/admin/templates/manager/list.html
index 23ef317f..d1c9da2d 100644
--- a/admin/freeposte/admin/templates/manager/list.html
+++ b/admin/freeposte/admin/templates/manager/list.html
@@ -22,7 +22,7 @@ Manager list
{% for manager in domain.managers %}
-
+
|
{{ manager }} |
diff --git a/admin/freeposte/admin/utils.py b/admin/freeposte/admin/utils.py
deleted file mode 100644
index 6deaf53c..00000000
--- a/admin/freeposte/admin/utils.py
+++ /dev/null
@@ -1,69 +0,0 @@
-from freeposte.admin import models, forms
-
-import flask
-import flask_login
-import functools
-
-
-def confirmation_required(action):
- """ View decorator that asks for a confirmation first.
- """
- def inner(function):
- @functools.wraps(function)
- def wrapper(*args, **kwargs):
- form = forms.ConfirmationForm()
- if form.validate_on_submit():
- return function(*args, **kwargs)
- return flask.render_template(
- "confirm.html", action=action.format(*args, **kwargs),
- form=form
- )
- return wrapper
- return inner
-
-
-def get_domain_admin(domain_name):
- domain = models.Domain.query.get(domain_name)
- if not domain:
- flask.abort(404)
- if not domain in flask_login.current_user.get_managed_domains():
- flask.abort(403)
- return domain
-
-
-def require_global_admin():
- if not flask_login.current_user.global_admin:
- flask.abort(403)
-
-
-def get_user(user_email, admin=False):
- if user_email is None:
- user_email = flask_login.current_user.email
- user = models.User.query.get(user_email)
- if not user:
- flask.abort(404)
- if not user.domain in flask_login.current_user.get_managed_domains():
- if admin:
- flask.abort(403)
- elif not user.email == flask_login.current_user.email:
- flask.abort(403)
- return user
-
-
-def get_alias(alias):
- alias = models.Alias.query.get(alias)
- if not alias:
- flask.abort(404)
- if not alias.domain in flask_login.current_user.get_managed_domains():
- return 403
- return alias
-
-
-def get_fetch(fetch_id):
- fetch = models.Fetch.query.get(fetch_id)
- if not fetch:
- flask.abort(404)
- if not fetch.user.domain in flask_login.current_user.get_managed_domains():
- if not fetch.user.email == flask_login.current_user.email:
- flask.abort(403)
- return fetch
diff --git a/admin/freeposte/admin/views/admins.py b/admin/freeposte/admin/views/admins.py
index 3e4cc3f7..0e314f4b 100644
--- a/admin/freeposte/admin/views/admins.py
+++ b/admin/freeposte/admin/views/admins.py
@@ -1,24 +1,19 @@
-from freeposte.admin import app, db, models, forms, utils
+from freeposte.admin import app, db, models, forms, access
-import os
-import pprint
import flask
import flask_login
-import json
@app.route('/admin/list', methods=['GET'])
-@flask_login.login_required
+@access.global_admin
def admin_list():
- utils.require_global_admin()
admins = models.User.query.filter_by(global_admin=True)
return flask.render_template('admin/list.html', admins=admins)
@app.route('/admin/create', methods=['GET', 'POST'])
-@flask_login.login_required
+@access.global_admin
def admin_create():
- utils.require_global_admin()
form = forms.AdminForm()
form.admin.choices = [
(user.email, user.email)
@@ -38,10 +33,9 @@ def admin_create():
@app.route('/admin/delete/', methods=['GET', 'POST'])
-@utils.confirmation_required("delete admin {admin}")
-@flask_login.login_required
+@access.global_admin
+@access.confirmation_required("delete admin {admin}")
def admin_delete(admin):
- utils.require_global_admin()
user = models.User.query.get(admin)
if user:
user.global_admin = False
diff --git a/admin/freeposte/admin/views/aliases.py b/admin/freeposte/admin/views/aliases.py
index b60ff7c0..aad93945 100644
--- a/admin/freeposte/admin/views/aliases.py
+++ b/admin/freeposte/admin/views/aliases.py
@@ -1,22 +1,20 @@
-from freeposte.admin import app, db, models, forms, utils
+from freeposte.admin import app, db, models, forms, access
-import os
import flask
-import flask_login
import wtforms_components
@app.route('/alias/list/', methods=['GET'])
-@flask_login.login_required
+@access.domain_admin(models.Domain, 'domain_name')
def alias_list(domain_name):
- domain = utils.get_domain_admin(domain_name)
+ domain = models.Domain.query.get(domain_name) or flask.abort(404)
return flask.render_template('alias/list.html', domain=domain)
@app.route('/alias/create/', methods=['GET', 'POST'])
-@flask_login.login_required
+@access.domain_admin(models.Domain, 'domain_name')
def alias_create(domain_name):
- domain = utils.get_domain_admin(domain_name)
+ domain = models.Domain.query.get(domain_name) or flask.abort(404)
if domain.max_aliases and len(domain.aliases) >= domain.max_aliases:
flask.flash('Too many aliases for domain %s' % domain, 'error')
return flask.redirect(
@@ -38,9 +36,9 @@ def alias_create(domain_name):
@app.route('/alias/edit/', methods=['GET', 'POST'])
-@flask_login.login_required
+@access.domain_admin(models.Alias, 'alias')
def alias_edit(alias):
- alias = utils.get_alias(alias)
+ alias = models.Alias.query.get(alias) or flask.abort(404)
form = forms.AliasForm(obj=alias)
wtforms_components.read_only(form.localpart)
if form.validate_on_submit():
@@ -54,10 +52,10 @@ def alias_edit(alias):
@app.route('/alias/delete/', methods=['GET', 'POST'])
-@utils.confirmation_required("delete {alias}")
-@flask_login.login_required
+@access.domain_admin(models.Alias, 'alias')
+@access.confirmation_required("delete {alias}")
def alias_delete(alias):
- alias = utils.get_alias(alias)
+ alias = models.Alias.query.get(alias) or flask.abort(404)
db.session.delete(alias)
db.session.commit()
flask.flash('Alias %s deleted' % alias)
diff --git a/admin/freeposte/admin/views/base.py b/admin/freeposte/admin/views/base.py
index 33e5d48d..d2e1ef8a 100644
--- a/admin/freeposte/admin/views/base.py
+++ b/admin/freeposte/admin/views/base.py
@@ -1,13 +1,12 @@
from freeposte import dockercli
-from freeposte.admin import app, db, models, forms, utils
+from freeposte.admin import app, db, models, forms, access
-import os
import flask
import flask_login
@app.route('/', methods=["GET"])
-@flask_login.login_required
+@access.authenticated
def index():
return flask.redirect(flask.url_for('.user_settings'))
@@ -26,16 +25,15 @@ def login():
@app.route('/logout', methods=['GET'])
-@flask_login.login_required
+@access.authenticated
def logout():
flask_login.logout_user()
return flask.redirect(flask.url_for('.index'))
@app.route('/services', methods=['GET'])
-@flask_login.login_required
+@access.global_admin
def services():
- utils.require_global_admin()
containers = {}
for brief in dockercli.containers(all=True):
if brief['Image'].startswith('freeposte/'):
diff --git a/admin/freeposte/admin/views/domains.py b/admin/freeposte/admin/views/domains.py
index 93b0449e..248423ee 100644
--- a/admin/freeposte/admin/views/domains.py
+++ b/admin/freeposte/admin/views/domains.py
@@ -1,22 +1,19 @@
-from freeposte.admin import app, db, models, forms, utils
+from freeposte.admin import app, db, models, forms, access
from freeposte import app as flask_app
-import os
import flask
-import flask_login
import wtforms_components
@app.route('/domain', methods=['GET'])
-@flask_login.login_required
+@access.authenticated
def domain_list():
return flask.render_template('domain/list.html')
@app.route('/domain/create', methods=['GET', 'POST'])
-@flask_login.login_required
+@access.global_admin
def domain_create():
- utils.require_global_admin()
form = forms.DomainForm()
if form.validate_on_submit():
if models.Domain.query.get(form.name.data):
@@ -32,10 +29,9 @@ def domain_create():
@app.route('/domain/edit/', methods=['GET', 'POST'])
-@flask_login.login_required
+@access.global_admin
def domain_edit(domain_name):
- utils.require_global_admin()
- domain = utils.get_domain_admin(domain_name)
+ domain = models.Domain.query.get(domain_name) or flask.abort(404)
form = forms.DomainForm(obj=domain)
wtforms_components.read_only(form.name)
if form.validate_on_submit():
@@ -48,11 +44,10 @@ def domain_edit(domain_name):
@app.route('/domain/delete/', methods=['GET', 'POST'])
-@utils.confirmation_required("delete {domain_name}")
-@flask_login.login_required
+@access.global_admin
+@access.confirmation_required("delete {domain_name}")
def domain_delete(domain_name):
- utils.require_global_admin()
- domain = utils.get_domain_admin(domain_name)
+ domain = models.Domain.query.get(domain_name) or flask.abort(404)
db.session.delete(domain)
db.session.commit()
flask.flash('Domain %s deleted' % domain)
@@ -60,18 +55,18 @@ def domain_delete(domain_name):
@app.route('/domain/details/', methods=['GET'])
-@flask_login.login_required
+@access.domain_admin(models.Domain, 'domain_name')
def domain_details(domain_name):
- domain = utils.get_domain_admin(domain_name)
+ domain = models.Domain.query.get(domain_name) or flask.abort(404)
return flask.render_template('domain/details.html', domain=domain,
config=flask_app.config)
@app.route('/domain/genkeys/', methods=['GET', 'POST'])
-@utils.confirmation_required("regenerate keys for {domain_name}")
-@flask_login.login_required
+@access.domain_admin(models.Domain, 'domain_name')
+@access.confirmation_required("regenerate keys for {domain_name}")
def domain_genkeys(domain_name):
- domain = utils.get_domain_admin(domain_name)
+ domain = models.Domain.query.get(domain_name) or flask.abort(404)
domain.generate_dkim_key()
return flask.redirect(
flask.url_for(".domain_details", domain_name=domain_name))
diff --git a/admin/freeposte/admin/views/fetches.py b/admin/freeposte/admin/views/fetches.py
index 5870ef2a..d9cdf7c2 100644
--- a/admin/freeposte/admin/views/fetches.py
+++ b/admin/freeposte/admin/views/fetches.py
@@ -1,24 +1,24 @@
-from freeposte.admin import app, db, models, forms, utils
+from freeposte.admin import app, db, models, forms, access
-import os
import flask
import flask_login
-import wtforms_components
@app.route('/fetch/list', methods=['GET', 'POST'], defaults={'user_email': None})
@app.route('/fetch/list/', methods=['GET'])
-@flask_login.login_required
+@access.owner(models.User, 'user_email')
def fetch_list(user_email):
- user = utils.get_user(user_email)
+ user_email = user_email or flask_login.current_user.email
+ user = models.User.query.get(user_email) or flask.abort(404)
return flask.render_template('fetch/list.html', user=user)
@app.route('/fetch/create', methods=['GET', 'POST'], defaults={'user_email': None})
@app.route('/fetch/create/', methods=['GET', 'POST'])
-@flask_login.login_required
+@access.owner(models.User, 'user_email')
def fetch_create(user_email):
- user = utils.get_user(user_email)
+ user_email = user_email or flask_login.current_user.email
+ user = models.User.query.get(user_email) or flask.abort(404)
form = forms.FetchForm()
if form.validate_on_submit():
fetch = models.Fetch(user=user)
@@ -32,9 +32,9 @@ def fetch_create(user_email):
@app.route('/fetch/edit/', methods=['GET', 'POST'])
-@flask_login.login_required
+@access.owner(models.Fetch, 'fetch_id')
def fetch_edit(fetch_id):
- fetch = utils.get_fetch(fetch_id)
+ fetch = models.Fetch.query.get(fetch_id) or flask.abort(404)
form = forms.FetchForm(obj=fetch)
if form.validate_on_submit():
form.populate_obj(fetch)
@@ -47,10 +47,10 @@ def fetch_edit(fetch_id):
@app.route('/fetch/delete/', methods=['GET', 'POST'])
-@utils.confirmation_required("delete a fetched account")
-@flask_login.login_required
+@access.confirmation_required("delete a fetched account")
+@access.owner(models.Fetch, 'fetch_id')
def fetch_delete(fetch_id):
- fetch = utils.get_fetch(fetch_id)
+ fetch = models.Fetch.query.get(fetch_id) or flask.abort(404)
db.session.delete(fetch)
db.session.commit()
flask.flash('Fetch configuration delete')
diff --git a/admin/freeposte/admin/views/managers.py b/admin/freeposte/admin/views/managers.py
index b19789df..7e464c48 100644
--- a/admin/freeposte/admin/views/managers.py
+++ b/admin/freeposte/admin/views/managers.py
@@ -1,30 +1,31 @@
-from freeposte.admin import app, db, models, forms, utils
+from freeposte.admin import app, db, models, forms, access
-import os
import flask
import flask_login
-import wtforms_components
@app.route('/manager/list/', methods=['GET'])
-@flask_login.login_required
+@access.domain_admin(models.Domain, 'domain_name')
def manager_list(domain_name):
- domain = utils.get_domain_admin(domain_name)
+ domain = models.Domain.query.get(domain_name) or flask.abort(404)
return flask.render_template('manager/list.html', domain=domain)
@app.route('/manager/create/', methods=['GET', 'POST'])
-@flask_login.login_required
+@access.domain_admin(models.Domain, 'domain_name')
def manager_create(domain_name):
- domain = utils.get_domain_admin(domain_name)
+ domain = models.Domain.query.get(domain_name) or flask.abort(404)
form = forms.ManagerForm()
+ available_users = flask_login.current_user.get_managed_emails(
+ include_aliases=False)
form.manager.choices = [
- (user.email, user.email) for user in
- flask_login.current_user.get_managed_emails(include_aliases=False)
+ (user.email, user.email) for user in available_users
]
if form.validate_on_submit():
- user = utils.get_user(form.manager.data, admin=True)
- if user in domain.managers:
+ user = models.User.query.get(form.manager.data)
+ if user not in available_users:
+ flask.abort(403)
+ elif user in domain.managers:
flask.flash('User %s is already manager' % user, 'error')
else:
domain.managers.append(user)
@@ -36,12 +37,12 @@ def manager_create(domain_name):
domain=domain, form=form)
-@app.route('/manager/delete/', methods=['GET', 'POST'])
-@utils.confirmation_required("remove manager {manager}")
-@flask_login.login_required
-def manager_delete(manager):
- user = utils.get_user(manager, admin=True)
- domain = utils.get_domain_admin(user.domain_name)
+@app.route('/manager/delete//', methods=['GET', 'POST'])
+@access.confirmation_required("remove manager {user_email}")
+@access.domain_admin(models.Domain, 'domain_name')
+def manager_delete(domain_name, user_email):
+ domain = models.Domain.query.get(domain_name) or flask.abort(404)
+ user = models.User.query.get(user_email) or flask.abort(404)
if user in domain.managers:
domain.managers.remove(user)
db.session.commit()
@@ -49,4 +50,4 @@ def manager_delete(manager):
else:
flask.flash('User %s is not manager' % user, 'error')
return flask.redirect(
- flask.url_for('.manager_list', domain_name=domain.name))
+ flask.url_for('.manager_list', domain_name=domain_name))
diff --git a/admin/freeposte/admin/views/users.py b/admin/freeposte/admin/views/users.py
index 0c6c6374..524d0804 100644
--- a/admin/freeposte/admin/views/users.py
+++ b/admin/freeposte/admin/views/users.py
@@ -1,22 +1,21 @@
-from freeposte.admin import app, db, models, forms, utils
+from freeposte.admin import app, db, models, forms, access
-import os
import flask
import flask_login
import wtforms_components
@app.route('/user/list/', methods=['GET'])
-@flask_login.login_required
+@access.domain_admin(models.Domain, 'domain_name')
def user_list(domain_name):
- domain = utils.get_domain_admin(domain_name)
+ domain = models.Domain.query.get(domain_name) or flask.abort(404)
return flask.render_template('user/list.html', domain=domain)
@app.route('/user/create/', methods=['GET', 'POST'])
-@flask_login.login_required
+@access.domain_admin(models.Domain, 'domain_name')
def user_create(domain_name):
- domain = utils.get_domain_admin(domain_name)
+ domain = models.Domain.query.get(domain_name) or flask.abort(404)
if domain.max_users and len(domain.users) >= domain.max_users:
flask.flash('Too many users for domain %s' % domain, 'error')
return flask.redirect(
@@ -39,9 +38,9 @@ def user_create(domain_name):
@app.route('/user/edit/', methods=['GET', 'POST'])
-@flask_login.login_required
+@access.domain_admin(models.User, 'user_email')
def user_edit(user_email):
- user = utils.get_user(user_email, True)
+ user = models.User.query.get(user_email) or flask.abort(404)
form = forms.UserForm(obj=user)
wtforms_components.read_only(form.localpart)
form.pw.validators = []
@@ -57,10 +56,10 @@ def user_edit(user_email):
@app.route('/user/delete/', methods=['GET', 'POST'])
-@utils.confirmation_required("delete {user_email}")
-@flask_login.login_required
+@access.domain_admin(models.User, 'user_email')
+@access.confirmation_required("delete {user_email}")
def user_delete(user_email):
- user = utils.get_user(user_email, True)
+ user = models.User.query.get(user_email) or flask.abort(404)
db.session.delete(user)
db.session.commit()
flask.flash('User %s deleted' % user)
@@ -70,9 +69,10 @@ def user_delete(user_email):
@app.route('/user/settings', methods=['GET', 'POST'], defaults={'user_email': None})
@app.route('/user/usersettings/', methods=['GET', 'POST'])
-@flask_login.login_required
+@access.owner(models.User, 'user_email')
def user_settings(user_email):
- user = utils.get_user(user_email)
+ user_email = user_email or flask_login.current_user.email
+ user = models.User.query.get(user_email) or flask.abort(404)
form = forms.UserSettingsForm(obj=user)
if form.validate_on_submit():
form.populate_obj(user)
@@ -86,9 +86,10 @@ def user_settings(user_email):
@app.route('/user/password', methods=['GET', 'POST'], defaults={'user_email': None})
@app.route('/user/password/', methods=['GET', 'POST'])
-@flask_login.login_required
+@access.owner(models.User, 'user_email')
def user_password(user_email):
- user = utils.get_user(user_email)
+ user_email = user_email or flask_login.current_user.email
+ user = models.User.query.get(user_email) or flask.abort(404)
form = forms.UserPasswordForm()
if form.validate_on_submit():
if form.pw.data != form.pw2.data:
@@ -105,9 +106,10 @@ def user_password(user_email):
@app.route('/user/forward', methods=['GET', 'POST'], defaults={'user_email': None})
@app.route('/user/forward/', methods=['GET', 'POST'])
-@flask_login.login_required
+@access.owner(models.User, 'user_email')
def user_forward(user_email):
- user = utils.get_user(user_email)
+ user_email = user_email or flask_login.current_user.email
+ user = models.User.query.get(user_email) or flask.abort(404)
form = forms.UserForwardForm(obj=user)
if form.validate_on_submit():
form.populate_obj(user)
@@ -121,9 +123,10 @@ def user_forward(user_email):
@app.route('/user/reply', methods=['GET', 'POST'], defaults={'user_email': None})
@app.route('/user/reply/', methods=['GET', 'POST'])
-@flask_login.login_required
+@access.owner(models.User, 'user_email')
def user_reply(user_email):
- user = utils.get_user(user_email)
+ user_email = user_email or flask_login.current_user.email
+ user = models.User.query.get(user_email) or flask.abort(404)
form = forms.UserReplyForm(obj=user)
if form.validate_on_submit():
form.populate_obj(user)
diff --git a/admin/requirements.txt b/admin/requirements.txt
index acd52c92..a7076531 100644
--- a/admin/requirements.txt
+++ b/admin/requirements.txt
@@ -10,3 +10,4 @@ PyOpenSSL
passlib
gunicorn
docker-py
+tabulate