From 229d5531dd561358f30e4ba495847f24cde33da0 Mon Sep 17 00:00:00 2001
From: Arie Peterson <arie@greenhost.nl>
Date: Tue, 21 May 2024 14:43:23 +0200
Subject: [PATCH] Admin app roles

---
 backend/areas/apps/__init__.py                |  3 -
 backend/areas/apps/apps_service.py            | 40 ++++++++++
 backend/areas/apps/models.py                  | 35 ++-------
 backend/areas/auth/auth.py                    |  2 +-
 backend/areas/users/models.py                 | 77 +++++++++++++++++++
 backend/areas/users/user_service.py           | 77 ++-----------------
 backend/cliapp/cliapp/cli.py                  |  5 +-
 backend/helpers/kubernetes.py                 | 37 +++++++++
 backend/web/login/login.py                    |  2 +-
 .../helmchart/templates/rbac/clusterrole.yaml |  1 +
 10 files changed, 173 insertions(+), 106 deletions(-)
 delete mode 100644 backend/areas/apps/__init__.py
 create mode 100644 backend/areas/users/models.py

diff --git a/backend/areas/apps/__init__.py b/backend/areas/apps/__init__.py
deleted file mode 100644
index c798e159..00000000
--- 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 04958aa8..dc88deb4 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 5c06341a..fd89c963 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 6cd27d34..861081a4 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 00000000..b8669895
--- /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 2415fea0..02b2d7af 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 f00abf71..ee588e74 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 4cea5262..de35673a 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 6198d851..c6bcd707 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 834c6c98..b6a7857d 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:
-- 
GitLab