diff --git a/CHANGELOG.md b/CHANGELOG.md index f3bc7757cccf5c2468d3969bb70754f0ca4a4484..f89f0ea0b3e3aebb791deae380cd9bed0b8bd6ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Make info modals slightly wider, to make sure you can see the full contents also for slightly larger fonts. - Upgrade to tailwind v3, and update several other javascript dependencies. +- Implement basic SCIM functionality for automatic user provisioning. "Basic" + because setup is still manual and will need to be automated later. ## [0.9.2] diff --git a/backend/app.py b/backend/app.py index 06db7d65e6edd9947b356375b2b59e360a79b891..b25e68020e3e62d8b3a2296944e67d7805f5c997 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,3 +1,4 @@ +from apscheduler.schedulers.background import BackgroundScheduler from flask import Flask, jsonify from flask_cors import CORS from flask_jwt_extended import JWTManager @@ -18,6 +19,7 @@ from areas import resources from areas import roles from areas import tags from cliapp import cliapp +import helpers.provision from web import login from database import db @@ -73,6 +75,7 @@ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = SQLALCHEMY_TRACK_MODIFICATIONS app.logger.setLevel(logging.INFO) cors = CORS(app) +provisioner = helpers.provision.Provision() db.init_app(app) @@ -108,6 +111,19 @@ def init_routines(): # Same for the list of oauthclients. cluster_config.populate_oauthclients() + if provisioner.enabled: + # We define this wrapper because the SCIM provisioning code needs to access the + # database, which needs a flask app context. + def provision(): + with app.app_context(): + provisioner.reconcile() + # Set up a generic task scheduler (cron-like). + scheduler = BackgroundScheduler() + scheduler.start() + # Add a job to run the provisioning reconciliation routine regularly. + # TODO: decrease the frequency once development settles. + scheduler.add_job(provision, 'interval', minutes=1) + # `init_routines` should only run once per dashboard instance. To enforce this # we have different behaviour for production and development mode: # * We have "preload" on for gunicorn, so this file is loaded only once, before diff --git a/backend/areas/apps/models.py b/backend/areas/apps/models.py index d2b411cbb1791b3431452da2e8c69e4c976d14b3..5d983dee629394f13a2159a4e8f406d2b26ebb1a 100644 --- a/backend/areas/apps/models.py +++ b/backend/areas/apps/models.py @@ -1,9 +1,10 @@ """Everything to do with Apps""" -import os import base64 +import enum +import os -from sqlalchemy import ForeignKey, Integer, String, Boolean +from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, Unicode from sqlalchemy.orm import relationship from database import db @@ -180,6 +181,21 @@ class App(db.Model): return os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates") +class ProvisionStatus(enum.Enum): + SyncNeeded = "SyncNeeded" + Provisioned = "Provisioned" + # Provisioning is not necessary for this user/role, for + # example because the user has no access to this app. + NotProvisioned = "NotProvisioned" + # SCIM Provisioning is not supported for this particular app. + NotSupported = "NotSupported" + # This user needs to be deleted from this app. + ToDelete = "ToDelete" + # Something went wrong; more information can be found in the + # `last_provision_message`. + Error = "Error" + + class AppRole(db.Model): # pylint: disable=too-few-public-methods """ The AppRole object, stores the roles Users have on Apps @@ -188,8 +204,22 @@ class AppRole(db.Model): # pylint: disable=too-few-public-methods user_id = db.Column(String(length=64), primary_key=True) app_id = db.Column(Integer, ForeignKey("app.id"), primary_key=True) role_id = db.Column(Integer, ForeignKey("role.id")) + provision_status = db.Column( + Enum( + ProvisionStatus, + native_enum=False, + length=32, + values_callable=lambda _: [str(member.value) for member in ProvisionStatus] + ), + nullable=False, + default=ProvisionStatus.SyncNeeded, + server_default=ProvisionStatus.SyncNeeded.value + ) + last_provision_attempt = db.Column(DateTime, nullable=True) + last_provision_message = db.Column(Unicode(length=256), nullable=True) role = relationship("Role") + app = relationship("App") def __repr__(self): return (f"role_id: {self.role_id}, user_id: {self.user_id}," diff --git a/backend/areas/users/user_service.py b/backend/areas/users/user_service.py index 8b7270d8d73d949efdc0356b41c192773e1a366c..af7e60fd37cbc8bfb4c0067533109e73a2e94e98 100644 --- a/backend/areas/users/user_service.py +++ b/backend/areas/users/user_service.py @@ -15,6 +15,7 @@ from flask import current_app 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 helpers import KratosApi @@ -148,22 +149,7 @@ class UserService: app_roles = data["app_roles"] for ar in app_roles: app = App.query.filter_by(slug=ar["name"]).first() - app_role = AppRole.query.filter_by( - user_id=id, app_id=app.id).first() - - if app_role: - # There is already a role set for this user and app, so we - # edit it. - app_role.role_id = ar["role_id"] if "role_id" in ar else None - else: - # There is no role set yet for this user and app, so we - # create a new one. - appRole = AppRole( - user_id=id, - role_id=ar["role_id"] if "role_id" in ar else None, - app_id=app.id, - ) - db.session.add(appRole) + cls.set_user_role(id, app.id, ar["role_id"] if "role_id" in ar else None) if data.get("tags") is not None: UserStackspinData.setTags(id, data["tags"]) @@ -172,6 +158,26 @@ class UserService: return cls.get_user(id) + @classmethod + def set_user_role(cls, user_id, app_id, role_id): + app_role = AppRole.query.filter_by(user_id=user_id, app_id=app_id).first() + if app_role: + # There is already a role set for this user and app, so we + # edit it. + app_role.role_id = role_id + # Mark the app role so the SCIM routine will pick it up at + # its next run. + app_role.provision_status = ProvisionStatus.SyncNeeded + else: + # There is no role set yet for this user and app, so we + # create a new one. + appRole = AppRole( + user_id=user_id, + role_id=role_id, + app_id=app_id, + ) + db.session.add(appRole) + @classmethod def put_multiple_users(cls, user_editing_id, data): for user_data in data["users"]: @@ -192,6 +198,9 @@ class UserService: if app_role: app_role.role_id = ar["role_id"] if "role_id" in ar else None + # Mark the app role so the SCIM routine will pick it up at + # its next run. + app_role.provision_status = ProvisionStatus.SyncNeeded db.session.commit() else: appRole = AppRole( @@ -211,7 +220,7 @@ class UserService: def delete_user(id): app_role = AppRole.query.filter_by(user_id=id).all() for ar in app_role: - db.session.delete(ar) + ar.provision_status = ProvisionStatus.ToDelete db.session.commit() @classmethod diff --git a/backend/cliapp/cliapp/cli.py b/backend/cliapp/cliapp/cli.py index 4c41ab53aecea3e893f8646f4344f616ab967660..41dec63ec81f0ee3159661842be42e5aa8164f87 100644 --- a/backend/cliapp/cliapp/cli.py +++ b/backend/cliapp/cliapp/cli.py @@ -113,6 +113,7 @@ def cleanup_users(dry_run): if not dry_run: print("Deleting.") user.delete() + UserService.delete(user.uuid) if dry_run: print(f"Would delete {number_inactive_users} users out of {number_users} total.") else: @@ -236,7 +237,8 @@ def setrole(email, app_slug, role): """Set role for a user :param email: Email address of user to assign role :param app_slug: Slug name of the app, for example 'nextcloud' - :param role: Role to assign. currently only 'admin', 'user' + :param role: Role to assign. Currently only 'admin', 'user', 'none'/'no + access'. """ current_app.logger.info(f"Assigning role {role} to {email} for app {app_slug}") @@ -244,40 +246,23 @@ def setrole(email, app_slug, role): # Find user user = KratosUser.find_by_email(kratos_identity_api, email) - if role not in ("admin", "user", "none"): - print("At this point the only accepted roles are 'admin', 'user' and 'none'.") - sys.exit(1) - if not user: print("User not found. Abort") sys.exit(1) - app_obj = db.session.query(App).filter(App.slug == app_slug).first() - if not app_obj: + app = db.session.query(App).filter(App.slug == app_slug).first() + if not app: print("App not found. Abort.") sys.exit(1) - role_obj = ( - db.session.query(AppRole) - .filter(AppRole.app_id == app_obj.id) - .filter(AppRole.user_id == user.uuid) - .first() - ) - - # Always delete the old role for this app and user if it exists. - if role_obj: - db.session.delete(role_obj) - - # If the new role is not "none", add it. - if role in ("admin", "user"): - role = Role.query.filter(func.lower(Role.name) == func.lower(role)).first() - - obj = AppRole() - obj.user_id = user.uuid - obj.app_id = app_obj.id - obj.role_id = role.id if role else None + if role == "none": + role = "no access" + role = Role.query.filter(func.lower(Role.name) == func.lower(role)).first() + if not role: + print("Role not found. Abort.") + sys.exit(1) - db.session.add(obj) + UserService.set_user_role(user.uuid, app.id, role.id) db.session.commit() @@ -347,7 +332,7 @@ def delete_user(email): if not user: current_app.logger.error(f"User with email {email} not found.") sys.exit(1) - user.delete() + UserService.delete_user(user.uuid) @user_cli.command("create") diff --git a/backend/config.py b/backend/config.py index 831f41caa0c67ae9ec493ef7b06e8b6f18be7125..f43ee28b16e2ce8ca7a648d21bc25d28d1d16e1a 100644 --- a/backend/config.py +++ b/backend/config.py @@ -30,9 +30,15 @@ if os.environ.get("TELEPRESENCE_ROOT"): KUBECONFIG = os.environ["TELEPRESENCE_MOUNTS"] else: KUBECONFIG = os.environ.get("TELEPRESENCE_ROOT") + os.environ["TELEPRESENCE_MOUNTS"] + print(f"KUBECONFIG from telepresence: {KUBECONFIG}") + print(os.stat(KUBECONFIG)) else: TELEPRESENCE = False KUBECONFIG = None DEMO_INSTANCE = os.environ.get("DASHBOARD_DEMO_INSTANCE", "False").lower() in ('true', '1') + ENFORCE_2FA = os.environ.get("DASHBOARD_ENFORCE_2FA", "False").lower() in ('true', '1') + +SCIM_URL = os.environ.get("SCIM_URL") +SCIM_TOKEN = os.environ.get("SCIM_TOKEN") diff --git a/backend/helpers/provision.py b/backend/helpers/provision.py new file mode 100644 index 0000000000000000000000000000000000000000..3b1f01f000286597f65b274aaa224dc54ef143fe --- /dev/null +++ b/backend/helpers/provision.py @@ -0,0 +1,304 @@ +from datetime import datetime +import json.decoder +import logging +import ory_kratos_client +from ory_kratos_client.api import identity_api +import requests + +from areas.apps.models import App, AppRole, ProvisionStatus +from areas.roles.models import Role +import config +from database import db +from helpers.kratos_user import KratosUser + +class ProvisionError(Exception): + pass + +class User: + def __init__(self, kratos_id, scim_id, displayName): + self.kratos_id = kratos_id + self.scim_id = scim_id + self.displayName = displayName + + def ref(self): + return f"{config.SCIM_URL}Users/{self.scim_id}" + +class Group: + def __init__(self, scim_id, displayName, members): + self.scim_id = scim_id + self.displayName = displayName + self.members = members + self.modified = False + + def add_member(self, user): + if user.scim_id not in self.members: + logging.info(f"Adding user to dict: {user.displayName} ({user.scim_id})") + self.members[user.scim_id] = user + self.modified = True + + def remove_member(self, user): + if user.scim_id in self.members: + logging.info(f"Found user to remove from dict: {user.displayName} ({user.scim_id})") + del self.members[user.scim_id] + self.modified = True + + def debug(self): + logging.info(f"Group {self.displayName} ({self.scim_id})") + for _, member in self.members.items(): + logging.info(f" with user {member.displayName} ({member.scim_id})") + +# Read from the database which users need to be provisioned on which apps, and +# do the corresponding SCIM calls to those apps to do the actual provisioning. +class Provision: + def __init__(self): + if config.SCIM_TOKEN is None: + logging.warn("SCIM_TOKEN is not set, so disabling SCIM") + self.enabled = False + return + if config.SCIM_URL is None: + logging.warn("SCIM_URL is not set, so disabling SCIM") + self.enabled = False + return + self.enabled = True + logging.info(f"Enabling SCIM, with URL {config.SCIM_URL}") + + # Set up kratos API client. + kratos_admin_api_configuration = ory_kratos_client.Configuration(host=config.KRATOS_ADMIN_URL, discard_unknown_keys=True) + kratos_admin_client = ory_kratos_client.ApiClient(kratos_admin_api_configuration) + self.kratos_identity_api = identity_api.IdentityApi(kratos_admin_client) + + def app_supported(self, app): + return (app.slug == "nextcloud") + + def reconcile(self): + logging.info("Reconciling user provisioning") + existing_users = self._get_existing_users() + for userId, u in existing_users.items(): + logging.info(f"Existing user: {u.displayName} ({userId})") + existing_groups = self._get_existing_groups() + for _, g in existing_groups.items(): + g.debug() + # We will modify this group in-memory over the course of the user reconciliation. + # After we have reconciled all users, we will update the group in a single operation. + # We could also use PATCH operations instead to add single users as we + # go, but the Nextcloud SCIM app does not support that. + admin_group = existing_groups.get('admin', None) + if admin_group is None: + raise ProvisionError("Admin group could not be found, aborting.") + # TODO: we later need to retry Error ones as well. + app_roles = db.session.query(AppRole).filter( + AppRole.provision_status.in_((ProvisionStatus.SyncNeeded, ProvisionStatus.ToDelete)) + ) + for app_role in app_roles: + # logging.info(f"found app_role: {app_role}") + app_role.last_provision_attempt = datetime.now() + existing_user = existing_users.get(app_role.user_id, None) + if existing_user is not None: + logging.info(f"User {app_role.user_id} already exists in the app.") + if app_role.role_id == Role.NO_ACCESS_ROLE_ID: + if existing_user is None: + logging.info("User without access does not exist yet, so nothing to do.") + # We have not provisioned this user in this app yet, so we + # don't have to do anything at this point. + app_role.provision_status = ProvisionStatus.Provisioned + app_role.last_provision_message = "Provisioning not required for user without access." + db.session.commit() + continue + else: + logging.info("User without access exists in the app; we continue so we can disable the user in the app.") + app = app_role.app + if not self.app_supported(app): + app_role.provision_status = ProvisionStatus.NotSupported + app_role.last_provision_message = f"App does not support automatic user provisioning." + db.session.commit() + continue + try: + self._provision_user(app_role, app, existing_user, admin_group) + new_status = ProvisionStatus.Provisioned + new_message = "User successfully provisioned." + except ProvisionError as ex: + new_status = ProvisionStatus.Error + new_message = str(ex) + app_role.provision_status = new_status + app_role.last_provision_message = new_message + db.session.commit() + # let's do only one at a time for now + # break + logging.info("Before provisioning admin group:") + admin_group.debug() + if admin_group.modified: + logging.info("Admin group was modified, so updating it via SCIM.") + self._provision_group(admin_group) + else: + logging.info("Admin group was not modified.") + + # Provision the user via SCIM PUT or POST, based on the user and role + # information in `app_role`, and on the existing user object in the app + # `existing_user` (previously obtained via SCIM GET). Also update the + # `admin_group` object so we may later add or remove the user to/from the + # admin group. + def _provision_user(self, app_role, app, existing_user, admin_group): + logging.info(f"Reconciling user {app_role.user_id}") + scim_headers = { + 'Authorization': 'Bearer ' + config.SCIM_TOKEN + } + if app_role.provision_status == ProvisionStatus.ToDelete: + if existing_user is None: + db.session.delete(app_role) + db.session.commit() + return + else: + logging.info(f"Deleting user {app_role.user_id} from {app.slug}") + url = f"{config.SCIM_URL}/Users/{existing_user.scim_id}" + response = requests.delete(url, headers=scim_headers) + logging.info(f"SCIM http status: {response.status_code}") + if response.status_code == 204: + db.session.delete(app_role) + db.session.commit() + return + else: + logging.info(f"Error returned by SCIM deletion: {response.content}") + + # Get the related user object + logging.info(f"Info: Getting user data from Kratos.") + kratos_user = KratosUser(self.kratos_identity_api, app_role.user_id) + if app_role.role_id == Role.NO_ACCESS_ROLE_ID: + active = False + else: + active = True + logging.info(f"Active user: {active}") + data = { + 'schemas': ['urn:ietf:params:scim:schemas:core:2.0:User'], + 'externalId': app_role.user_id, + 'active': active, + # Sadly Nextcloud doesn't allow changing the userName, so we set it + # to something unique and stable. + # https://github.com/nextcloud/server/issues/5488 + # We add the `stackspin-` prefix to make this compatible with the + # username generated by the sociallogin (SSO) Nextcloud app. + 'userName': f"stackspin-{app_role.user_id}", + 'displayName': kratos_user.name, + 'emails': [{ + 'value': kratos_user.email, + 'primary': True + }], + } + if existing_user is None: + url = f"{config.SCIM_URL}/Users" + response = requests.post(url, headers=scim_headers, json=data) + else: + url = f"{config.SCIM_URL}/Users/{existing_user.scim_id}" + response = requests.put(url, headers=scim_headers, json=data) + logging.info(f"SCIM url: {url}") + logging.info(f"SCIM http status: {response.status_code}") + try: + response_json = response.json() + except json.decoder.JSONDecodeError as e: + logging.info("SCIM result was not json") + logging.info(response.content) + raise ProvisionError("App returned non-json data in SCIM user put/post.") + logging.info(f"got: {response_json}") + # {'schemas': ['urn:ietf:params:scim:schemas:core:2.0:User'], 'id': + # 'Greenhostie', 'externalId': '316cbd5c-7b69-4a27-8a3b-96b3ec056e99', + # 'meta': None, 'userName': 'Greenhostie', 'name': {'formatted': + # 'Greenhostie', 'familyName': None, 'givenName': None, 'middleName': None, + # 'honorificPrefix': None, 'honorificSuffix': None}, 'displayName': + # 'Greenhostie', 'nickName': None, 'profileUrl': None, 'title': None, + # 'userType': None, 'preferredLanguage': None, 'locale': None, 'timezone': + # None, 'active': True, 'password': None, 'emails': [{'type': None, + # 'primary': True, 'display': None, 'value': 'arie+scim@greenhost.nl', + # '$ref': None}], 'phoneNumbers': None, 'ims': None, 'photos': None, + # 'addresses': None, 'groups': None, 'entitlements': None, 'roles': None, + # 'x509Certificates': None} + user = User(app_role.user_id, response_json['id'], kratos_user.name) + if app_role.role_id == Role.ADMIN_ROLE_ID: + logging.info(f"Adding user to admin group: {user.displayName} ({user.kratos_id})") + admin_group.add_member(user) + else: + logging.info(f"Removing user from admin group: {user.displayName} ({user.kratos_id})") + admin_group.remove_member(user) + logging.info("After adding/removing user:") + admin_group.debug() + + def _get_existing_users(self): + logging.info(f"Info: Getting list of current users from app via SCIM.") + url = f"{config.SCIM_URL}/Users" + scim_headers = { + 'Authorization': 'Bearer ' + config.SCIM_TOKEN + } + response = requests.get(url, headers=scim_headers) + logging.info(f"SCIM http status: {response.status_code}") + try: + response_json = response.json() + except json.decoder.JSONDecodeError as e: + logging.info("SCIM result was not json") + logging.info(response.content) + raise ProvisionError("Failed to get existing users from Nextcloud") + # logging.info(f"got: {response_json}") + # Make a dictionary of the users, using their externalId as key, which + # is the kratos user ID. + users = {} + for u in response_json['Resources']: + kratos_id = u['externalId'] + if not kratos_id: + logging.info(f"Got user without externalId: {u}") + # Users that were created in Nextcloud by SSO, before SCIM was + # introduced in Stackspin, will not have `externalId` set, so + # we get the Kratos ID from the `id` attribute instead. + if u['id'].startswith('stackspin-'): + kratos_id = u['id'][len('stackspin-'):] + else: + # This is a user that was not created by Stackspin (either + # SSO or SCIM), so we'll ignore it. + continue + users[kratos_id] = User(kratos_id, u['id'], u['displayName']) + return users + + def _get_existing_groups(self): + logging.info(f"Info: Getting list of current groups from Nextcloud via SCIM.") + url = f"{config.SCIM_URL}/Groups" + scim_headers = { + 'Authorization': 'Bearer ' + config.SCIM_TOKEN + } + response = requests.get(url, headers=scim_headers) + logging.info(f"SCIM http status: {response.status_code}") + try: + response_json = response.json() + except json.decoder.JSONDecodeError as e: + logging.info("SCIM result was not json") + logging.info(response.content) + raise ProvisionError("Failed to get existing groups from Nextcloud") + logging.info(f"got: {response_json}") + groups = {} + for group in response_json['Resources']: + members = {} + for member in group['members']: + scim_id = member['value'] + members[scim_id] = User(None, scim_id, member['display']) + groups[group['id']] = Group(group['id'], group['displayName'], members) + return groups + + def _provision_group(self, group): + logging.info(f"Reconciling group {group.scim_id}") + scim_headers = { + 'Authorization': 'Bearer ' + config.SCIM_TOKEN + } + member_data = [ + {'value': member.scim_id, 'display': member.displayName, '$ref': member.ref()} + for _, member in group.members.items()] + logging.info(f"Will update admin group with member data {member_data}") + data = { + 'schemas': ['urn:ietf:params:scim:schemas:core:2.0:Group'], + 'displayName': group.displayName, + 'members': member_data, + } + url = f"{config.SCIM_URL}/Groups/{group.scim_id}" + response = requests.put(url, headers=scim_headers, json=data) + logging.info(f"SCIM http status: {response.status_code}") + try: + response_json = response.json() + except json.decoder.JSONDecodeError as e: + logging.info("SCIM result was not json") + logging.info(response.content) + raise ProvisionError("App returned non-json data in SCIM group put.") + logging.info(f"got: {response_json}") diff --git a/backend/migrations/versions/7d27395c892a_new_migration.py b/backend/migrations/versions/7d27395c892a_new_migration.py index 94a1d844c0918712970147183025150a28ccd37c..5c1a3332615421e89f20395d866d992aa5881ee3 100644 --- a/backend/migrations/versions/7d27395c892a_new_migration.py +++ b/backend/migrations/versions/7d27395c892a_new_migration.py @@ -16,11 +16,11 @@ 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(): + conn = op.get_bind() + inspector = Inspector.from_engine(conn) + tables = inspector.get_table_names() + if "app" not in tables: op.create_table( "app", @@ -69,4 +69,4 @@ 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 + op.drop_table("app") diff --git a/backend/migrations/versions/825262488cd9_add_scim_support.py b/backend/migrations/versions/825262488cd9_add_scim_support.py new file mode 100644 index 0000000000000000000000000000000000000000..2541f090bf1b4d90eb01c63c875d0c4127a1a62b --- /dev/null +++ b/backend/migrations/versions/825262488cd9_add_scim_support.py @@ -0,0 +1,57 @@ +"""Add SCIM support for user provisioning + +Revision ID: 825262488cd9 +Revises: fdb28e81f5c2 +Create Date: 2023-03-08 10:50:00 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql +from sqlalchemy.engine.reflection import Inspector + +from helpers.provision import ProvisionStatus + +# revision identifiers, used by Alembic. +revision = '825262488cd9' +down_revision = 'fdb28e81f5c2' +branch_labels = None +depends_on = None + +def upgrade(): + op.add_column( + "app_role", + sa.Column( + "provision_status", + sa.Enum( + ProvisionStatus, + native_enum=False, + length=32, + values_callable=lambda _: [str(member.value) for member in ProvisionStatus] + ), + nullable=False, + default=ProvisionStatus.SyncNeeded, + server_default=ProvisionStatus.SyncNeeded.value + ), + ) + op.add_column( + "app_role", + sa.Column( + "last_provision_attempt", + sa.DateTime, + nullable=True + ), + ) + op.add_column( + "app_role", + sa.Column( + "last_provision_message", + sa.Unicode(length=256), + nullable=True + ), + ) + +def downgrade(): + op.drop_column("app_role", "provision_status") + op.drop_column("app_role", "last_provision_attempt") + op.drop_column("app_role", "last_provision_message") diff --git a/backend/requirements.txt b/backend/requirements.txt index 0aa91c2fd5ad014f2574d90dbda762e668a010f0..244e4e8b576dc4e66de300dfda2b6cc84ab89137 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,3 +1,4 @@ +APScheduler==3.9.1 attrs==21.4.0 black==22.1.0 certifi==2021.10.8 diff --git a/dev.sh b/dev.sh index e9f383a8f3083e0d16250534b2bc8a0642a4a5c1..486097c783e0b79fe476c1a923b97fd9e5201232 100755 --- a/dev.sh +++ b/dev.sh @@ -103,14 +103,14 @@ runBackend() { echo "Stopping any previous intercept for dashboard-backend..." telepresence leave dashboard-backend echo "Starting new intercept for dashboard-backend..." - telepresence intercept dashboard-backend --service=dashboard-backend --port 5000:80 --mount=true -- env TELEPRESENCE_MODE=native flask run --reload + telepresence intercept dashboard-backend --service=dashboard-backend --port 5000:80 --mount=true --replace --env-file=./backend.env -- env TELEPRESENCE_MODE=native flask run --reload deactivate popd > /dev/null ;; "docker") echo "Stopping any previous intercept for dashboard-backend..." telepresence leave dashboard-backend - telepresence intercept dashboard-backend --service=dashboard-backend --port 5000:80 --mount=true --docker-run -- --dns $(telepresenceDns) -e TELEPRESENCE_MODE=docker dashboard-backend:test + telepresence intercept dashboard-backend --service=dashboard-backend --port 5000:80 --mount=true --replace --docker-run -- --dns $(telepresenceDns) -e TELEPRESENCE_MODE=docker dashboard-backend:test esac }