From 2a40e0ccf1fecb1d6e05e905d24fb6ea0b2caca1 Mon Sep 17 00:00:00 2001 From: Tiago Garcia Date: Sat, 16 Nov 2024 22:59:54 +0000 Subject: [PATCH] User management Signed-off-by: Tiago Garcia --- delivery1/server/app.py | 32 +++-- delivery1/server/models/org.py | 11 +- delivery1/server/models/user.py | 6 +- delivery1/server/routes/file.py | 10 ++ delivery1/server/routes/user.py | 130 ++++++++++++++++++++ delivery1/server/services/orgs.py | 64 +++++++++- delivery1/server/services/sessions.py | 2 +- delivery1/server/services/users.py | 15 ++- delivery1/server/tests/user_management.http | 80 ++++++++++++ delivery1/server/utils/__init__.py | 1 + delivery1/server/utils/data_checks.py | 28 +++++ 11 files changed, 359 insertions(+), 20 deletions(-) create mode 100644 delivery1/server/tests/user_management.http create mode 100644 delivery1/server/utils/__init__.py create mode 100644 delivery1/server/utils/data_checks.py diff --git a/delivery1/server/app.py b/delivery1/server/app.py index 832351a..cb701d8 100644 --- a/delivery1/server/app.py +++ b/delivery1/server/app.py @@ -1,6 +1,7 @@ -from flask import Flask +import sqlalchemy.exc +from flask import Flask, request, jsonify from routes import org_bp, user_bp, file_bp -from database import db_connection +from database import db_connection, db from models import Organization, User, File, Session app = Flask(__name__) @@ -10,18 +11,35 @@ app.config["SQLALCHEMY_AUTOCOMMIT"] = False app.config["SQLALCHEMY_AUTOFLUSH"] = False db_connection.init_app(app) with app.app_context(): + try: + db_connection.session.query(Session).delete() + db_connection.session.commit() + except sqlalchemy.exc.OperationalError: + pass db_connection.create_all() app.register_blueprint(org_bp, url_prefix="/org") app.register_blueprint(user_bp, url_prefix="/user") app.register_blueprint(file_bp, url_prefix="/file") -@app.route("/reset") + +@app.route("/", methods=["GET"]) +def index(): + return jsonify({"message": "Welcome to the API"}), 200 + + +@app.route("/reset", methods=["POST"]) def reset(): - with app.app_context(): - db_connection.drop_all() - db_connection.create_all() - return "Database reset" + password = request.json["password"] + if password != "123": + return jsonify({"error": "Invalid password"}), 403 + try: + with app.app_context(): + db_connection.drop_all() + db_connection.create_all() + except sqlalchemy.exc.OperationalError: + return jsonify({"error": "Database error"}), 500 + return jsonify({"message": "Database reset"}), 200 if __name__ == "__main__": app.run(debug=True) \ No newline at end of file diff --git a/delivery1/server/models/org.py b/delivery1/server/models/org.py index b94609f..53c1adb 100644 --- a/delivery1/server/models/org.py +++ b/delivery1/server/models/org.py @@ -5,8 +5,9 @@ class Organization(db_connection.Model): id = db_connection.Column(db_connection.Integer, primary_key=True, index=True) name = db_connection.Column(db_connection.String, unique=True, index=True, nullable=False) - owner_id = db_connection.Column(db_connection.Integer, db_connection.ForeignKey('users.id'), nullable=False) - owner = db_connection.relationship('User', back_populates='orgs') + users = db_connection.Column(db_connection.JSON, nullable=False, default=dict) + owner_id = db_connection.Column(db_connection.Integer, db_connection.ForeignKey('users.id')) + owner = db_connection.relationship('User', backref=db_connection.backref('owned_organization', uselist=False)) files = db_connection.relationship('File', back_populates='org') def to_dict(self): @@ -14,5 +15,11 @@ class Organization(db_connection.Model): "id": self.id, "name": self.name, "owner": self.owner.to_dict(), + "users": [{"id": user_id, "user_data": { + "username": user_data["username"], + "full_name": user_data["full_name"], + "email": user_data["email"], + "status": user_data["status"] + }} for user_id, user_data in self.users.items()], "files": [{"id": file.id, "name": file.name, "file_handle": file.file_handle} for file in self.files] } \ No newline at end of file diff --git a/delivery1/server/models/user.py b/delivery1/server/models/user.py index 499856b..df03020 100644 --- a/delivery1/server/models/user.py +++ b/delivery1/server/models/user.py @@ -10,7 +10,8 @@ class User(db_connection.Model): full_name = db_connection.Column(db_connection.String, nullable=False) email = db_connection.Column(db_connection.String, unique=True, index=True, nullable=False) public_keys = db_connection.Column(db_connection.JSON, nullable=False, default=dict) - orgs = db_connection.relationship('Organization', back_populates='owner') + orgs = db_connection.Column(db_connection.JSON, nullable=False, default=dict) + files = db_connection.relationship('File', back_populates='creator') def to_dict(self): @@ -19,7 +20,6 @@ class User(db_connection.Model): "username": self.username, "full_name": self.full_name, "email": self.email, - "public_keys": [{"org_id": org_id, "key": public_key} for org_id, public_key in self.public_keys.items()], - "orgs": [{"id": org.id, "name": org.name} for org in self.orgs], + "orgs": [{"id": org_id, "name": org_data["name"], "status": org_data["status"]} for org_id, org_data in self.orgs.items()], "files": [{"id": file.id, "name": file.name, "file_handle": file.file_handle} for file in self.files] } \ No newline at end of file diff --git a/delivery1/server/routes/file.py b/delivery1/server/routes/file.py index 558ba0d..7373c46 100644 --- a/delivery1/server/routes/file.py +++ b/delivery1/server/routes/file.py @@ -2,3 +2,13 @@ from flask import Blueprint, request, jsonify from services import FileService file_bp = Blueprint("file", __name__) + +@file_bp.route("/get", methods=["GET"]) +def file_get(): + data = request.json + file_handle = data["file_handle"] + file = FileService.get_file_by_file_handle(file_handle) + if not file: + return jsonify({"error": "File not found"}), 404 + return jsonify(file.to_dict()), 200 + diff --git a/delivery1/server/routes/user.py b/delivery1/server/routes/user.py index 3df87ae..5c1c670 100644 --- a/delivery1/server/routes/user.py +++ b/delivery1/server/routes/user.py @@ -1,5 +1,7 @@ +import json from flask import Blueprint, request, jsonify from services import UserService, SessionService, OrganizationService +from utils import data_checks user_bp = Blueprint("user", __name__) @@ -23,3 +25,131 @@ def user_login(): session = SessionService.create_session(user, org) return jsonify(session.to_dict()), 201 + + +@user_bp.route("/logout", methods=["POST"]) +def user_logout(): + data = request.json + session_file = data["session_file"] + session_data = json.loads(session_file) + session_token = session_data["token"] + session = SessionService.get_session(session_token) + + if not session: + return jsonify({"error": "Not authenticated"}), 401 + + SessionService.delete_session(session) + return jsonify({"message": "Logged out"}), 200 + + +@user_bp.route("/list", methods=["GET"]) +def user_list(): + data = request.json + if "session_file" not in data: + return jsonify({"error": "No session file"}), 400 + + session_file = data["session_file"] + session_data = json.loads(session_file) + + session = data_checks.validate_session_file(session_data) + if isinstance(session, tuple): + return session + + org = OrganizationService.get_organization(session.org_id) + if not org: + return jsonify({"error": "Organization not found"}), 404 + + if "username" in data: + user = UserService.get_user_by_username(data["username"]) + if not user: + return jsonify({"error": "User not found"}), 404 + return jsonify(user.to_dict()), 200 + + users = OrganizationService.get_users_in_organization(org) + return jsonify(users), 200 + + +@user_bp.route("/create", methods=["POST"]) +def user_create(): + data = request.json + if "session_file" not in data or "username" not in data or "full_name" not in data or "email" not in data or "public_key" not in data: + return jsonify({"error": "Missing required fields"}), 400 + + session_file = data["session_file"] + session_data = json.loads(session_file) + + session = data_checks.validate_session_file(session_data) + if isinstance(session, tuple): + return session + + org = OrganizationService.get_organization(session.org_id) + if not org: + return jsonify({"error": "Organization not found"}), 404 + + if org.owner.id != session.user_id: + return jsonify({"error": "Not authorized to create users"}), 403 + + user = UserService.get_user_by_username(data["username"]) + if not user: + user = UserService.create_user( + username=data["username"], + full_name=data["full_name"], + email=data["email"], + public_key=data["public_key"], + org=org + ) + + return jsonify(user.to_dict()), 201 + + +@user_bp.route("/suspend", methods=["POST"]) +def user_suspend(): + data = request.json + if "session_file" not in data or "username" not in data: + return jsonify({"error": "Missing required fields"}), 400 + + session_file = data["session_file"] + session_data = json.loads(session_file) + + session = data_checks.validate_session_file(session_data) + if isinstance(session, tuple): + return session + + org = OrganizationService.get_organization(session.org_id) + if not org: + return jsonify({"error": "Organization not found"}), 404 + if org.owner.id != session.user_id: + return jsonify({"error": "Not authorized to suspend users"}), 403 + + user = UserService.get_user_by_username(data["username"]) + if not user: + return jsonify({"error": "User not found"}), 404 + + return OrganizationService.suspend_user(org, user) + + +@user_bp.route("/activate", methods=["POST"]) +def user_unsuspend(): + data = request.json + if "session_file" not in data or "username" not in data: + return jsonify({"error": "Missing required fields"}), 400 + + session_file = data["session_file"] + session_data = json.loads(session_file) + + session = data_checks.validate_session_file(session_data) + if isinstance(session, tuple): + return session + + org = OrganizationService.get_organization(session.org_id) + if not org: + return jsonify({"error": "Organization not found"}), 404 + if org.owner.id != session.user_id: + return jsonify({"error": "Not authorized to unsuspend users"}), 403 + + user = UserService.get_user_by_username(data["username"]) + if not user: + return jsonify({"error": "User not found"}), 404 + + return OrganizationService.activate_user(org, user) + diff --git a/delivery1/server/services/orgs.py b/delivery1/server/services/orgs.py index d6f1ea4..746b4e2 100644 --- a/delivery1/server/services/orgs.py +++ b/delivery1/server/services/orgs.py @@ -1,7 +1,8 @@ import os.path from database import db -from models import Organization +from models import Organization, User +from sqlalchemy.orm.attributes import flag_modified class OrganizationService: @@ -19,8 +20,13 @@ class OrganizationService: organization = Organization( name=name, - owner_id=user.id, - owner=user + owner=user, + users={user.id: { + "username": user.username, + "full_name": user.full_name, + "email": user.email, + "status": "active" + }} ) db.add(organization) @@ -32,6 +38,10 @@ class OrganizationService: return organization + @staticmethod + def list_organizations(): + return db.query(Organization).all() + @staticmethod def get_organization(org_id: int) -> Organization | None: return db.query(Organization).filter(Organization.id == org_id).first() @@ -41,5 +51,49 @@ class OrganizationService: return db.query(Organization).filter(Organization.name == name).first() @staticmethod - def list_organizations(): - return db.query(Organization).all() \ No newline at end of file + def get_users_in_organization(org: Organization) -> list[User]: + return db.query(Organization).filter(Organization.id == org.id).first().users + + @staticmethod + def get_user_status(org: Organization, user_id: int) -> str: + return db.query(Organization).filter(Organization.id == org.id).first().users[str(user_id)]["status"] + + @staticmethod + def add_user_to_organization(org: Organization, user: User) -> Organization: + org.users[str(user.id)] = { + "username": user.username, + "full_name": user.full_name, + "email": user.email, + "status": "active" + } + flag_modified(org, "users") + db.commit() + db.refresh(org) + return org + + @staticmethod + def suspend_user(org: Organization, user: User) -> tuple: + if OrganizationService.get_user_status(org, user.id) != "active": + return {"error": "User already suspended"}, 400 + + if org.owner.id == user.id: + return {"error": "Cannot suspend owner"}, 400 + + org.users[str(user.id)]["status"] = "suspended" + flag_modified(org, "users") + db.commit() + db.refresh(org) + + return {"message": "User suspended"}, 200 + + @staticmethod + def activate_user(org: Organization, user: User) -> tuple: + if OrganizationService.get_user_status(org, user.id) != "suspended": + return {"error": "User already active"}, 400 + + org.users[str(user.id)]["status"] = "active" + flag_modified(org, "users") + db.commit() + db.refresh(org) + + return {"message": "User activated"}, 200 diff --git a/delivery1/server/services/sessions.py b/delivery1/server/services/sessions.py index 797f2e3..9885e92 100644 --- a/delivery1/server/services/sessions.py +++ b/delivery1/server/services/sessions.py @@ -17,7 +17,7 @@ class SessionService: return session @staticmethod - def get_session_by_token(token: str) -> Session | None: + def get_session(token: str) -> Session | None: return db.query(Session).filter(Session.token == token).first() @staticmethod diff --git a/delivery1/server/services/users.py b/delivery1/server/services/users.py index 59f298a..2f11bca 100644 --- a/delivery1/server/services/users.py +++ b/delivery1/server/services/users.py @@ -5,16 +5,22 @@ from models import User, Organization class UserService: @staticmethod def create_user(username: str, full_name: str, email: str, public_key: str, org: Organization = None) -> User: + from services import OrganizationService user = User( username=username, full_name=full_name, email=email, public_keys={org.id: public_key} if org else {}, - orgs=[org] if org else [] + orgs={org.id: { + "name": org.name, + "status": "active" + }} if org else {} ) db.add(user) db.commit() db.refresh(user) + if org: + OrganizationService.add_user_to_organization(org, user) return user @staticmethod @@ -27,7 +33,12 @@ class UserService: @staticmethod def add_org_to_user(user: User, org: Organization) -> User: - user.orgs.append(org) + orgs = user.orgs.copy() + orgs[org.id] = { + "name": org.name, + "status": "active" + } + user.orgs = orgs db.commit() db.refresh(user) return user diff --git a/delivery1/server/tests/user_management.http b/delivery1/server/tests/user_management.http new file mode 100644 index 0000000..64f4e95 --- /dev/null +++ b/delivery1/server/tests/user_management.http @@ -0,0 +1,80 @@ +### Reset database +POST http://localhost:5000/reset +Content-Type: application/json + +{ + "password": "123" +} + +### Create a new organization +POST http://localhost:5000/org/create +Content-Type: application/json + +{ + "name": "org", + "username": "username", + "full_name": "Full Name", + "email": "user@mail.com", + "public_key": "null" +} + +### Login +POST http://localhost:5000/user/login +Content-Type: application/json + +{ + "username": "username", + "org": "org", + "public_key": "null" +} + +> {% client.global.set("token", response.body["token"]) %} + +### List organizations +GET http://localhost:5000/org/list + +### Create a new user +POST http://localhost:5000/user/create +Content-Type: application/json + +{ + "session_file": "{\"token\":\"{{token}}\"}", + "username": "newuser", + "full_name": "Full Name", + "email": "newuser@mail.com", + "public_key": "null2" +} + +### List users +GET http://localhost:5000/user/list +Content-Type: application/json + +{ + "session_file": "{\"token\":\"{{token}}\"}" +} + +### Suspend user +POST http://localhost:5000/user/suspend +Content-Type: application/json + +{ + "session_file": "{\"token\":\"{{token}}\"}", + "username": "newuser" +} + +### Activate user +POST http://localhost:5000/user/activate +Content-Type: application/json + +{ + "session_file": "{\"token\":\"{{token}}\"}", + "username": "newuser" +} + +### Logout +POST http://localhost:5000/user/logout +Content-Type: application/json + +{ + "session_file": "{\"token\":\"{{token}}\"}" +} \ No newline at end of file diff --git a/delivery1/server/utils/__init__.py b/delivery1/server/utils/__init__.py new file mode 100644 index 0000000..5edd78f --- /dev/null +++ b/delivery1/server/utils/__init__.py @@ -0,0 +1 @@ +from .data_checks import validate_session_file \ No newline at end of file diff --git a/delivery1/server/utils/data_checks.py b/delivery1/server/utils/data_checks.py new file mode 100644 index 0000000..7f31068 --- /dev/null +++ b/delivery1/server/utils/data_checks.py @@ -0,0 +1,28 @@ +import json +from flask import jsonify +from services import SessionService, OrganizationService +from models import Session + +def validate_session_file(data) -> tuple | Session: + """ + Check if the session file is valid, and return the session object if it is + :param data: session file data (json) + :return: Session object or error response + """ + if "token" not in data: + return jsonify({"error": "No session token"}), 400 + session_token = data["token"] + + session = SessionService.get_session(session_token) + if not session: + return jsonify({"error": "Not authenticated"}), 401 + + org = OrganizationService.get_organization(session.org_id) + if not org: + return jsonify({"error": "Organization not found"}), 404 + + status = OrganizationService.get_user_status(org, session.user_id) + if status != "active": + return jsonify({"error": "User is not active"}), 403 + + return session \ No newline at end of file