Skip to content

Commit

Permalink
Add authentication and authorisation to Ibutsu
Browse files Browse the repository at this point in the history
- Add a login page
- Add a login controller
- Use JWT authentication
- Make HttpClient service object that transparently auths requests
- Add OAuth2 login to Ibutsu
- Add tests for the login controller
- Add Keycloak integration
- Add authorisation, users can only see resources for projects they are part of
- Use the 'from_file' method if it exists
- Add support for running the dev servers under TLS
  • Loading branch information
rsnyman committed Aug 25, 2021
1 parent 3e757dd commit fef5d8c
Show file tree
Hide file tree
Showing 69 changed files with 7,130 additions and 5,407 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ frontend/public/version.json

# Settings
backend/settings.yaml
backend/settings.local.yaml

# pytest-ibutsu stuff
*.tar.gz
Expand Down
11 changes: 7 additions & 4 deletions backend/ibutsu_server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,13 @@ def get_app(**extra_config):
config = app.app.config
config.setdefault("BCRYPT_LOG_ROUNDS", 20)
config.setdefault("SQLALCHEMY_TRACK_MODIFICATIONS", True)
settings_path = Path("./settings.yaml").resolve()
if settings_path.exists():
# If there's a config file, load it
config.from_mapping(yaml_load(settings_path.open()))
if hasattr(config, "from_file"):
config.from_file(str(Path("./settings.yaml").resolve()), yaml_load, silent=True)
else:
settings_path = Path("./settings.yaml").resolve()
if settings_path.exists():
# If there's a config file, load it
config.from_mapping(yaml_load(settings_path.open()))
# Now load config from environment variables
config.from_mapping(os.environ)
if config.get("POSTGRESQL_HOST") and config.get("POSTGRESQL_DATABASE"):
Expand Down
26 changes: 25 additions & 1 deletion backend/ibutsu_server/__main__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
#!/usr/bin/env python3
import sys
from pathlib import Path

from ibutsu_server import get_app

SSL_CERT = Path("../certs/dev.ibutsu.org+2.pem")
SSL_KEY = Path("../certs/dev.ibutsu.org+2-key.pem")


if __name__ == "__main__":
get_app().run(port=8080, debug=True)
kwargs = {}
if "--ssl" in sys.argv:
if not SSL_CERT.exists() or not SSL_CERT.exists():
print(
"SSL certificate and/or key files not found. Please download and install mkcert "
"and run the following command to generate these files:"
)
print("")
print(" mkcert dev.ibutsu.org devapi.ibutsu.org localhost")
print("")
print(
"Then place these two files in a `certs` directory in the top level directory of "
"the project (the same directory containing `backend` and `frontend`)."
)
sys.exit(1)
else:
kwargs["ssl_context"] = (SSL_CERT, SSL_KEY)
get_app().run(port=8080, debug=True, **kwargs)
27 changes: 27 additions & 0 deletions backend/ibutsu_server/constants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,30 @@
OAUTH_CONFIG = {
"google": {
"scope": ["https://www.googleapis.com/auth/userinfo.profile"],
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"user_url": "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
},
"github": {
"scope": ["user"],
"user_url": "https://api.github.com/user",
"auth_url": "https://github.com/login/oauth/authorize",
"token_url": "https://github.com/login/oauth/access_token",
},
"facebook": {
"scope": ["email", "public_profile"],
"auth_url": "https://www.facebook.com/v11.0/dialog/oauth",
"user_url": "",
"token_url": "https://graph.facebook.com/v11.0/oauth/access_token",
},
"gitlab": {
"scope": ["read_user"],
"sep": "+",
"user_url": "/api/v4/user",
"auth_url": "/oauth/authorize",
"token_url": "/oauth/token",
},
}
ALLOWED_TRUE_BOOLEANS = ["y", "t", "1"]
ARRAY_FIELDS = ["metadata.tags", "metadata.markers", "metadata.annotations"]
NUMERIC_FIELDS = [
Expand Down
42 changes: 31 additions & 11 deletions backend/ibutsu_server/controllers/artifact_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
from flask import make_response
from ibutsu_server.db.base import session
from ibutsu_server.db.models import Artifact
from ibutsu_server.db.models import Result
from ibutsu_server.db.models import User
from ibutsu_server.util.projects import add_user_filter
from ibutsu_server.util.projects import project_has_user
from ibutsu_server.util.uuid import validate_uuid


Expand All @@ -22,18 +26,21 @@ def _build_artifact_response(id_):
return artifact, response


def view_artifact(id_):
def view_artifact(id_, token_info=None, user=None):
"""Stream an artifact directly to the client/browser
:param id: ID of the artifact to download
:type id: str
:rtype: file
"""
return _build_artifact_response(id_)[1]
artifact, response = _build_artifact_response(id_)
if not project_has_user(artifact.result.project, user):
return "Forbidden", 403
return response


def download_artifact(id_):
def download_artifact(id_, token_info=None, user=None):
"""Download an artifact
:param id: ID of artifact to download
Expand All @@ -42,12 +49,14 @@ def download_artifact(id_):
:rtype: file
"""
artifact, response = _build_artifact_response(id_)
if not project_has_user(artifact.result.project, user):
return "Forbidden", 403
response.headers["Content-Disposition"] = "attachment; filename={}".format(artifact.filename)
return response


@validate_uuid
def get_artifact(id_):
def get_artifact(id_, token_info=None, user=None):
"""Return a single artifact
:param id: ID of the artifact
Expand All @@ -58,10 +67,12 @@ def get_artifact(id_):
artifact = Artifact.query.get(id_)
if not artifact:
return "Not Found", 404
if not project_has_user(artifact.result.project, user):
return "Forbidden", 403
return artifact.to_dict()


def get_artifact_list(result_id=None, page_size=25, page=1):
def get_artifact_list(result_id=None, page_size=25, page=1, token_info=None, user=None):
"""Get a list of artifact files for result
:param id: ID of test result
Expand All @@ -70,8 +81,13 @@ def get_artifact_list(result_id=None, page_size=25, page=1):
:rtype: List[Artifact]
"""
query = Artifact.query
user = User.query.get(user)
if "result_id" in connexion.request.args:
result_id = connexion.request.args["result_id"]
if result_id:
query = query.filter(Artifact.result_id == result_id)
if user:
query = add_user_filter(query, user, Result.project)
total_items = query.count()
offset = (page * page_size) - page_size
total_pages = (total_items // page_size) + (1 if total_items % page_size > 0 else 0)
Expand All @@ -87,7 +103,7 @@ def get_artifact_list(result_id=None, page_size=25, page=1):
}


def upload_artifact(body):
def upload_artifact(body, token_info=None, user=None):
"""Uploads a artifact artifact
:param result_id: ID of result to attach artifact to
Expand All @@ -102,6 +118,9 @@ def upload_artifact(body):
:rtype: tuple
"""
result_id = body.get("result_id")
result = Result.query.get(result_id)
if result and not project_has_user(result.project, user):
return "Forbidden", 403
filename = body.get("filename")
additional_metadata = body.get("additional_metadata", {})
file_ = connexion.request.files["file"]
Expand Down Expand Up @@ -130,7 +149,7 @@ def upload_artifact(body):
return artifact.to_dict(), 201


def delete_artifact(id_):
def delete_artifact(id_, token_info=None, user=None):
"""Deletes an artifact
:param id: ID of the artifact to delete
Expand All @@ -141,7 +160,8 @@ def delete_artifact(id_):
artifact = Artifact.query.get(id_)
if not artifact:
return "Not Found", 404
else:
session.delete(artifact)
session.commit()
return "OK", 200
if not project_has_user(artifact.result.project, user):
return "Forbidden", 403
session.delete(artifact)
session.commit()
return "OK", 200
49 changes: 32 additions & 17 deletions backend/ibutsu_server/controllers/dashboard_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
from ibutsu_server.db.base import session
from ibutsu_server.db.models import Dashboard
from ibutsu_server.db.models import WidgetConfig
from ibutsu_server.util.projects import project_has_user
from ibutsu_server.util.uuid import validate_uuid


def add_dashboard(dashboard=None):
def add_dashboard(dashboard=None, token_info=None, user=None):
"""Create a dashboard
:param body: Dashboard
Expand All @@ -16,29 +17,31 @@ def add_dashboard(dashboard=None):
if not connexion.request.is_json:
return "Bad request, JSON required", 400
dashboard = Dashboard.from_dict(**connexion.request.get_json())
if dashboard.project_id and not project_has_user(dashboard.project_id, user):
return "Forbidden", 403
session.add(dashboard)
session.commit()
return dashboard.to_dict(), 201


@validate_uuid
def get_dashboard(id_):
def get_dashboard(id_, token_info=None, user=None):
"""Get a single dashboard by ID
:param id: ID of test dashboard
:type id: str
:rtype: Dashboard
"""
dashboard = Dashboard.query.filter(Dashboard.id == id_).first()
if not dashboard:
dashboard = Dashboard.query.get(id_)
dashboard = Dashboard.query.get(id_)
if not dashboard:
return "Dashboard not found", 404
if dashboard and dashboard.project and not project_has_user(dashboard.project, user):
return "Forbidden", 403
return dashboard.to_dict()


def get_dashboard_list(project_id=None, user_id=None, page=1, page_size=25):
def get_dashboard_list(project_id=None, page=1, page_size=25, token_info=None, user=None):
"""Get a list of dashboards
:param project_id: Filter dashboards by project ID
Expand All @@ -53,10 +56,14 @@ def get_dashboard_list(project_id=None, user_id=None, page=1, page_size=25):
:rtype: DashboardList
"""
query = Dashboard.query
if "project_id" in connexion.request.args:
project_id = connexion.request.args["project_id"]
if project_id:
if not project_has_user(project_id, user):
return "Forbidden", 403
query = query.filter(Dashboard.project_id == project_id)
if user_id:
query = query.filter(Dashboard.user_id == user_id)
if user:
query = query.filter(Dashboard.user_id == user)
offset = (page * page_size) - page_size
total_items = query.count()
total_pages = (total_items // page_size) + (1 if total_items % page_size > 0 else 0)
Expand All @@ -72,7 +79,7 @@ def get_dashboard_list(project_id=None, user_id=None, page=1, page_size=25):
}


def update_dashboard(id_, dashboard=None):
def update_dashboard(id_, dashboard=None, token_info=None, user=None):
"""Update a dashboard
:param id: ID of test dashboard
Expand All @@ -84,16 +91,23 @@ def update_dashboard(id_, dashboard=None):
"""
if not connexion.request.is_json:
return "Bad request, JSON required", 400
dashboard_dict = connexion.request.get_json()
if dashboard_dict.get("metadata", {}).get("project") and not project_has_user(
dashboard_dict["metadata"]["project"], user
):
return "Forbidden", 403
dashboard = Dashboard.query.get(id_)
if not dashboard:
return "Dashboard not found", 404
if project_has_user(dashboard.project, user):
return "Forbidden", 403
dashboard.update(connexion.request.get_json())
session.add(dashboard)
session.commit()
return dashboard.to_dict()


def delete_dashboard(id_):
def delete_dashboard(id_, token_info=None, user=None):
"""Deletes a dashboard
:param id: ID of the dashboard to delete
Expand All @@ -104,10 +118,11 @@ def delete_dashboard(id_):
dashboard = Dashboard.query.get(id_)
if not dashboard:
return "Not Found", 404
else:
widget_configs = WidgetConfig.query.filter(WidgetConfig.dashboard_id == dashboard.id).all()
for widget_config in widget_configs:
session.delete(widget_config)
session.delete(dashboard)
session.commit()
return "OK", 200
if not project_has_user(dashboard.project, user):
return "Forbidden", 403
widget_configs = WidgetConfig.query.filter(WidgetConfig.dashboard_id == dashboard.id).all()
for widget_config in widget_configs:
session.delete(widget_config)
session.delete(dashboard)
session.commit()
return "OK", 200
4 changes: 2 additions & 2 deletions backend/ibutsu_server/controllers/group_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def add_group(group=None):


@validate_uuid
def get_group(id_):
def get_group(id_, token_info=None, user=None):
"""Get a group
:param id: The ID of the group
Expand All @@ -35,7 +35,7 @@ def get_group(id_):
return "Group not found", 404


def get_group_list(page=1, page_size=25):
def get_group_list(page=1, page_size=25, token_info=None, user=None):
"""Get a list of groups
Expand Down
6 changes: 3 additions & 3 deletions backend/ibutsu_server/controllers/health_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@
IS_CONNECTED = False


def get_health():
def get_health(token_info=None, user=None):
"""Get a health report
:rtype: Health
"""
return {"status": "OK", "message": "Service is running"}


def get_database_health():
def get_database_health(token_info=None, user=None):
"""Get a health report for the database
:rtype: Health
Expand All @@ -40,7 +40,7 @@ def get_database_health():
return response


def get_health_info():
def get_health_info(token_info=None, user=None):
"""Get the information about this server
:rtype: HealthInfo
Expand Down
Loading

0 comments on commit fef5d8c

Please sign in to comment.