Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PoC: Adding a basic login system to Puppetboard #468

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ ENV PUPPETBOARD_SETTINGS docker_settings.py
RUN mkdir -p /usr/src/app/
WORKDIR /usr/src/app/

VOLUME /var/lib/puppetboard

COPY requirements*.txt /usr/src/app/
RUN pip install -r requirements-docker.txt

Expand Down
78 changes: 76 additions & 2 deletions puppetboard/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,22 @@
from flask import (
Flask, render_template, abort, url_for,
Response, stream_with_context, redirect,
request, session, jsonify
request, session, jsonify, flash
)
from flask_login import (
LoginManager, login_required,
login_user, logout_user
)
from jinja2.utils import contextfunction

from pypuppetdb.QueryBuilder import *

from puppetboard.forms import QueryForm
from puppetboard.forms import QueryForm, LoginForm
from puppetboard.utils import (get_or_abort, yield_or_stop,
get_db_version)
from puppetboard.dailychart import get_daily_reports_chart
from puppetboard.models import db, Users
from sqlalchemy.exc import OperationalError

import werkzeug.exceptions as ex
import CommonMark
Expand Down Expand Up @@ -51,6 +57,19 @@
]

app = get_app()
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login"
if not app.config['LOGIN_DISABLED']:
try:
users = Users.query.all()
except OperationalError:
db.create_all()
users = Users.query.all()
if len(users) < 1:
admin_user = Users(username='admin', password='admin123')
db.session.add(admin_user)
db.session.commit()
graph_facts = app.config['GRAPH_FACTS']
numeric_level = getattr(logging, app.config['LOGLEVEL'].upper(), None)

Expand Down Expand Up @@ -88,6 +107,7 @@ def now(format='%m/%d/%Y %H:%M:%S'):

@app.route('/', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/')
@login_required
def index(env):
"""This view generates the index page and displays a set of metrics and
latest reports on nodes fetched from PuppetDB.
Expand Down Expand Up @@ -200,6 +220,7 @@ def index(env):

@app.route('/nodes', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/nodes')
@login_required
def nodes(env):
"""Fetch all (active) nodes from PuppetDB and stream a table displaying
those nodes.
Expand Down Expand Up @@ -285,6 +306,7 @@ def inventory_facts():

@app.route('/inventory', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/inventory')
@login_required
def inventory(env):
"""Fetch all (active) nodes from PuppetDB and stream a table displaying
those nodes along with a set of facts about them.
Expand All @@ -306,6 +328,7 @@ def inventory(env):
@app.route('/inventory/json',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/inventory/json')
@login_required
def inventory_ajax(env):
"""Backend endpoint for inventory table"""
draw = int(request.args.get('draw', 0))
Expand Down Expand Up @@ -344,6 +367,7 @@ def inventory_ajax(env):
@app.route('/node/<node_name>',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/node/<node_name>')
@login_required
def node(env, node_name):
"""Display a dashboard for a node showing as much data as we have on that
node. This includes facts and reports but not Resources as that is too
Expand Down Expand Up @@ -378,6 +402,7 @@ def node(env, node_name):
@app.route('/reports/<node_name>',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/reports/<node_name>')
@login_required
def reports(env, node_name):
"""Query and Return JSON data to reports Jquery datatable

Expand All @@ -401,6 +426,7 @@ def reports(env, node_name):
@app.route('/reports/<node_name>/json',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/reports/<node_name>/json')
@login_required
def reports_ajax(env, node_name):
"""Query and Return JSON data to reports Jquery datatable

Expand Down Expand Up @@ -509,6 +535,7 @@ def reports_ajax(env, node_name):
@app.route('/report/<node_name>/<report_id>',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/report/<node_name>/<report_id>')
@login_required
def report(env, node_name, report_id):
"""Displays a single report including all the events associated with that
report and their status.
Expand Down Expand Up @@ -560,6 +587,7 @@ def report(env, node_name, report_id):

@app.route('/facts', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/facts')
@login_required
def facts(env):
"""Displays an alphabetical list of all facts currently known to
PuppetDB.
Expand Down Expand Up @@ -609,6 +637,7 @@ def facts(env):
@app.route('/fact/<fact>/<value>',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/fact/<fact>/<value>')
@login_required
def fact(env, fact, value):
"""Fetches the specific fact(/value) from PuppetDB and displays per
node for which this fact is known.
Expand Down Expand Up @@ -655,6 +684,7 @@ def fact(env, fact, value):
'fact': None, 'value': None})
@app.route('/<env>/node/<node>/facts/json',
defaults={'fact': None, 'value': None})
@login_required
def fact_ajax(env, node, fact, value):
"""Fetches the specific facts matching (node/fact/value) from PuppetDB and
return a JSON table
Expand Down Expand Up @@ -747,6 +777,7 @@ def fact_ajax(env, node, fact, value):
@app.route('/query', methods=('GET', 'POST'),
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/query', methods=('GET', 'POST'))
@login_required
def query(env):
"""Allows to execute raw, user created querries against PuppetDB. This is
currently highly experimental and explodes in interesting ways since none
Expand Down Expand Up @@ -792,6 +823,7 @@ def query(env):

@app.route('/metrics', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/metrics')
@login_required
def metrics(env):
"""Lists all available metrics that PuppetDB is aware of.

Expand All @@ -812,6 +844,7 @@ def metrics(env):
@app.route('/metric/<path:metric>',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/metric/<path:metric>')
@login_required
def metric(env, metric):
"""Lists all information about the metric of the given name.

Expand Down Expand Up @@ -839,6 +872,7 @@ def metric(env, metric):
@app.route('/catalogs/compare/<compare>',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/catalogs/compare/<compare>')
@login_required
def catalogs(env, compare):
"""Lists all nodes with a compiled catalog.

Expand Down Expand Up @@ -867,6 +901,7 @@ def catalogs(env, compare):
@app.route('/catalogs/compare/<compare>/json',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/catalogs/compare/<compare>/json')
@login_required
def catalogs_ajax(env, compare):
"""Server data to catalogs as JSON to Jquery datatables
"""
Expand Down Expand Up @@ -926,6 +961,7 @@ def catalogs_ajax(env, compare):
@app.route('/catalog/<node_name>',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/catalog/<node_name>')
@login_required
def catalog_node(env, node_name):
"""Fetches from PuppetDB the compiled catalog of a given node.

Expand All @@ -950,6 +986,7 @@ def catalog_node(env, node_name):
@app.route('/catalogs/compare/<compare>...<against>',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/catalogs/compare/<compare>...<against>')
@login_required
def catalog_compare(env, compare, against):
"""Compares the catalog of one node, parameter compare, with that of
with that of another node, parameter against.
Expand Down Expand Up @@ -978,6 +1015,7 @@ def catalog_compare(env, compare, against):

@app.route('/radiator', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/radiator')
@login_required
def radiator(env):
"""This view generates a simplified monitoring page
akin to the radiator view in puppet dashboard
Expand Down Expand Up @@ -1077,6 +1115,7 @@ def radiator(env):
@app.route('/daily_reports_chart.json',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/daily_reports_chart.json')
@login_required
def daily_reports_chart(env):
"""Return JSON data to generate a bar chart of daily runs.

Expand Down Expand Up @@ -1106,6 +1145,41 @@ def offline_static(filename):
status=200, mimetype=mimetype)


@app.route("/login", methods=["GET", "POST"])
def login():
form = LoginForm(meta={
'csrf_secret': app.config['SECRET_KEY'],
'csrf_context': session})
if form.validate_on_submit():
user = Users.query.filter_by(username=form.username.data).first()
if user and user.password == form.password.data:
login_user(user, remember=form.remember.data)
return redirect(url_for('index'))
else:
flash('Login failed.', 'error')
return render_template('login.html', form=form)


@app.route("/users")
@login_required
def users():
users = Users.query.all()
return render_template('users.html', users=users)


@app.route("/logout")
@login_required
def logout():
logout_user()
flash('You have been logged out.', 'info')
return redirect(url_for('login'))


@login_manager.user_loader
def load_user(user_id):
return Users.query.filter_by(id=int(user_id)).first()


@app.route('/status')
def health_status():
return 'OK'
3 changes: 3 additions & 0 deletions puppetboard/default_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
PUPPETDB_CERT = None
PUPPETDB_TIMEOUT = 20
DEFAULT_ENVIRONMENT = 'production'
LOGIN_DISABLED = True
SQLALCHEMY_DATABASE_URI = 'sqlite:////tmp/puppetboard.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
SECRET_KEY = os.urandom(24)
DEV_LISTEN_HOST = '127.0.0.1'
DEV_LISTEN_PORT = 5000
Expand Down
3 changes: 3 additions & 0 deletions puppetboard/docker_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
PUPPETDB_CERT = os.getenv('PUPPETDB_CERT', None)
PUPPETDB_PROTO = os.getenv('PUPPETDB_PROTO', None)
PUPPETDB_TIMEOUT = int(os.getenv('PUPPETDB_TIMEOUT', '20'))
LOGIN_DISABLED = os.getenv('LOGIN_DISABLED', True)
SQLALCHEMY_DATABASE_URI = os.getenv('SQLALCHEMY_DATABASE_URI',
'sqlite:////var/lib/puppetboard/database.db')
DEFAULT_ENVIRONMENT = os.getenv('DEFAULT_ENVIRONMENT', 'production')
SECRET_KEY = os.getenv('SECRET_KEY', os.urandom(24))
DEV_LISTEN_HOST = os.getenv('DEV_LISTEN_HOST', '127.0.0.1')
Expand Down
10 changes: 9 additions & 1 deletion puppetboard/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from flask_wtf import FlaskForm
from wtforms import (
HiddenField, RadioField, SelectField,
TextAreaField, BooleanField, validators
TextAreaField, BooleanField, StringField,
PasswordField, validators
)


Expand All @@ -28,3 +29,10 @@ class QueryForm(FlaskForm):
('pql', 'PQL'),
])
rawjson = BooleanField('Raw JSON')


class LoginForm(FlaskForm):
"""The form used to login to Puppetboard"""
username = StringField('Username', [validators.DataRequired(message='Username is required')])
password = PasswordField('Password', [validators.DataRequired(message='Password is required')])
remember = BooleanField('Remember me')
31 changes: 31 additions & 0 deletions puppetboard/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from puppetboard.core import get_app
from flask_sqlalchemy import SQLAlchemy
import datetime


app = get_app()
db = SQLAlchemy(app)


class Users(db.Model):
created = db.Column(db.DateTime, default=datetime.datetime.now, nullable=False)
modified = db.Column(db.DateTime, default=datetime.datetime.now,
onupdate=datetime.datetime.now, nullable=False)
id = db.Column(db.Integer, unique=True, primary_key=True, nullable=False)
username = db.Column(db.String(80), unique=True, nullable=False)
password = db.Column(db.String(80), nullable=False)

def __repr__(self):
return '{}/{}/{}'.format(self.id, self.username, self.password)

def is_authenticated(self):
return True

def is_active(self):
return True

def is_anonymous(self):
return False

def get_id(self):
return self.id
18 changes: 18 additions & 0 deletions puppetboard/static/css/puppetboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,24 @@ h1.ui.header.no-margin-bottom {
color: #FFF;
}

.ui.toggle.checkbox input:focus:checked ~ .box:before, .ui.toggle.checkbox input:focus:checked ~ label:before {
background-color: #2C3E50 !important;
}

.ui.button.darkblue {
background-color: #2C3E50;
border-color: #2C3E50;
color: #FFF;
}

.ui.button.darkblue:hover {
background-color: #2C3E50DB;
}

.inline.field.left {
text-align: left;
}

.ui.menu.yellow {
background-color: #F0E965;
}
Expand Down
23 changes: 22 additions & 1 deletion puppetboard/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,28 @@
{% endfor %}
</div>
</div>
<div class="item right"><a href="https://github.com/voxpupuli/puppetboard" target="_blank">{{version()}}</a></div>
{%- if current_user.is_authenticated -%}
<div class="ui dropdown item">
Settings
<i class="dropdown icon"></i>
<div class="menu">
<a class="item" href="{{url_for('users')}}">Users</a>
</div>
</div>
{%- endif -%}
<div class="right menu">
{%- if current_user.is_authenticated -%}
<div class="ui dropdown item">
Logged in as: {{ current_user.username }}
<i class="dropdown icon"></i>
<div class="menu">
<a class="item" href="{{url_for('index')}}">Change Password</a>
<a class="item" href="{{url_for('logout')}}">Logout</a>
</div>
</div>
{%- endif -%}
<a class= "item" href="https://github.com/voxpupuli/puppetboard" target="_blank">{{version()}}</a>
</div>
</div>
<div class="ui grid padding-bottom">
<div class="one wide column"></div>
Expand Down
Loading