diff --git a/backend/areas/apps/__init__.py b/backend/areas/apps/__init__.py deleted file mode 100644 index c798e1591650493727541bc252166abdf252a585..0000000000000000000000000000000000000000 --- a/backend/areas/apps/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .apps import * -from .apps_service import * -from .models import * diff --git a/backend/areas/apps/apps_service.py b/backend/areas/apps/apps_service.py index 04958aa884b76119ac390dbda33927438ae950fa..dc88deb4171dc5de2673ffaba7202566262914b0 100644 --- a/backend/areas/apps/apps_service.py +++ b/backend/areas/apps/apps_service.py @@ -1,12 +1,18 @@ +import threading + from flask import current_app from flask_jwt_extended import get_jwt import ory_kratos_client from ory_kratos_client.api import identity_api from .models import App, AppRole +from areas.roles.models import Role +from areas.users.models import User from config import * +from database import db from helpers.access_control import user_has_access from helpers.kratos_user import KratosUser +import helpers.kubernetes as k8s class AppsService: @staticmethod @@ -42,3 +48,37 @@ class AppsService: def get_app_roles(): app_roles = AppRole.query.all() return [{"user_id": app_role.user_id, "app_id": app_role.app_id, "role_id": app_role.role_id} for app_role in app_roles] + + @classmethod + def install_app(cls, app): + app.install() + # Create app roles for the new app for all admins, and reprovision. We + # do this asynchronously, because we first need to wait for the app + # installation to be finished -- otherwise the SCIM config for user + # provisioning is not ready yet. + current_app.logger.info("Starting thread for creating app roles.") + # We need to pass the app context to the thread, because it needs that + # for database operations. + ca = current_app._get_current_object() + threading.Thread(target=cls.create_admin_app_roles, args=(ca, app,)).start() + + @staticmethod + def create_admin_app_roles(ca, app): + """Create AppRole objects for the given app for all admins.""" + with ca.app_context(): + ca.logger.info("Waiting for kustomizations to be ready.") + k8s.wait_kustomization_ready(app) + for user in User.get_all(): + if not user['stackspin_data']['stackspin_admin']: + # We are only dealing with admin users here. + continue + existing_app_role = AppRole.query.filter_by(app_id=app.id, user_id=user['id']).first() + if existing_app_role is None: + ca.logger.info(f"Creating app role for app {app.slug} for admin user {user['id']}") + app_role = AppRole( + user_id=user['id'], + app_id=app.id, + role_id=Role.ADMIN_ROLE_ID + ) + db.session.add(app_role) + db.session.commit() diff --git a/backend/areas/apps/models.py b/backend/areas/apps/models.py index 5c06341a9f8bad1bf422fa2c070869cb63fe93e5..fd89c963fc705036e8028148f1cd0c404fe43cf5 100644 --- a/backend/areas/apps/models.py +++ b/backend/areas/apps/models.py @@ -92,16 +92,17 @@ class App(db.Model): def install(self): """Creates a Kustomization in the Kubernetes cluster that installs this application""" # Create add-<app> kustomization + print("Creating app kustomization.") self.__create_kustomization() def uninstall(self): """ - Delete the app kustomization. + Delete the `add-$app` kustomization. - In our case, this triggers a deletion of the app's PVCs (so deletes all - data), as well as any other Kustomizations and HelmReleases related to - the app. It also triggers a deletion of the OAuth2Client object. It - also does not remove the TLS secret generated by cert-manager. + This triggers a deletion of the app's PVCs (so deletes all data), as + well as any other Kustomizations and HelmReleases related to the app. + It also triggers a deletion of the OAuth2Client object. It does not + remove the TLS secret generated by cert-manager. """ self.__delete_kustomization() @@ -258,7 +259,7 @@ class AppStatus(): # pylint: disable=too-few-public-methods kustomization = app.kustomization if kustomization is not None and "status" in kustomization: - ks_ready, ks_message = AppStatus.check_condition(kustomization['status']) + ks_ready, ks_message = k8s.check_condition(kustomization['status']) self.installed = True if ks_ready: self.ready = ks_ready @@ -275,7 +276,7 @@ class AppStatus(): # pylint: disable=too-few-public-methods helmreleases = app.helmreleases for helmrelease in helmreleases: hr_status = helmrelease['status'] - hr_ready, hr_message = AppStatus.check_condition(hr_status) + hr_ready, hr_message = k8s.check_condition(hr_status) # For now, only show the message of the first HR that isn't ready if not hr_ready: @@ -290,26 +291,6 @@ class AppStatus(): # pylint: disable=too-few-public-methods def __repr__(self): return f"Installed: {self.installed}\tReady: {self.ready}\tMessage: {self.message}" - @staticmethod - def check_condition(status): - """ - Returns a tuple that has true/false for readiness and a message - - Ready, in this case means that the condition's type == "Ready" and its - status == "True". If the condition type "Ready" does not occur, the - status is interpreted as not ready. - - The message that is returned is the message that comes with the - condition with type "Ready" - - :param status: Kubernetes resource's "status" object. - :type status: dict - """ - for condition in status["conditions"]: - if condition["type"] == "Ready": - return condition["status"] == "True", condition["message"] - return False, "Condition with type 'Ready' not found" - def to_dict(self): """Represents this app status as a dict""" return { diff --git a/backend/areas/auth/auth.py b/backend/areas/auth/auth.py index 6cd27d343e77514b37f9147d93c31d42d9d775d0..861081a403b69b505beb506a3240f99529ac73dc 100644 --- a/backend/areas/auth/auth.py +++ b/backend/areas/auth/auth.py @@ -4,7 +4,7 @@ from flask_cors import cross_origin from datetime import timedelta from areas import api_v1 -from areas.apps import App, AppRole +from areas.apps.models import App, AppRole from config import * from helpers import HydraOauth, BadRequest, KratosApi diff --git a/backend/areas/users/models.py b/backend/areas/users/models.py new file mode 100644 index 0000000000000000000000000000000000000000..b8669895ee274d56fb9126567a6423488d4488a6 --- /dev/null +++ b/backend/areas/users/models.py @@ -0,0 +1,77 @@ +from areas.apps.models import App, AppRole +from areas.roles.models import Role +from areas.tags.models import TagUser +from database import db +from helpers import KratosApi + +class User(): + @staticmethod + def get_all(): + page = 0 + userList = [] + # Get all associated user data (Stackspin roles, tags). + stackspinData = UserStackspinData() + while page >= 0: + if page == 0: + res = KratosApi.get("/admin/identities?per_page=1000").json() + else: + res = KratosApi.get("/admin/identities?per_page=1000&page={}".format(page)).json() + for r in res: + # Inject information from the `stackspin` database that's associated to this user. + r["stackspin_data"] = stackspinData.getData(r["id"]) + userList.append(r) + if len(res) == 0: + page = -1 + else: + page = page + 1 + + return userList + +class UserStackspinData(): + # TODO: we currently ignore the userID parameter, so we always get all + # associated information even if we only need it for a single user. + # That should be changed. + def __init__(self, userID=None): + self.dashboardRoles = self.__getDashboardRoles() + self.userTags = self.__getUserTags() + + def getData(self, userID): + stackspinData = {} + dashboardRole = self.dashboardRoles.get(userID) + if dashboardRole is not None: + stackspinData["stackspin_admin"] = dashboardRole == Role.ADMIN_ROLE_ID + # Also, user tags. + stackspinData["tags"] = self.userTags.get(userID, []) + return stackspinData + + @staticmethod + def setTags(userID, tags): + # Delete all existing tags, because the new set of tags is interpreted + # to overwrite the previous set. + db.session.query(TagUser).filter(TagUser.user_id == userID).delete() + # Now create an entry for every tag in the new list. + for tagID in tags: + tagUser = TagUser(user_id=userID, tag_id=tagID) + db.session.add(tagUser) + + @staticmethod + def __getDashboardRoles(): + dashboardRoles = {} + for appRole, app in ( + db.session.query(AppRole, App) + .filter(AppRole.app_id == App.id) + .filter(App.slug == "dashboard") + .all() + ): + dashboardRoles[appRole.user_id] = appRole.role_id + return dashboardRoles + + @staticmethod + def __getUserTags(): + userTags = {} + for tagUser in db.session.query(TagUser).all(): + if tagUser.user_id in userTags: + userTags[tagUser.user_id].append(tagUser.tag_id) + else: + userTags[tagUser.user_id] = [tagUser.tag_id] + return userTags diff --git a/backend/areas/users/user_service.py b/backend/areas/users/user_service.py index 2415fea0f9bd2d922277c3a7927cd1ff73b40fae..02b2d7afde3b1683c64c98efc7893850c89ac28a 100644 --- a/backend/areas/users/user_service.py +++ b/backend/areas/users/user_service.py @@ -12,12 +12,12 @@ import time from flask import current_app +from .models import UserStackspinData from config import KRATOS_ADMIN_URL from database import db -from areas.apps import App, AppRole, AppsService -from areas.apps.models import ProvisionStatus -from areas.roles import Role, RoleService -from areas.tags import TagUser +from areas.apps.models import App, AppRole, ProvisionStatus +from areas.apps.apps_service import AppsService +from areas.roles import Role from helpers import KratosApi from helpers.error_handler import KratosError from helpers.threads import request_provision @@ -32,25 +32,7 @@ kratos_identity_api = identity_api.IdentityApi(kratos_client) class UserService: @classmethod def get_users(cls): - page = 0 - userList = [] - # Get all associated user data (Stackspin roles, tags). - stackspinData = UserStackspinData() - while page >= 0: - if page == 0: - res = KratosApi.get("/admin/identities?per_page=1000").json() - else: - res = KratosApi.get("/admin/identities?per_page=1000&page={}".format(page)).json() - for r in res: - # Inject information from the `stackspin` database that's associated to this user. - r["stackspin_data"] = stackspinData.getData(r["id"]) - userList.append(r) - if len(res) == 0: - page = -1 - else: - page = page + 1 - - return userList + return User.get_all() @classmethod def get_user(cls, id): @@ -274,52 +256,3 @@ class UserService: userRes["traits"]["app_roles"] = app_roles return userRes - -class UserStackspinData(): - # TODO: we currently ignore the userID parameter, so we always get all - # associated information even if we only need it for a single user. - # That should be changed. - def __init__(self, userID=None): - self.dashboardRoles = self.__getDashboardRoles() - self.userTags = self.__getUserTags() - - def getData(self, userID): - stackspinData = {} - dashboardRole = self.dashboardRoles.get(userID) - if dashboardRole is not None: - stackspinData["stackspin_admin"] = dashboardRole == Role.ADMIN_ROLE_ID - # Also, user tags. - stackspinData["tags"] = self.userTags.get(userID, []) - return stackspinData - - @staticmethod - def setTags(userID, tags): - # Delete all existing tags, because the new set of tags is interpreted - # to overwrite the previous set. - db.session.query(TagUser).filter(TagUser.user_id == userID).delete() - # Now create an entry for every tag in the new list. - for tagID in tags: - tagUser = TagUser(user_id=userID, tag_id=tagID) - db.session.add(tagUser) - - @staticmethod - def __getDashboardRoles(): - dashboardRoles = {} - for appRole, app in ( - db.session.query(AppRole, App) - .filter(AppRole.app_id == App.id) - .filter(App.slug == "dashboard") - .all() - ): - dashboardRoles[appRole.user_id] = appRole.role_id - return dashboardRoles - - @staticmethod - def __getUserTags(): - userTags = {} - for tagUser in db.session.query(TagUser).all(): - if tagUser.user_id in userTags: - userTags[tagUser.user_id].append(tagUser.tag_id) - else: - userTags[tagUser.user_id] = [tagUser.tag_id] - return userTags diff --git a/backend/cliapp/cliapp/cli.py b/backend/cliapp/cliapp/cli.py index f00abf7143c9229737d5ade3ac051209750b4cb2..ee588e74f36c3c0e40d70f0192a350f1f78f4c70 100644 --- a/backend/cliapp/cliapp/cli.py +++ b/backend/cliapp/cliapp/cli.py @@ -18,7 +18,8 @@ from sqlalchemy import func from config import HYDRA_ADMIN_URL, KRATOS_ADMIN_URL, KRATOS_PUBLIC_URL from helpers import KratosUser from cliapp import cli -from areas.apps import AppRole, App +from areas.apps.apps_service import AppsService +from areas.apps.models import AppRole, App from areas.roles import Role from areas.users import UserService from database import db @@ -200,7 +201,7 @@ def install_app(slug): current_status = app.get_status() if not current_status.installed: - app.install() + AppsService.install_app(app) current_app.logger.info( f"App {slug} installing... use `status` to see status") else: diff --git a/backend/helpers/kubernetes.py b/backend/helpers/kubernetes.py index 4cea526239d102a978f62c2d444dc81d442d02ea..de35673ac9430e6cce2c03f2a5d34074a5c192c3 100644 --- a/backend/helpers/kubernetes.py +++ b/backend/helpers/kubernetes.py @@ -457,3 +457,40 @@ def watch_dashboard_config(app, reload): debounced_reload() thread = threading.Thread(target=p) thread.start() + +def check_condition(status): + """ + Returns a tuple that has true/false for readiness and a message + + Ready, in this case means that the condition's type == "Ready" and its + status == "True". If the condition type "Ready" does not occur, the + status is interpreted as not ready. + + The message that is returned is the message that comes with the + condition with type "Ready" + + :param status: Kubernetes resource's "status" object. + :type status: dict + """ + if status["observedGeneration"] == -1: + return False, "Kustomization is not yet seen by controller" + for condition in status["conditions"]: + if condition["type"] == "Ready": + return condition["status"] == "True", condition["message"] + return False, "Condition with type 'Ready' not found" + +def wait_kustomization_ready(app): + w = watch.Watch() + api_instance = client.CustomObjectsApi() + for event in w.stream(api_instance.list_namespaced_custom_object, 'kustomize.toolkit.fluxcd.io', 'v1', 'flux-system', 'kustomizations'): + ks = event['object'] + if ks['metadata']['name'] != app.slug: + # We're currently only interested in the `app` app. + continue + ks_ready, ks_message = check_condition(ks['status']) + if not ks_ready: + # There is some data on the app kustomization, but it's not ready + # yet. + continue + print(f"Kustomization {app.slug} is now ready.") + return diff --git a/backend/web/login/login.py b/backend/web/login/login.py index 6198d85140b5ff0b95f473dc9e361fa089f17bc1..c6bcd70792a3e983ec895e1340688f9bef109902 100644 --- a/backend/web/login/login.py +++ b/backend/web/login/login.py @@ -24,7 +24,7 @@ from database import db from helpers import KratosUser from config import * from web import web -from areas.apps import AppRole, App, OAuthClientApp +from areas.apps.models import AppRole, App, OAuthClientApp from areas.roles import RoleService from areas.roles.models import Role from areas.users.user_service import UserService diff --git a/deployment/helmchart/templates/rbac/clusterrole.yaml b/deployment/helmchart/templates/rbac/clusterrole.yaml index 834c6c980c38a7174115a1e7a1eb42492f6715a3..b6a7857d7c47acb718a607f05bc2b59bd5bf0d14 100644 --- a/deployment/helmchart/templates/rbac/clusterrole.yaml +++ b/deployment/helmchart/templates/rbac/clusterrole.yaml @@ -22,6 +22,7 @@ rules: - get - patch - create + - watch - apiGroups: - helm.toolkit.fluxcd.io resources: