diff --git a/backend/api/auth_api.py b/backend/api/auth_api.py index 516b9e5..82ceff5 100644 --- a/backend/api/auth_api.py +++ b/backend/api/auth_api.py @@ -109,6 +109,7 @@ def logout(): # Endpoint for revoking the current users refresh token @auth_api_bp.route('/logout2', methods=['GET', 'DELETE']) +@auth_api_bp.route('/revokeRefreshToken', methods=['GET', 'DELETE']) @jwt_refresh_token_required def logout2(): jti = get_raw_jwt()['jti'] @@ -137,6 +138,7 @@ def create_or_retrieve_user_from_userinfo(userinfo): logger.error("email is missing in OIDC userinfo! Can't create user!") return None + pprint(userinfo) user_groups = check_and_create_groups(groups=userinfo.get("memberOf", [])) user = User.get_by_identifier(email) @@ -145,6 +147,7 @@ def create_or_retrieve_user_from_userinfo(userinfo): pprint(user.to_dict()) user.first_name = userinfo.get("given_name", "") user.last_name = userinfo.get("family_name", "") + user.external_user_id = userinfo.get("eduperson_principal_name", None) for g in user_groups: user.groups.append(g) db.session.commit() @@ -152,7 +155,7 @@ def create_or_retrieve_user_from_userinfo(userinfo): user = User(email=email, first_name=userinfo.get("given_name", ""), last_name=userinfo.get("family_name", ""), external_user=True, - groups=user_groups) + groups=user_groups, external_user_id=userinfo.get("eduperson_principal_name", None)) logger.info("creating new user") diff --git a/backend/api/models.py b/backend/api/models.py index e82a41e..a35f81d 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -12,9 +12,15 @@ user_model = api_user.model('User', { 'nickname': fields.String(required=False, description='The user\'s nick name'), 'last_seen': fields.DateTime(required=False, description='Last time user logged in'), 'last_time_modified': fields.DateTime(required=False, description='Last time user was modified'), + 'external_user': fields.Boolean(required=True, description='Indicates whether the user is external (OIDC) or not'), + 'external_user_id': fields.String(required=False, description='External ID of a user (EPPN, etc.)'), 'role': fields.String(required=False, description='Role a user might have (in addition to group memberships)'), 'effective_permissions': fields.List( - fields.String(required=True), required=False, description="List of permissions (groups + (optional) role)." + fields.Nested(api_user.model('effective_permission', + {'id': fields.Integer(required=True), + 'name': fields.String(required=True) + }), + required=False, description="List of permissions (groups + (optional) role).") ), 'groups': fields.List( fields.Nested(api_user.model('user_group', {'id': fields.Integer(), 'name': fields.String()})), @@ -45,7 +51,8 @@ recorder_model = api_recorder.model('Recorder', { 'additional_camera_connected': fields.Boolean(required=False, description='Indicates whether an additional camera is connected'), 'ip': fields.String(required=False, description='The recorder\'s IP address'), - 'mac': fields.String(required=False, description='The recorder\'s IP address'), + 'ip6': fields.String(required=False, description='The recorder\'s IP v6 address'), + 'mac': fields.String(required=False, description='The recorder\'s MAC address'), 'network_name': fields.String(required=False, description='The recorder\'s network name'), 'ssh_port': fields.Integer(required=True, default=22, description='The recorder\'s SSH port number'), 'telnet_port': fields.Integer(required=True, default=23, description='The recorder\'s telnet port number'), diff --git a/backend/api/virtual_command_api.py b/backend/api/virtual_command_api.py index 3aec647..e3b6661 100644 --- a/backend/api/virtual_command_api.py +++ b/backend/api/virtual_command_api.py @@ -14,6 +14,7 @@ from flask_restx import fields, Resource from backend import db, app from backend.api import api_virtual_command +from backend.models import VirtualCommand from backend.models.recorder_model import Recorder, RecorderModel, RecorderCommand from backend.models.room_model import Room import backend.recorder_adapters as r_a @@ -109,26 +110,37 @@ class RecorderList(Resource): @api_virtual_command.expect(virtual_command_model_parser) @api_virtual_command.marshal_with(virtual_command_model, skip_none=False, code=201) def post(self): - if "room_id" in api_virtual_command.payload: - if api_virtual_command.payload["room_id"] is None: - api_virtual_command.payload["room"] = None + pprint(api_virtual_command.payload) + room_id = api_virtual_command.payload.pop('recorder_id', None) + if room_id is None: + api_virtual_command.payload["room"] = None + else: + room = Room.query.get(room_id) + if room is not None: + api_virtual_command.payload["room"] = room else: - room = Room.query.get(api_virtual_command.payload["room_id"]) - if room is not None: - api_virtual_command.payload["room"] = room - else: - return "specified room (id: {}) does not exist!".format(api_virtual_command.payload["room_id"]), 404 - if "recorder_model_id" in api_virtual_command.payload: - if api_virtual_command.payload["recorder_model_id"] is None: - api_virtual_command.payload["recorder_model"] = None + return "specified room (id: {}) does not exist!".format(api_virtual_command.payload["room_id"]), 404 + recorder_model_id = api_virtual_command.payload.pop('recorder_model_id', None) + if recorder_model_id is None: + api_virtual_command.payload["recorder_model"] = None + else: + rec_model = RecorderModel.query.get(recorder_model_id) + if rec_model is not None: + api_virtual_command.payload["recorder_model"] = rec_model else: - rec_model = RecorderModel.query.get(api_virtual_command.payload["recorder_model_id"]) - if rec_model is not None: - api_virtual_command.payload["recorder_model"] = rec_model - else: - return "specified recorder model (id: {}) does not exist!".format( - api_virtual_command.payload["recorder_model_id"]), 404 - recorder = Recorder(**api_virtual_command.payload) - db.session.add(recorder) + return "specified recorder model (id: {}) does not exist!".format( + api_virtual_command.payload["recorder_model_id"]), 404 + recorder_id = api_virtual_command.payload.pop('recorder_id', None) + if recorder_id is None: + api_virtual_command.payload["recorder"] = None + else: + recorder = Recorder.query.get(recorder_id) + if recorder is not None: + api_virtual_command.payload["recorder"] = recorder + else: + return "specified recorder (id: {}) does not exist!".format( + api_virtual_command.payload["recorder_id"]), 404 + virtual_command = VirtualCommand(**api_virtual_command.payload) + db.session.add(virtual_command) db.session.commit() - return recorder + return virtual_command diff --git a/backend/config.py b/backend/config.py index 3525ee4..d2a63a9 100644 Binary files a/backend/config.py and b/backend/config.py differ diff --git a/backend/manage.py b/backend/manage.py index 21ff429..39b2118 100644 --- a/backend/manage.py +++ b/backend/manage.py @@ -1,5 +1,8 @@ #!/usr/bin/env python import os, sys + +from backend.models import Permission, Group + sys.path.append(os.path.join(os.path.dirname(__file__), os.path.pardir)) import os import unittest @@ -59,10 +62,30 @@ def cov(): return 1 +def insert_initial_groups(): + print("DB: inserting default groups:") + for g in app.config.get("GROUPS", []): + print(g['name']) + g_permissions = g.pop('permissions', []) + g['permissions'] = Permission.get_by_names(g_permissions) + db.session.add(Group(**g)) + db.session.commit() + + +@manager.command +def recreate_db(): + """Drops the db tables.""" + db.drop_all() + """Creates the db tables.""" + db.create_all() + insert_initial_groups() + + @manager.command def create_db(): """Creates the db tables.""" db.create_all() + insert_initial_groups() @manager.command diff --git a/backend/models/user_model.py b/backend/models/user_model.py index 47f9623..44f6c50 100644 --- a/backend/models/user_model.py +++ b/backend/models/user_model.py @@ -4,8 +4,9 @@ Example user model and related models """ import json +import sqlalchemy from sqlalchemy.orm import relation -from sqlalchemy import MetaData +from sqlalchemy import MetaData, any_ from backend import db, app, login_manager from backend.config import Config @@ -74,16 +75,16 @@ group_permission_table = db.Table('group_permission', # This is the association table for the many-to-many relationship between # users and permissions. user_permission_table = db.Table('user_permission', - db.Column('user_id', db.Integer, - db.ForeignKey('user.id', - onupdate="CASCADE", - ondelete="CASCADE"), - primary_key=True), - db.Column('permission_id', db.Integer, - db.ForeignKey('permission.id', - onupdate="CASCADE", - ondelete="CASCADE"), - primary_key=True)) + db.Column('user_id', db.Integer, + db.ForeignKey('user.id', + onupdate="CASCADE", + ondelete="CASCADE"), + primary_key=True), + db.Column('permission_id', db.Integer, + db.ForeignKey('permission.id', + onupdate="CASCADE", + ondelete="CASCADE"), + primary_key=True)) class User(UserMixin, db.Model): @@ -108,6 +109,7 @@ class User(UserMixin, db.Model): password = db.Column(db.String(255), nullable=True) registered_on = db.Column(db.DateTime, nullable=False, default=datetime.utcnow()) external_user = db.Column(db.Boolean, default=False) + external_user_id = db.Column(db.Unicode(63), unique=True, nullable=True, default=None) last_seen = db.Column(db.DateTime, default=datetime.utcnow()) last_time_modified = db.Column(db.DateTime, default=datetime.utcnow()) jwt_exp_delta_seconds = db.Column(db.Integer, nullable=True) @@ -200,6 +202,8 @@ class User(UserMixin, db.Model): return None user = cls.query.filter_by(email=email).first() + if not user: + user = cls.query.filter_by(nickname=email).first() # be nice and allow nickname as well... if not user or not user.verify_password(password): return None @@ -243,12 +247,10 @@ class User(UserMixin, db.Model): @property def effective_permissions(self): - permissions = Config.ROLE_PERMISSION_MAPPINGS.get(self.role, []) + permissions = Config.ROLE_PERMISSION_MAPPINGS.get(self.role, set()) for g in self.groups: - print(g) for p in g.permissions: - print(p) - permissions.append(p) + permissions.add(p) return permissions @staticmethod @@ -497,14 +499,13 @@ class Permission(db.Model): id = db.Column(db.Integer, autoincrement=True, primary_key=True) name = db.Column(db.Unicode(63), unique=True, nullable=False) description = db.Column(db.Unicode(511)) - #read_only = db.Column(db.Boolean, default=False) + # read_only = db.Column(db.Boolean, default=False) groups = db.relationship(Group, secondary=group_permission_table, back_populates='permissions') users = db.relationship(User, secondary=user_permission_table, - back_populates='permissions') + back_populates='permissions') access_control_entry = db.relationship('AccessControlEntry', back_populates='required_permission') - @staticmethod def get_by_name(name): """ @@ -514,6 +515,17 @@ class Permission(db.Model): """ return Permission.query.filter(Permission.name == name).first() + @staticmethod + def get_by_names(names: list): + """ + Find permissions by their names + :param names: + :return: + """ + if len(names) < 1: + return [] + return Permission.query.filter(or_(*[Permission.name.like(name) for name in names])).all() + @staticmethod def get_all(): """ @@ -522,22 +534,42 @@ class Permission(db.Model): """ return Permission.query.all() + +@event.listens_for(Permission.__table__, 'after_create') +def insert_initial_permissions(*args, **kwargs): + print("DB: inserting default permissions:") + for p in app.config.get("PERMISSIONS", []): + print(p) + db.session.add(Permission(name=p)) + db.session.commit() + # insert_initial_groups() # call this function here again, as often (always?) permission table does not yet exist + + @event.listens_for(User.__table__, 'after_create') def insert_initial_users(*args, **kwargs): + print("DB: inserting default users:") for u in app.config.get("USERS", []): db.session.add(User(**u)) db.session.commit() +# The following initialization does not work as it depends on the existence of multiple tables +# This initialization has now been moved to manage.py! +""" @event.listens_for(Group.__table__, 'after_create') def insert_initial_groups(*args, **kwargs): - for g in app.config.get("GROUPS", []): - db.session.add(Group(**g)) - db.session.commit() - - -@event.listens_for(Permission.__table__, 'after_create') -def insert_initial_permissions(*args, **kwargs): - for p in app.config.get("PERMISSIONS", []): - db.session.add(Permission(name=p)) - db.session.commit() + print("DB: inserting default groups:") + try: + for g in app.config.get("GROUPS", []): + print(g['name']) + g_permissions = g.pop('permissions', []) + g['permissions'] = Permission.get_by_names(g_permissions) + print(g['permissions']) + db.session.add(Group(**g)) + db.session.commit() + except sqlalchemy.exc.OperationalError as e: + first_error_line = str(e).split('\n')[0] + if "no such table" not in first_error_line: + raise + print(f"Permission table probably does not exist yet: {first_error_line} - you can probably ignore this!") +""" diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..f8ed480 --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,45 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[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 + +[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/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..9452179 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,96 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from flask import current_app +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix='sqlalchemy.', + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"}