diff --git a/backend/app.py b/backend/app.py index 97f6ffbbe18701768f8e96a8958883d047587c0d..622173723d1215ddac05ddd36bfce17c8a8018b8 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,8 +1,9 @@ from flask import Flask, jsonify from flask_cors import CORS from flask_jwt_extended import JWTManager -from flask_migrate import Migrate +import flask_migrate from jsonschema.exceptions import ValidationError +from NamedAtomicLock import NamedAtomicLock from werkzeug.exceptions import BadRequest # These imports are required @@ -32,8 +33,29 @@ from helpers import ( unauthorized_error, ) +import cluster_config from config import * import logging +import migration_reset +import sys + +# Configure logging. +from logging.config import dictConfig +dictConfig({ + 'version': 1, + 'formatters': {'default': { + 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s', + }}, + 'handlers': {'wsgi': { + 'class': 'logging.StreamHandler', + 'stream': 'ext://flask.logging.wsgi_errors_stream', + 'formatter': 'default', + }}, + 'root': { + 'level': 'INFO', + 'handlers': ['wsgi'], + } +}) app = Flask(__name__) @@ -41,12 +63,42 @@ app.config["SECRET_KEY"] = SECRET_KEY app.config["SQLALCHEMY_DATABASE_URI"] = SQLALCHEMY_DATABASE_URI app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = SQLALCHEMY_TRACK_MODIFICATIONS +app.logger.setLevel(logging.INFO) +app.logger.info("Starting dashboard backend.") + cors = CORS(app) -Migrate(app, db) -db.init_app(app) +db.init_app(app) -app.logger.setLevel(logging.INFO) +# We'll now perform some initialization routines. Because these have to be done +# once at startup, not for every gunicorn worker, we take a machine-wide lock +# for this. +init_lock = NamedAtomicLock('dashboard_init') +if init_lock.acquire(): + try: + with app.app_context(): + # We have reset the alembic migration history at Stackspin version 2.2. + # This checks whether we need to prepare the database to follow that + # change. + migration_reset.reset() + flask_migrate.Migrate(app, db) + try: + with app.app_context(): + flask_migrate.upgrade() + except Exception as e: + app.logger.info(f"upgrade failed: {type(e)}: {e}") + sys.exit(2) + + # We need this app context in order to talk the database, which is managed by + # flask-sqlalchemy, which assumes a flask app context. + with app.app_context(): + # Load the list of apps from a configmap and store any missing ones in the + # database. + cluster_config.populate_apps() + # Same for the list of oauthclients. + cluster_config.populate_oauthclients() + finally: + init_lock.release() app.register_blueprint(api_v1) app.register_blueprint(web) diff --git a/backend/areas/apps/apps.py b/backend/areas/apps/apps.py index b677411827ee220f198c2b66fe3c0733ff7463f5..1bd55644f0a7b985062e8897423ed430318b247c 100644 --- a/backend/areas/apps/apps.py +++ b/backend/areas/apps/apps.py @@ -23,7 +23,7 @@ CONFIG_DATA = [ @cross_origin() def get_apps(): """Return data about all apps""" - apps = AppsService.get_all_apps() + apps = AppsService.get_accessible_apps() return jsonify(apps) diff --git a/backend/areas/apps/apps_service.py b/backend/areas/apps/apps_service.py index 665b4fed6fdae2a98b48b871344135767bc240bb..289529de75435c538e4796a36945a9ddaeab9d83 100644 --- a/backend/areas/apps/apps_service.py +++ b/backend/areas/apps/apps_service.py @@ -1,4 +1,12 @@ +from flask import current_app +from flask_jwt_extended import get_jwt +import ory_kratos_client +from ory_kratos_client.api import v0alpha2_api as kratos_api + from .models import App, AppRole +from config import * +from helpers.access_control import user_has_access +from helpers.kratos_user import KratosUser class AppsService: @staticmethod @@ -6,6 +14,24 @@ class AppsService: apps = App.query.all() return [app.to_dict() for app in apps] + @staticmethod + def get_accessible_apps(): + apps = App.query.all() + + kratos_admin_api_configuration = ory_kratos_client.Configuration(host=KRATOS_ADMIN_URL, discard_unknown_keys=True) + KRATOS_ADMIN = kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_admin_api_configuration)) + + user_id = get_jwt()['user_id'] + current_app.logger.info(f"user_id: {user_id}") + # Get the related user object + current_app.logger.info(f"Info: Getting user from admin {user_id}") + user = KratosUser(KRATOS_ADMIN, user_id) + if not user: + current_app.logger.error(f"User not found in database: {user_id}") + return [] + + return [app.to_dict() for app in apps if user_has_access(user, app)] + @staticmethod def get_app(slug): app = App.query.filter_by(slug=slug).first() diff --git a/backend/areas/apps/models.py b/backend/areas/apps/models.py index ef930501a9d8be66a5d27114531edd57477c2574..069c73751643f42af0619e65c7d6425d4802138d 100644 --- a/backend/areas/apps/models.py +++ b/backend/areas/apps/models.py @@ -30,6 +30,12 @@ class App(db.Model): # URL is stored in a configmap (see get_url) url = db.Column(String(length=128), unique=False) + def __init__(self, slug, name, external=False, url=None): + self.slug = slug + self.name = name + self.external = external + self.url = url + def __repr__(self): return f"{self.id} <{self.name}>" @@ -303,6 +309,7 @@ class OAuthClientApp(db.Model): # pylint: disable=too-few-public-methods This mapping exists so that * you can have a different name for the OAuth client than for the app, and * you can have multiple OAuth clients that belong to the same app. + Also, some apps might have no OAuth client at all. """ __tablename__ = "oauthclient_app" diff --git a/backend/areas/roles/models.py b/backend/areas/roles/models.py index d822901c933568dd62e575675e0afa68565aa29f..b761bb579ebc9133b86c8822c724f1eda2112a97 100644 --- a/backend/areas/roles/models.py +++ b/backend/areas/roles/models.py @@ -3,6 +3,7 @@ from database import db class Role(db.Model): + ADMIN_ROLE_ID = 1 NO_ACCESS_ROLE_ID = 3 id = db.Column(Integer, primary_key=True) diff --git a/backend/areas/roles/role_service.py b/backend/areas/roles/role_service.py index 90ad064f40f5149054bfd5287ef918a2d719911a..3520b273e75026410d095ccbf7df26a6b097728b 100644 --- a/backend/areas/roles/role_service.py +++ b/backend/areas/roles/role_service.py @@ -15,4 +15,4 @@ class RoleService: @staticmethod def is_user_admin(userId): dashboard_role_id = AppRole.query.filter_by(user_id=userId, app_id=1).first().role_id - return dashboard_role_id == 1 \ No newline at end of file + return dashboard_role_id == 1 diff --git a/backend/cluster_config.py b/backend/cluster_config.py new file mode 100644 index 0000000000000000000000000000000000000000..20ec9e98023dfd01b46a30492f74057aea1a1aed --- /dev/null +++ b/backend/cluster_config.py @@ -0,0 +1,83 @@ +from database import db +from areas.apps.models import App, OAuthClientApp +import helpers.kubernetes as k8s + +import logging +import yaml + +# Read in two configmaps from the cluster, which specify which apps should be +# present in the database. +def populate_apps(): + logging.info("cluster_config: populating apps") + database_apps = {} + for app in App.query.all(): + slug = app.slug + database_apps[slug] = app + logging.info(f"database app: {slug}") + _populate_apps_from(database_apps, "stackspin-apps") + _populate_apps_from(database_apps, "stackspin-apps-custom") + +# Read a list of apps from a configmap. Check if they are already present in +# the database, and if not, add missing ones there. Properties `name`, +# `external` and `url` can be specified in yaml format in the configmap value +# contents. +def _populate_apps_from(database_apps, configmap_name): + cm_apps = k8s.get_kubernetes_config_map_data(configmap_name, "flux-system") + if cm_apps is None: + logging.info(f"Could not find configmap '{configmap_name}' in namespace 'flux-system'; ignoring.") + else: + for app_slug, app_data in cm_apps.items(): + logging.info(f"configmap app: {app_slug}") + if app_slug in database_apps: + logging.info(f" already present in database") + else: + logging.info(f" not present in database, adding!") + data = yaml.safe_load(app_data) + name = data["name"] + logging.info(f" name: {name}") + external = data.get("external", False) + logging.info(f" type external: {type(external)}") + logging.info(f" external: {external}") + url = data.get("url", None) + logging.info(f" url: {url}") + new_app = App(slug=app_slug, name=name, external=external, url=url) + db.session.add(new_app) + db.session.commit() + +# Read in two configmaps from the cluster, which specify which oauthclients +# should be present in the database. +def populate_oauthclients(): + logging.info("cluster_config: populating oauthclients") + database_oauthclients = {} + for client in OAuthClientApp.query.all(): + id = client.oauthclient_id + database_oauthclients[id] = client + logging.info(f"database oauthclient: {id}") + _populate_oauthclients_from(database_oauthclients, "stackspin-oauthclients") + _populate_oauthclients_from(database_oauthclients, "stackspin-oauthclients-custom") + +# Read a list of oauthclients from a configmap. Check if they are already +# present in the database, and if not, add missing ones there. The value of the +# mapping is taken to be the slug of the app the oauthclient belongs to. +def _populate_oauthclients_from(database_oauthclients, configmap_name): + cm_oauthclients = k8s.get_kubernetes_config_map_data(configmap_name, "flux-system") + if cm_oauthclients is None: + logging.info(f"Could not find configmap '{configmap_name}' in namespace 'flux-system'; ignoring.") + else: + for client_id, client_app in cm_oauthclients.items(): + logging.info(f"configmap oauthclient: {client_id}") + if client_id in database_oauthclients: + logging.info(f" already present in database") + else: + logging.info(f" not present in database, adding!") + # Take the value of the configmap mapping (`client_app`) and + # interpret it as the slug of the app that this oauthclient + # belongs to. + app = App.query.filter_by(slug=client_app).first() + if not app: + logging.error(f" could not find app with slug {client_app}") + continue + new_client = OAuthClientApp(oauthclient_id=client_id, app_id=app.id) + logging.info(f" new oauth client: {new_client}") + db.session.add(new_client) + db.session.commit() diff --git a/backend/helpers/access_control.py b/backend/helpers/access_control.py new file mode 100644 index 0000000000000000000000000000000000000000..347230d5904f633364eb76621f672847fc3362d4 --- /dev/null +++ b/backend/helpers/access_control.py @@ -0,0 +1,43 @@ +from flask import current_app + +from areas.apps.models import App, AppRole +from areas.roles.models import Role +from config import * +from database import db + +def user_has_access(user, app): + # Get role on dashboard + dashboard_app = db.session.query(App).filter( + App.slug == 'dashboard').first() + if not dashboard_app: + current_app.logger.error("Dashboard app not found in database.") + return False + role_object = ( + db.session.query(AppRole) + .filter(AppRole.app_id == dashboard_app.id) + .filter(AppRole.user_id == user.uuid) + .first() + ) + if role_object is None: + current_app.logger.info(f"No dashboard role set for user {user.uuid}.") + return False + + # If the user is dashboard admin, they have access to everything. + if role_object.role_id == Role.ADMIN_ROLE_ID: + current_app.logger.info(f"User {user.uuid} has admin dashboard role") + return True + + # Get role for app. + role_object = ( + db.session.query(AppRole) + .filter(AppRole.app_id == app.id) + .filter(AppRole.user_id == user.uuid) + .first() + ) + # Role ID 3 is always "No access" due to migration b514cca2d47b + if role_object is None or role_object.role_id is None or role_object.role_id == Role.NO_ACCESS_ROLE_ID: + current_app.logger.info(f"User {user.uuid} has no access for: {app.name}") + return False + + # In all other cases, access is granted. + return True diff --git a/backend/helpers/kratos_user.py b/backend/helpers/kratos_user.py index 523f67b0f6736a274c2dfae910b0ff7a4eea7471..dee31b4c229a513154c55eb12d72c0c1d7a65766 100644 --- a/backend/helpers/kratos_user.py +++ b/backend/helpers/kratos_user.py @@ -9,8 +9,6 @@ import urllib.request from typing import Dict from urllib.request import Request -# Some imports commented out to satisfy pylint. They will be used once more -# functions are migrated to this model from ory_kratos_client.model.admin_create_identity_body import AdminCreateIdentityBody from ory_kratos_client.model.admin_create_self_service_recovery_link_body \ import AdminCreateSelfServiceRecoveryLinkBody diff --git a/backend/migration_reset.py b/backend/migration_reset.py new file mode 100644 index 0000000000000000000000000000000000000000..4f674c75ecff4e31ccf744818978580270e4ef48 --- /dev/null +++ b/backend/migration_reset.py @@ -0,0 +1,34 @@ +from sqlalchemy import exc + +from database import db +import logging + +# We "reset" the alembic version history for Stackspin 2.2, to clean up our old +# mess of database migrations a bit, and in particular to make the transition +# easier to moving the source of truth for some of the data (list of apps) out +# of the database and into configmaps. This function deals with older clusters +# that have to be led through this transition. To determine if we need to do +# anything, we look at the `alembic_version` value in the database. If it's a +# legacy version, we delete the table so the alembic migration will view the +# database as "empty" and perform all new migrations on it. The new initial +# migration will have to handle that case specially, by checking if any tables +# already exist, and not do anything in that case. +def reset(): + logging.info("Checking if alembic version needs to be reset.") + version = None + try: + result = db.session.execute("select version_num from alembic_version") + for row in result: + version = row[0] + except exc.ProgrammingError: + # We assume this means the alembic_version table doesn't exist, which + # is expected for new clusters. + pass + logging.info(f"alembic version: {version}") + legacy_versions = ["fc0892d07771", "3fa0c38ea1ac", "e08df0bef76f", "b514cca2d47b", "5f462d2d9d25", "27761560bbcb"] + if version in legacy_versions: + logging.info("This is an old version: resetting.") + db.session.execute("drop table alembic_version") + else: + logging.info("This is not a known legacy version: not resetting.") + diff --git a/backend/migrations/alembic.ini b/backend/migrations/alembic.ini index ec9d45c26a6bb54e833fd4e6ce2de29343894f4b..ef355cdd0bc77377516a78e5f891f20b8573b403 100644 --- a/backend/migrations/alembic.ini +++ b/backend/migrations/alembic.ini @@ -8,43 +8,3 @@ # the 'revision' command, regardless of autogenerate # revision_environment = false - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic,flask_migrate - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[logger_flask_migrate] -level = INFO -handlers = -qualname = flask_migrate - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/backend/migrations/env.py b/backend/migrations/env.py index 68feded2a040005310d770ac7136b2e4ff8a6312..2a7628b80babbf6b5a2777efe7e02c5c115c391c 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -13,7 +13,9 @@ config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. -fileConfig(config.config_file_name) +# We commented this out, because we want to configure logging in the app +# itself, not here. +# fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') # add your model's MetaData object here diff --git a/backend/migrations/versions/27761560bbcb_.py b/backend/migrations/versions/27761560bbcb_.py deleted file mode 100644 index baa80e4969b00d9fcb03b6048d254703d6a07759..0000000000000000000000000000000000000000 --- a/backend/migrations/versions/27761560bbcb_.py +++ /dev/null @@ -1,46 +0,0 @@ -"""empty message - -Revision ID: 27761560bbcb -Revises: -Create Date: 2021-12-21 06:07:14.857940 - -""" -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = "27761560bbcb" -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "app", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("name", sa.String(length=64), nullable=True), - sa.Column("slug", sa.String(length=64), nullable=True), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("slug"), - ) - op.create_table( - "app_role", - sa.Column("user_id", sa.String(length=64), nullable=False), - sa.Column("app_id", sa.Integer(), nullable=False), - sa.Column("role", sa.String(length=64), nullable=True), - sa.ForeignKeyConstraint( - ["app_id"], - ["app.id"], - ), - sa.PrimaryKeyConstraint("user_id", "app_id"), - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("app_role") - op.drop_table("app") - # ### end Alembic commands ### diff --git a/backend/migrations/versions/3fa0c38ea1ac_add_velero_as_app.py b/backend/migrations/versions/3fa0c38ea1ac_add_velero_as_app.py deleted file mode 100644 index 5caae97d62acdabc750580389246cbc376162c35..0000000000000000000000000000000000000000 --- a/backend/migrations/versions/3fa0c38ea1ac_add_velero_as_app.py +++ /dev/null @@ -1,25 +0,0 @@ -"""add-velero-as-app - -Revision ID: 3fa0c38ea1ac -Revises: e08df0bef76f -Create Date: 2022-10-13 09:40:44.290319 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '3fa0c38ea1ac' -down_revision = 'e08df0bef76f' -branch_labels = None -depends_on = None - - -def upgrade(): - # Add monitoring app - op.execute(f'INSERT IGNORE INTO app (`name`, `slug`) VALUES ("Velero","velero")') - - -def downgrade(): - pass diff --git a/backend/migrations/versions/5f462d2d9d25_convert_role_column_to_table.py b/backend/migrations/versions/5f462d2d9d25_convert_role_column_to_table.py deleted file mode 100644 index 53a8a1d5defc9223443200f79649c703e6b4025c..0000000000000000000000000000000000000000 --- a/backend/migrations/versions/5f462d2d9d25_convert_role_column_to_table.py +++ /dev/null @@ -1,48 +0,0 @@ -"""convert role column to table - -Revision ID: 5f462d2d9d25 -Revises: 27761560bbcb -Create Date: 2022-04-13 15:00:27.182898 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = "5f462d2d9d25" -down_revision = "27761560bbcb" -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - role_table = op.create_table( - "role", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("name", sa.String(length=64), nullable=True), - sa.PrimaryKeyConstraint("id"), - ) - op.add_column("app_role", sa.Column("role_id", sa.Integer(), nullable=True)) - op.create_foreign_key(None, "app_role", "role", ["role_id"], ["id"]) - # ### end Alembic commands ### - - # Insert default role "admin" as ID 1 - op.execute(sa.insert(role_table).values(id=1,name="admin")) - # Set role_id 1 to all current "admin" users - op.execute("UPDATE app_role SET role_id = 1 WHERE role = 'admin'") - - # Drop old column - op.drop_column("app_role", "role") - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.add_column( - "app_role", sa.Column("role", mysql.VARCHAR(length=64), nullable=True) - ) - op.drop_constraint(None, "app_role", type_="foreignkey") - op.drop_column("app_role", "role_id") - op.drop_table("role") - # ### end Alembic commands ### diff --git a/backend/migrations/versions/7d27395c892a_new_migration.py b/backend/migrations/versions/7d27395c892a_new_migration.py new file mode 100644 index 0000000000000000000000000000000000000000..94a1d844c0918712970147183025150a28ccd37c --- /dev/null +++ b/backend/migrations/versions/7d27395c892a_new_migration.py @@ -0,0 +1,72 @@ +"""Initial version after history reset: Create tables and fill the "role" one + +Revision ID: 7d27395c892a +Revises: +Create Date: 2023-01-18 14:48:23.996261 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql +from sqlalchemy.engine.reflection import Inspector + +# revision identifiers, used by Alembic. +revision = '7d27395c892a' +down_revision = None +branch_labels = None +depends_on = None + +conn = op.get_bind() +inspector = Inspector.from_engine(conn) +tables = inspector.get_table_names() + +def upgrade(): + if "app" not in tables: + op.create_table( + "app", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=64), nullable=False), + sa.Column("slug", sa.String(length=64), nullable=False), + sa.Column("external", sa.Boolean(), server_default='0', nullable=False), + sa.Column("url", sa.String(length=128), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("slug"), + ) + + if "role" not in tables: + op.create_table( + "role", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=64), nullable=True), + sa.PrimaryKeyConstraint("id") + ) + op.execute("INSERT INTO `role` (id, `name`) VALUES (1, 'admin')") + op.execute("INSERT INTO `role` (id, `name`) VALUES (2, 'user')") + op.execute("INSERT INTO `role` (id, `name`) VALUES (3, 'no access')") + + if "app_role" not in tables: + op.create_table( + "app_role", + sa.Column("user_id", sa.String(length=64), nullable=False), + sa.Column("app_id", sa.Integer(), nullable=False), + sa.Column("role_id", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("user_id", "app_id"), + sa.ForeignKeyConstraint(["app_id"],["app.id"]), + sa.ForeignKeyConstraint(["role_id"],["role.id"]) + ) + + if "oauthclient_app" not in tables: + op.create_table('oauthclient_app', + sa.Column('oauthclient_id', mysql.VARCHAR(length=64), nullable=False), + sa.Column('app_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('oauthclient_id'), + sa.ForeignKeyConstraint(['app_id'], ['app.id']), + mysql_default_charset='utf8mb3', + mysql_engine='InnoDB' + ) + +def downgrade(): + op.drop_table("oauthclient_app") + op.drop_table("app_role") + op.drop_table("role") + op.drop_table("app") \ No newline at end of file diff --git a/backend/migrations/versions/b514cca2d47b_add_user_role.py b/backend/migrations/versions/b514cca2d47b_add_user_role.py deleted file mode 100644 index 058694200fe0e2c1a9b6ca82abf9c023e5562b40..0000000000000000000000000000000000000000 --- a/backend/migrations/versions/b514cca2d47b_add_user_role.py +++ /dev/null @@ -1,76 +0,0 @@ -"""update apps and add 'user' and 'no access' role - -Revision ID: b514cca2d47b -Revises: 5f462d2d9d25 -Create Date: 2022-06-08 17:24:51.305129 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = 'b514cca2d47b' -down_revision = '5f462d2d9d25' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### end Alembic commands ### - - # Check and update app table in DB - apps = { - "dashboard": "Dashboard", - "wekan": "Wekan", - "wordpress": "WordPress", - "nextcloud": "Nextcloud", - "zulip": "Zulip" - } - # app table - app_table = sa.table('app', sa.column('id', sa.Integer), sa.column( - 'name', sa.String), sa.column('slug', sa.String)) - - existing_apps = op.get_bind().execute(app_table.select()).fetchall() - existing_app_slugs = [app['slug'] for app in existing_apps] - for app_slug in apps.keys(): - if app_slug in existing_app_slugs: - op.execute(f'UPDATE app SET `name` = "{apps.get(app_slug)}" WHERE slug = "{app_slug}"') - else: - op.execute(f'INSERT INTO app (`name`, slug) VALUES ("{apps.get(app_slug)}","{app_slug}")') - - # Fetch all apps including newly created - existing_apps = op.get_bind().execute(app_table.select()).fetchall() - # Insert role "user" as ID 2 - op.execute("INSERT INTO `role` (id, `name`) VALUES (2, 'user')") - # Insert role "no access" as ID 3 - op.execute("INSERT INTO `role` (id, `name`) VALUES (3, 'no access')") - # Set role_id 2 to all current "user" users which by have NULL role ID - op.execute("UPDATE app_role SET role_id = 2 WHERE role_id IS NULL") - - # Add 'no access' role for all users that don't have any roles for specific apps - app_roles_table = sa.table('app_role', sa.column('user_id', sa.String), sa.column( - 'app_id', sa.Integer), sa.column('role_id', sa.Integer)) - - app_ids = [app['id'] for app in existing_apps] - app_roles = op.get_bind().execute(app_roles_table.select()).fetchall() - user_ids = set([app_role['user_id'] for app_role in app_roles]) - - for user_id in user_ids: - existing_user_app_ids = [x['app_id'] for x in list(filter(lambda role: role['user_id'] == user_id, app_roles))] - missing_user_app_ids = [x for x in app_ids if x not in existing_user_app_ids] - - if len(missing_user_app_ids) > 0: - values = [{'user_id': user_id, 'app_id': app_id, 'role_id': 3} for app_id in missing_user_app_ids] - op.bulk_insert(app_roles_table, values) - - -def downgrade(): - # Revert all users role_id to NULL where role is 'user' - op.execute("UPDATE app_role SET role_id = NULL WHERE role_id = 2") - # Delete role 'user' from roles - op.execute("DELETE FROM `role` WHERE id = 2") - - # Delete all user app roles where role is 'no access' with role_id 3 - op.execute("DELETE FROM app_role WHERE role_id = 3") - # Delete role 'no access' from roles - op.execute("DELETE FROM `role` WHERE id = 3") diff --git a/backend/migrations/versions/e08df0bef76f_.py b/backend/migrations/versions/e08df0bef76f_.py deleted file mode 100644 index 005833fb12e38ce6241720a520f160316c53d251..0000000000000000000000000000000000000000 --- a/backend/migrations/versions/e08df0bef76f_.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Add fields for external apps - -Revision ID: e08df0bef76f -Revises: b514cca2d47b -Create Date: 2022-09-23 16:38:06.557307 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'e08df0bef76f' -down_revision = 'b514cca2d47b' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('app', sa.Column('external', sa.Boolean(), server_default='0', nullable=False)) - op.add_column('app', sa.Column('url', sa.String(length=128), nullable=True)) - # ### end Alembic commands ### - - # Add monitoring app - op.execute(f'INSERT IGNORE INTO app (`name`, `slug`) VALUES ("Monitoring","monitoring")') - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('app', 'url') - op.drop_column('app', 'external') - # ### end Alembic commands ### diff --git a/backend/migrations/versions/fc0892d07771_add_oauthclient_app_table.py b/backend/migrations/versions/fc0892d07771_add_oauthclient_app_table.py deleted file mode 100644 index be0ddf02201f097547d2a69346aa1479dabd976c..0000000000000000000000000000000000000000 --- a/backend/migrations/versions/fc0892d07771_add_oauthclient_app_table.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Add oauthclient_app table - -Revision ID: fc0892d07771 -Revises: 3fa0c38ea1ac -Create Date: 2022-11-02 09:52:09.510764 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = 'fc0892d07771' -down_revision = '3fa0c38ea1ac' -branch_labels = None -depends_on = None - - -def upgrade(): - oauthclient_app_table = op.create_table('oauthclient_app', - sa.Column('oauthclient_id', mysql.VARCHAR(length=64), nullable=False), - sa.Column('app_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False), - sa.ForeignKeyConstraint(['app_id'], ['app.id'], name='oauthclient_app_fk_app_id'), - sa.PrimaryKeyConstraint('oauthclient_id'), - mysql_default_charset='utf8mb3', - mysql_engine='InnoDB' - ) - values = [ - {"oauthclient_id": "dashboard" , "app_id": 1}, - {"oauthclient_id": "wekan" , "app_id": 2}, - {"oauthclient_id": "wordpress" , "app_id": 3}, - {"oauthclient_id": "nextcloud" , "app_id": 4}, - {"oauthclient_id": "zulip" , "app_id": 5}, - {"oauthclient_id": "kube-prometheus-stack", "app_id": 6}, - ] - op.bulk_insert(oauthclient_app_table, values) - -def downgrade(): - op.drop_table('oauthclient_app') diff --git a/backend/requirements.txt b/backend/requirements.txt index eae5bd291b0e1fc6c0733dd3fc9ca23eb52145d1..05715b0ea460384fee1dd9668bd3c9810ed849ce 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -19,12 +19,14 @@ jinja2-base64-filters==0.1.4 kubernetes==24.2.0 MarkupSafe==2.1.1 mypy-extensions==0.4.3 +NamedAtomicLock==1.1.3 oauthlib==3.2.0 pathspec==0.9.0 platformdirs==2.5.1 pycparser==2.21 PyJWT==2.3.0 pyrsistent==0.18.1 +PyYAML==6.0 regex==2022.3.15 requests==2.27.1 requests-oauthlib==1.3.1 diff --git a/backend/web/login/login.py b/backend/web/login/login.py index acd61288c56146acb907648c3583949ee6ae991d..94ee4c37036fce38e47dcfe7c70e8ba87f4aac2d 100644 --- a/backend/web/login/login.py +++ b/backend/web/login/login.py @@ -289,8 +289,9 @@ def consent(): ) # Resolve to which app the client_id belongs. - app_obj = db.session.query(OAuthClientApp).filter(OAuthClientApp.oauthclient_id == client_id).first().app - if not app_obj: + try: + app_obj = db.session.query(OAuthClientApp).filter(OAuthClientApp.oauthclient_id == client_id).first().app + except AttributeError: current_app.logger.error(f"Could not find app for client {client_id}") return redirect( consent_request.reject( diff --git a/backend/web/templates/settings.html b/backend/web/templates/settings.html index 60ad59534bd36e20ca2579a60e6cf0550b1ccda4..8cb290afa9f0d5e09b34177b1e0ec74529e2b2cd 100644 --- a/backend/web/templates/settings.html +++ b/backend/web/templates/settings.html @@ -19,7 +19,7 @@ <div id="contentMessages"></div> <div id="contentProfileSaved" class='alert alert-success' - style='display:none'>Successfuly saved new settings.</div> + style='display:none'>Successfully saved new settings.</div> <div id="contentProfileSaveFailed" class='alert alert-danger' style='display:none'>Your changes are not saved. Please check the fields for errors.</div> diff --git a/deployment/helmchart/Chart.yaml b/deployment/helmchart/Chart.yaml index 79b2a08366414430914b47456254b0ed029d8b1c..a1dcad3965eb4258c604b4d87ae7d808024be538 100644 --- a/deployment/helmchart/Chart.yaml +++ b/deployment/helmchart/Chart.yaml @@ -23,4 +23,4 @@ name: stackspin-dashboard sources: - https://open.greenhost.net/stackspin/dashboard/ - https://open.greenhost.net/stackspin/dashboard-backend/ -version: 1.5.2 +version: 1.6.0 diff --git a/deployment/helmchart/templates/job-initialize-user.yaml b/deployment/helmchart/templates/job-initialize-user.yaml index 32910dbc7b17209e8a3a74fe59008c34e34aa247..4cf77350d3cbfbd3b2defeb9e3f732397d8ab95b 100644 --- a/deployment/helmchart/templates/job-initialize-user.yaml +++ b/deployment/helmchart/templates/job-initialize-user.yaml @@ -21,6 +21,7 @@ spec: component: dashboard spec: restartPolicy: Never + serviceAccountName: {{ include "dashboard.serviceAccountName" . }} containers: - name: {{ .Chart.Name }}-login-create-admin image: {{ template "backend.image" . }} diff --git a/deployment/helmchart/values.yaml b/deployment/helmchart/values.yaml index 17a0c539b1485b8732710b24011410c20cadf285..58c293add3bcd6d10b79fa2c73846a7ae2fabace 100644 --- a/deployment/helmchart/values.yaml +++ b/deployment/helmchart/values.yaml @@ -68,7 +68,7 @@ dashboard: image: registry: open.greenhost.net:4567 repository: stackspin/dashboard/dashboard - tag: 0.5.2 + tag: 0.6.0 digest: "" ## Optionally specify an array of imagePullSecrets. ## Secrets must be manually created in the namespace. @@ -236,7 +236,7 @@ backend: image: registry: open.greenhost.net:4567 repository: stackspin/dashboard/dashboard-backend - tag: 0.5.2 + tag: 0.6.0 digest: "" ## Optionally specify an array of imagePullSecrets. ## Secrets must be manually created in the namespace. diff --git a/docker-compose.yml b/docker-compose.yml index 284a7a145317cb16b1bf1ac9d6881c3d4f377ded..fe11ab8c1886ece8f46929221c59dbdeab4217f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: - "3000:3000" # command: "yarn start" stackspin_proxy: - image: nginx:1.23.2 + image: nginx:1.23.3 ports: - "8081:8081" volumes: @@ -54,7 +54,7 @@ services: - kube_port_mysql entrypoint: ["bash", "-c", "flask run --host $$(hostname -i)"] kube_port_kratos_admin: - image: bitnami/kubectl:1.25.4 + image: bitnami/kubectl:1.26.1 user: "${KUBECTL_UID}:${KUBECTL_GID}" expose: - 8000 @@ -62,7 +62,7 @@ services: - "$KUBECONFIG:/.kube/config" entrypoint: ["bash", "-c", "kubectl -n stackspin port-forward --address $$(hostname -i) service/kratos-admin 8000:80"] kube_port_hydra_admin: - image: bitnami/kubectl:1.25.4 + image: bitnami/kubectl:1.26.1 user: "${KUBECTL_UID}:${KUBECTL_GID}" expose: - 4445 @@ -70,7 +70,7 @@ services: - "$KUBECONFIG:/.kube/config" entrypoint: ["bash", "-c", "kubectl -n stackspin port-forward --address $$(hostname -i) service/hydra-admin 4445:4445"] kube_port_kratos_public: - image: bitnami/kubectl:1.25.4 + image: bitnami/kubectl:1.26.1 user: "${KUBECTL_UID}:${KUBECTL_GID}" ports: - "8080:8080" @@ -80,7 +80,7 @@ services: - "$KUBECONFIG:/.kube/config" entrypoint: ["bash", "-c", "kubectl -n stackspin port-forward --address 0.0.0.0 service/kratos-public 8080:80"] kube_port_mysql: - image: bitnami/kubectl:1.25.4 + image: bitnami/kubectl:1.26.1 user: "${KUBECTL_UID}:${KUBECTL_GID}" expose: - 3306 diff --git a/frontend/src/modules/login/LoginCallback.tsx b/frontend/src/modules/login/LoginCallback.tsx index aa3d992094b2261ceff91c8854f2639e1839d4e7..54a55edfb7132a5825e9949bd6b89c88901bce0f 100644 --- a/frontend/src/modules/login/LoginCallback.tsx +++ b/frontend/src/modules/login/LoginCallback.tsx @@ -50,7 +50,7 @@ export function LoginCallback() { /> </svg> </div> - <p className="text-lg text-primary-600 mt-2">Logging You in, just a moment.</p> + <p className="text-lg text-primary-600 mt-2">Logging you in, just a moment.</p> </div> </div> </div>