diff --git a/gatehouse_app/api/v1/departments.py b/gatehouse_app/api/v1/departments.py new file mode 100644 index 0000000..155e465 --- /dev/null +++ b/gatehouse_app/api/v1/departments.py @@ -0,0 +1,522 @@ +"""Department endpoints.""" +from flask import g, request +from marshmallow import Schema, fields, validate, ValidationError + +from gatehouse_app.api.v1 import api_v1_bp +from gatehouse_app.utils.response import api_response +from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required +from gatehouse_app.models import Department, DepartmentMembership +from gatehouse_app.services.organization_service import OrganizationService +from gatehouse_app.services.user_service import UserService +from gatehouse_app.extensions import db + + +class DepartmentCreateSchema(Schema): + """Schema for creating a department.""" + name = fields.Str(required=True, validate=validate.Length(min=1, max=255)) + description = fields.Str(allow_none=True, validate=validate.Length(max=2000)) + + +class DepartmentUpdateSchema(Schema): + """Schema for updating a department.""" + name = fields.Str(validate=validate.Length(min=1, max=255)) + description = fields.Str(allow_none=True, validate=validate.Length(max=2000)) + + +class AddDepartmentMemberSchema(Schema): + """Schema for adding a member to a department.""" + email = fields.Email(required=True) + + +@api_v1_bp.route("/organizations//departments", methods=["GET"]) +@login_required +@full_access_required +def list_departments(org_id): + """ + List all departments in an organization. + + Args: + org_id: Organization ID + + Returns: + 200: List of departments + 401: Not authenticated + 403: Not a member + 404: Organization not found + """ + org = OrganizationService.get_organization_by_id(org_id) + + # Check if user is a member + if not org.is_member(g.current_user.id): + return api_response( + success=False, + message="You are not a member of this organization", + status=403, + error_type="AUTHORIZATION_ERROR", + ) + + departments = Department.query.filter_by( + organization_id=org_id, + deleted_at=None + ).all() + + return api_response( + data={ + "departments": [d.to_dict() for d in departments], + "count": len(departments), + }, + message="Departments retrieved successfully", + ) + + +@api_v1_bp.route("/organizations//departments", methods=["POST"]) +@login_required +@require_admin +@full_access_required +def create_department(org_id): + """ + Create a new department. + + Args: + org_id: Organization ID + + Request body: + name: Department name (required) + description: Optional description + + Returns: + 201: Department created successfully + 400: Validation error + 401: Not authenticated + 403: Not an admin + 404: Organization not found + 409: Department name already exists in org + """ + try: + org = OrganizationService.get_organization_by_id(org_id) + + schema = DepartmentCreateSchema() + data = schema.load(request.json or {}) + + # Check if department name already exists + existing = Department.query.filter_by( + organization_id=org_id, + name=data["name"], + deleted_at=None + ).first() + + if existing: + return api_response( + success=False, + message=f"Department '{data['name']}' already exists in this organization", + status=409, + error_type="CONFLICT", + ) + + # Create department + dept = Department( + organization_id=org_id, + name=data["name"], + description=data.get("description"), + ) + db.session.add(dept) + db.session.commit() + + return api_response( + data={"department": dept.to_dict()}, + message="Department created successfully", + status=201, + ) + + except ValidationError as e: + return api_response( + success=False, + message="Validation failed", + status=400, + error_type="VALIDATION_ERROR", + error_details=e.messages, + ) + + +@api_v1_bp.route("/organizations//departments/", methods=["GET"]) +@login_required +@full_access_required +def get_department(org_id, dept_id): + """ + Get a specific department. + + Args: + org_id: Organization ID + dept_id: Department ID + + Returns: + 200: Department data + 401: Not authenticated + 403: Not a member + 404: Organization or department not found + """ + org = OrganizationService.get_organization_by_id(org_id) + + if not org.is_member(g.current_user.id): + return api_response( + success=False, + message="You are not a member of this organization", + status=403, + error_type="AUTHORIZATION_ERROR", + ) + + dept = Department.query.filter_by( + id=dept_id, + organization_id=org_id, + deleted_at=None + ).first() + + if not dept: + return api_response( + success=False, + message="Department not found", + status=404, + error_type="NOT_FOUND", + ) + + return api_response( + data={"department": dept.to_dict()}, + message="Department retrieved successfully", + ) + + +@api_v1_bp.route("/organizations//departments/", methods=["PATCH"]) +@login_required +@require_admin +@full_access_required +def update_department(org_id, dept_id): + """ + Update a department. + + Args: + org_id: Organization ID + dept_id: Department ID + + Request body: + name: Optional new name + description: Optional new description + + Returns: + 200: Department updated successfully + 400: Validation error + 401: Not authenticated + 403: Not an admin + 404: Organization or department not found + 409: Name already exists + """ + try: + org = OrganizationService.get_organization_by_id(org_id) + + dept = Department.query.filter_by( + id=dept_id, + organization_id=org_id, + deleted_at=None + ).first() + + if not dept: + return api_response( + success=False, + message="Department not found", + status=404, + error_type="NOT_FOUND", + ) + + schema = DepartmentUpdateSchema() + data = schema.load(request.json or {}) + + # Check if new name already exists + if "name" in data and data["name"] != dept.name: + existing = Department.query.filter_by( + organization_id=org_id, + name=data["name"], + deleted_at=None + ).first() + if existing: + return api_response( + success=False, + message=f"Department '{data['name']}' already exists", + status=409, + error_type="CONFLICT", + ) + + # Update fields + for key, value in data.items(): + setattr(dept, key, value) + + db.session.commit() + + return api_response( + data={"department": dept.to_dict()}, + message="Department updated successfully", + ) + + except ValidationError as e: + return api_response( + success=False, + message="Validation failed", + status=400, + error_type="VALIDATION_ERROR", + error_details=e.messages, + ) + + +@api_v1_bp.route("/organizations//departments/", methods=["DELETE"]) +@login_required +@require_admin +@full_access_required +def delete_department(org_id, dept_id): + """ + Delete a department (soft delete). + + Args: + org_id: Organization ID + dept_id: Department ID + + Returns: + 200: Department deleted successfully + 401: Not authenticated + 403: Not an admin + 404: Organization or department not found + """ + org = OrganizationService.get_organization_by_id(org_id) + + dept = Department.query.filter_by( + id=dept_id, + organization_id=org_id, + deleted_at=None + ).first() + + if not dept: + return api_response( + success=False, + message="Department not found", + status=404, + error_type="NOT_FOUND", + ) + + # Soft delete + dept.deleted_at = db.func.now() + db.session.commit() + + return api_response( + message="Department deleted successfully", + ) + + +@api_v1_bp.route("/organizations//departments//members", methods=["GET"]) +@login_required +@full_access_required +def get_department_members(org_id, dept_id): + """ + Get all members of a department. + + Args: + org_id: Organization ID + dept_id: Department ID + + Returns: + 200: List of members + 401: Not authenticated + 403: Not a member + 404: Organization or department not found + """ + org = OrganizationService.get_organization_by_id(org_id) + + if not org.is_member(g.current_user.id): + return api_response( + success=False, + message="You are not a member of this organization", + status=403, + error_type="AUTHORIZATION_ERROR", + ) + + dept = Department.query.filter_by( + id=dept_id, + organization_id=org_id, + deleted_at=None + ).first() + + if not dept: + return api_response( + success=False, + message="Department not found", + status=404, + error_type="NOT_FOUND", + ) + + members = DepartmentMembership.query.filter_by( + department_id=dept_id, + deleted_at=None + ).all() + + members_data = [] + for member in members: + member_dict = member.to_dict() + member_dict["user"] = member.user.to_dict() + members_data.append(member_dict) + + return api_response( + data={ + "members": members_data, + "count": len(members_data), + }, + message="Members retrieved successfully", + ) + + +@api_v1_bp.route("/organizations//departments//members", methods=["POST"]) +@login_required +@require_admin +@full_access_required +def add_department_member(org_id, dept_id): + """ + Add a member to a department. + + Args: + org_id: Organization ID + dept_id: Department ID + + Request body: + email: User email to add + + Returns: + 201: Member added successfully + 400: Validation error + 401: Not authenticated + 403: Not an admin + 404: Organization, department, or user not found + 409: User already a member + """ + try: + org = OrganizationService.get_organization_by_id(org_id) + + dept = Department.query.filter_by( + id=dept_id, + organization_id=org_id, + deleted_at=None + ).first() + + if not dept: + return api_response( + success=False, + message="Department not found", + status=404, + error_type="NOT_FOUND", + ) + + schema = AddDepartmentMemberSchema() + data = schema.load(request.json or {}) + + # Find user by email + user = UserService.get_user_by_email(data["email"]) + if not user: + return api_response( + success=False, + message="User not found", + status=404, + error_type="NOT_FOUND", + ) + + # Check if already a member + existing = DepartmentMembership.query.filter_by( + user_id=user.id, + department_id=dept_id, + deleted_at=None + ).first() + + if existing: + return api_response( + success=False, + message="User is already a member of this department", + status=409, + error_type="CONFLICT", + ) + + # Add member + membership = DepartmentMembership( + user_id=user.id, + department_id=dept_id, + ) + db.session.add(membership) + db.session.commit() + + member_dict = membership.to_dict() + member_dict["user"] = user.to_dict() + + return api_response( + data={"member": member_dict}, + message="Member added successfully", + status=201, + ) + + except ValidationError as e: + return api_response( + success=False, + message="Validation failed", + status=400, + error_type="VALIDATION_ERROR", + error_details=e.messages, + ) + + +@api_v1_bp.route("/organizations//departments//members/", methods=["DELETE"]) +@login_required +@require_admin +@full_access_required +def remove_department_member(org_id, dept_id, user_id): + """ + Remove a member from a department. + + Args: + org_id: Organization ID + dept_id: Department ID + user_id: User ID to remove + + Returns: + 200: Member removed successfully + 401: Not authenticated + 403: Not an admin + 404: Organization, department, or member not found + """ + org = OrganizationService.get_organization_by_id(org_id) + + dept = Department.query.filter_by( + id=dept_id, + organization_id=org_id, + deleted_at=None + ).first() + + if not dept: + return api_response( + success=False, + message="Department not found", + status=404, + error_type="NOT_FOUND", + ) + + membership = DepartmentMembership.query.filter_by( + user_id=user_id, + department_id=dept_id, + deleted_at=None + ).first() + + if not membership: + return api_response( + success=False, + message="User is not a member of this department", + status=404, + error_type="NOT_FOUND", + ) + + # Soft delete + membership.deleted_at = db.func.now() + db.session.commit() + + return api_response( + message="Member removed successfully", + ) diff --git a/gatehouse_app/api/v1/organizations.py b/gatehouse_app/api/v1/organizations.py index 98c39e1..8f4f6cb 100644 --- a/gatehouse_app/api/v1/organizations.py +++ b/gatehouse_app/api/v1/organizations.py @@ -14,7 +14,7 @@ from gatehouse_app.services.organization_service import OrganizationService from gatehouse_app.services.user_service import UserService from gatehouse_app.utils.constants import OrganizationRole - +########jb- need to implement departs, principals @api_v1_bp.route("/organizations", methods=["POST"]) @login_required @full_access_required diff --git a/gatehouse_app/api/v1/principals.py b/gatehouse_app/api/v1/principals.py new file mode 100644 index 0000000..11f3332 --- /dev/null +++ b/gatehouse_app/api/v1/principals.py @@ -0,0 +1,745 @@ +"""Principal endpoints.""" +from flask import g, request +from marshmallow import Schema, fields, validate, ValidationError + +from gatehouse_app.api.v1 import api_v1_bp +from gatehouse_app.utils.response import api_response +from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required +from gatehouse_app.models import Principal, PrincipalMembership, Department, DepartmentPrincipal +from gatehouse_app.services.organization_service import OrganizationService +from gatehouse_app.services.user_service import UserService +from gatehouse_app.extensions import db + + +class PrincipalCreateSchema(Schema): + """Schema for creating a principal.""" + name = fields.Str(required=True, validate=validate.Length(min=1, max=255)) + description = fields.Str(allow_none=True, validate=validate.Length(max=2000)) + + +class PrincipalUpdateSchema(Schema): + """Schema for updating a principal.""" + name = fields.Str(validate=validate.Length(min=1, max=255)) + description = fields.Str(allow_none=True, validate=validate.Length(max=2000)) + + +class AddPrincipalMemberSchema(Schema): + """Schema for adding a member to a principal.""" + email = fields.Email(required=True) + + +class LinkPrincipalSchema(Schema): + """Schema for linking principal to department.""" + department_id = fields.Str(required=True) + + +@api_v1_bp.route("/organizations//principals", methods=["GET"]) +@login_required +@full_access_required +def list_principals(org_id): + """ + List all principals in an organization. + + Args: + org_id: Organization ID + + Returns: + 200: List of principals + 401: Not authenticated + 403: Not a member + 404: Organization not found + """ + org = OrganizationService.get_organization_by_id(org_id) + + if not org.is_member(g.current_user.id): + return api_response( + success=False, + message="You are not a member of this organization", + status=403, + error_type="AUTHORIZATION_ERROR", + ) + + principals = Principal.query.filter_by( + organization_id=org_id, + deleted_at=None + ).all() + + return api_response( + data={ + "principals": [p.to_dict() for p in principals], + "count": len(principals), + }, + message="Principals retrieved successfully", + ) + + +@api_v1_bp.route("/organizations//principals", methods=["POST"]) +@login_required +@require_admin +@full_access_required +def create_principal(org_id): + """ + Create a new principal. + + Args: + org_id: Organization ID + + Request body: + name: Principal name (required) + description: Optional description + + Returns: + 201: Principal created successfully + 400: Validation error + 401: Not authenticated + 403: Not an admin + 404: Organization not found + 409: Principal name already exists + """ + try: + org = OrganizationService.get_organization_by_id(org_id) + + schema = PrincipalCreateSchema() + data = schema.load(request.json or {}) + + # Check if principal name already exists + existing = Principal.query.filter_by( + organization_id=org_id, + name=data["name"], + deleted_at=None + ).first() + + if existing: + return api_response( + success=False, + message=f"Principal '{data['name']}' already exists", + status=409, + error_type="CONFLICT", + ) + + # Create principal + principal = Principal( + organization_id=org_id, + name=data["name"], + description=data.get("description"), + ) + db.session.add(principal) + db.session.commit() + + return api_response( + data={"principal": principal.to_dict()}, + message="Principal created successfully", + status=201, + ) + + except ValidationError as e: + return api_response( + success=False, + message="Validation failed", + status=400, + error_type="VALIDATION_ERROR", + error_details=e.messages, + ) + + +@api_v1_bp.route("/organizations//principals/", methods=["GET"]) +@login_required +@full_access_required +def get_principal(org_id, principal_id): + """ + Get a specific principal. + + Args: + org_id: Organization ID + principal_id: Principal ID + + Returns: + 200: Principal data + 401: Not authenticated + 403: Not a member + 404: Organization or principal not found + """ + org = OrganizationService.get_organization_by_id(org_id) + + if not org.is_member(g.current_user.id): + return api_response( + success=False, + message="You are not a member of this organization", + status=403, + error_type="AUTHORIZATION_ERROR", + ) + + principal = Principal.query.filter_by( + id=principal_id, + organization_id=org_id, + deleted_at=None + ).first() + + if not principal: + return api_response( + success=False, + message="Principal not found", + status=404, + error_type="NOT_FOUND", + ) + + return api_response( + data={"principal": principal.to_dict()}, + message="Principal retrieved successfully", + ) + + +@api_v1_bp.route("/organizations//principals/", methods=["PATCH"]) +@login_required +@require_admin +@full_access_required +def update_principal(org_id, principal_id): + """ + Update a principal. + + Args: + org_id: Organization ID + principal_id: Principal ID + + Request body: + name: Optional new name + description: Optional new description + + Returns: + 200: Principal updated successfully + 400: Validation error + 401: Not authenticated + 403: Not an admin + 404: Organization or principal not found + 409: Name already exists + """ + try: + org = OrganizationService.get_organization_by_id(org_id) + + principal = Principal.query.filter_by( + id=principal_id, + organization_id=org_id, + deleted_at=None + ).first() + + if not principal: + return api_response( + success=False, + message="Principal not found", + status=404, + error_type="NOT_FOUND", + ) + + schema = PrincipalUpdateSchema() + data = schema.load(request.json or {}) + + # Check if new name already exists + if "name" in data and data["name"] != principal.name: + existing = Principal.query.filter_by( + organization_id=org_id, + name=data["name"], + deleted_at=None + ).first() + if existing: + return api_response( + success=False, + message=f"Principal '{data['name']}' already exists", + status=409, + error_type="CONFLICT", + ) + + # Update fields + for key, value in data.items(): + setattr(principal, key, value) + + db.session.commit() + + return api_response( + data={"principal": principal.to_dict()}, + message="Principal updated successfully", + ) + + except ValidationError as e: + return api_response( + success=False, + message="Validation failed", + status=400, + error_type="VALIDATION_ERROR", + error_details=e.messages, + ) + + +@api_v1_bp.route("/organizations//principals/", methods=["DELETE"]) +@login_required +@require_admin +@full_access_required +def delete_principal(org_id, principal_id): + """ + Delete a principal (soft delete). + + Args: + org_id: Organization ID + principal_id: Principal ID + + Returns: + 200: Principal deleted successfully + 401: Not authenticated + 403: Not an admin + 404: Organization or principal not found + """ + org = OrganizationService.get_organization_by_id(org_id) + + principal = Principal.query.filter_by( + id=principal_id, + organization_id=org_id, + deleted_at=None + ).first() + + if not principal: + return api_response( + success=False, + message="Principal not found", + status=404, + error_type="NOT_FOUND", + ) + + # Soft delete + principal.deleted_at = db.func.now() + db.session.commit() + + return api_response( + message="Principal deleted successfully", + ) + + +@api_v1_bp.route("/organizations//principals//members", methods=["GET"]) +@login_required +@full_access_required +def get_principal_members(org_id, principal_id): + """ + Get all members (direct + via department) with access to a principal. + + Args: + org_id: Organization ID + principal_id: Principal ID + + Returns: + 200: List of members + 401: Not authenticated + 403: Not a member + 404: Organization or principal not found + """ + org = OrganizationService.get_organization_by_id(org_id) + + if not org.is_member(g.current_user.id): + return api_response( + success=False, + message="You are not a member of this organization", + status=403, + error_type="AUTHORIZATION_ERROR", + ) + + principal = Principal.query.filter_by( + id=principal_id, + organization_id=org_id, + deleted_at=None + ).first() + + if not principal: + return api_response( + success=False, + message="Principal not found", + status=404, + error_type="NOT_FOUND", + ) + + # Get direct members + direct_members = PrincipalMembership.query.filter_by( + principal_id=principal_id, + deleted_at=None + ).all() + + all_users = set() + for membership in direct_members: + if membership.user.deleted_at is None: + all_users.add(membership.user) + + # Get members via departments + dept_links = DepartmentPrincipal.query.filter_by( + principal_id=principal_id, + deleted_at=None + ).all() + + for link in dept_links: + dept = link.department + if dept.deleted_at is None: + dept_members = dept.get_members(active_only=True) + for dept_member in dept_members: + if dept_member.user.deleted_at is None: + all_users.add(dept_member.user) + + users_data = [u.to_dict() for u in all_users] + + return api_response( + data={ + "members": users_data, + "count": len(users_data), + }, + message="Members retrieved successfully", + ) + + +@api_v1_bp.route("/organizations//principals//members", methods=["POST"]) +@login_required +@require_admin +@full_access_required +def add_principal_member(org_id, principal_id): + """ + Add a direct member to a principal. + + Args: + org_id: Organization ID + principal_id: Principal ID + + Request body: + email: User email to add + + Returns: + 201: Member added successfully + 400: Validation error + 401: Not authenticated + 403: Not an admin + 404: Organization, principal, or user not found + 409: User already a member + """ + try: + org = OrganizationService.get_organization_by_id(org_id) + + principal = Principal.query.filter_by( + id=principal_id, + organization_id=org_id, + deleted_at=None + ).first() + + if not principal: + return api_response( + success=False, + message="Principal not found", + status=404, + error_type="NOT_FOUND", + ) + + schema = AddPrincipalMemberSchema() + data = schema.load(request.json or {}) + + # Find user by email + user = UserService.get_user_by_email(data["email"]) + if not user: + return api_response( + success=False, + message="User not found", + status=404, + error_type="NOT_FOUND", + ) + + # Check if already a member + existing = PrincipalMembership.query.filter_by( + user_id=user.id, + principal_id=principal_id, + deleted_at=None + ).first() + + if existing: + return api_response( + success=False, + message="User is already a member of this principal", + status=409, + error_type="CONFLICT", + ) + + # Add member + membership = PrincipalMembership( + user_id=user.id, + principal_id=principal_id, + ) + db.session.add(membership) + db.session.commit() + + member_dict = membership.to_dict() + member_dict["user"] = user.to_dict() + + return api_response( + data={"member": member_dict}, + message="Member added successfully", + status=201, + ) + + except ValidationError as e: + return api_response( + success=False, + message="Validation failed", + status=400, + error_type="VALIDATION_ERROR", + error_details=e.messages, + ) + + +@api_v1_bp.route("/organizations//principals//members/", methods=["DELETE"]) +@login_required +@require_admin +@full_access_required +def remove_principal_member(org_id, principal_id, user_id): + """ + Remove a direct member from a principal. + + Args: + org_id: Organization ID + principal_id: Principal ID + user_id: User ID to remove + + Returns: + 200: Member removed successfully + 401: Not authenticated + 403: Not an admin + 404: Organization, principal, or member not found + """ + org = OrganizationService.get_organization_by_id(org_id) + + principal = Principal.query.filter_by( + id=principal_id, + organization_id=org_id, + deleted_at=None + ).first() + + if not principal: + return api_response( + success=False, + message="Principal not found", + status=404, + error_type="NOT_FOUND", + ) + + membership = PrincipalMembership.query.filter_by( + user_id=user_id, + principal_id=principal_id, + deleted_at=None + ).first() + + if not membership: + return api_response( + success=False, + message="User is not a member of this principal", + status=404, + error_type="NOT_FOUND", + ) + + # Soft delete + membership.deleted_at = db.func.now() + db.session.commit() + + return api_response( + message="Member removed successfully", + ) + + +@api_v1_bp.route("/organizations//principals//departments", methods=["GET"]) +@login_required +@full_access_required +def get_principal_departments(org_id, principal_id): + """ + Get all departments this principal is assigned to. + + Args: + org_id: Organization ID + principal_id: Principal ID + + Returns: + 200: List of departments + 401: Not authenticated + 403: Not a member + 404: Organization or principal not found + """ + org = OrganizationService.get_organization_by_id(org_id) + + if not org.is_member(g.current_user.id): + return api_response( + success=False, + message="You are not a member of this organization", + status=403, + error_type="AUTHORIZATION_ERROR", + ) + + principal = Principal.query.filter_by( + id=principal_id, + organization_id=org_id, + deleted_at=None + ).first() + + if not principal: + return api_response( + success=False, + message="Principal not found", + status=404, + error_type="NOT_FOUND", + ) + + depts = principal.get_departments(active_only=True) + + return api_response( + data={ + "departments": [d.to_dict() for d in depts], + "count": len(depts), + }, + message="Departments retrieved successfully", + ) + + +@api_v1_bp.route("/organizations//principals//departments/", methods=["POST"]) +@login_required +@require_admin +@full_access_required +def link_principal_to_department(org_id, principal_id, dept_id): + """ + Link a principal to a department. + + Args: + org_id: Organization ID + principal_id: Principal ID + dept_id: Department ID + + Returns: + 201: Principal linked successfully + 401: Not authenticated + 403: Not an admin + 404: Organization, principal, or department not found + 409: Already linked + """ + org = OrganizationService.get_organization_by_id(org_id) + + principal = Principal.query.filter_by( + id=principal_id, + organization_id=org_id, + deleted_at=None + ).first() + + if not principal: + return api_response( + success=False, + message="Principal not found", + status=404, + error_type="NOT_FOUND", + ) + + dept = Department.query.filter_by( + id=dept_id, + organization_id=org_id, + deleted_at=None + ).first() + + if not dept: + return api_response( + success=False, + message="Department not found", + status=404, + error_type="NOT_FOUND", + ) + + # Check if already linked + existing = DepartmentPrincipal.query.filter_by( + department_id=dept_id, + principal_id=principal_id, + deleted_at=None + ).first() + + if existing: + return api_response( + success=False, + message="Principal is already linked to this department", + status=409, + error_type="CONFLICT", + ) + + # Create link + link = DepartmentPrincipal( + department_id=dept_id, + principal_id=principal_id, + ) + db.session.add(link) + db.session.commit() + + return api_response( + message="Principal linked to department successfully", + status=201, + ) + + +@api_v1_bp.route("/organizations//principals//departments/", methods=["DELETE"]) +@login_required +@require_admin +@full_access_required +def unlink_principal_from_department(org_id, principal_id, dept_id): + """ + Unlink a principal from a department. + + Args: + org_id: Organization ID + principal_id: Principal ID + dept_id: Department ID + + Returns: + 200: Principal unlinked successfully + 401: Not authenticated + 403: Not an admin + 404: Organization, principal, department, or link not found + """ + org = OrganizationService.get_organization_by_id(org_id) + + principal = Principal.query.filter_by( + id=principal_id, + organization_id=org_id, + deleted_at=None + ).first() + + if not principal: + return api_response( + success=False, + message="Principal not found", + status=404, + error_type="NOT_FOUND", + ) + + dept = Department.query.filter_by( + id=dept_id, + organization_id=org_id, + deleted_at=None + ).first() + + if not dept: + return api_response( + success=False, + message="Department not found", + status=404, + error_type="NOT_FOUND", + ) + + link = DepartmentPrincipal.query.filter_by( + department_id=dept_id, + principal_id=principal_id, + deleted_at=None + ).first() + + if not link: + return api_response( + success=False, + message="Principal is not linked to this department", + status=404, + error_type="NOT_FOUND", + ) + + # Soft delete + link.deleted_at = db.func.now() + db.session.commit() + + return api_response( + message="Principal unlinked from department successfully", + ) diff --git a/gatehouse_app/models/__init__.py b/gatehouse_app/models/__init__.py index 99ef6fb..71ba304 100644 --- a/gatehouse_app/models/__init__.py +++ b/gatehouse_app/models/__init__.py @@ -20,6 +20,15 @@ from gatehouse_app.models.oidc_audit_log import OIDCAuditLog from gatehouse_app.models.organization_security_policy import OrganizationSecurityPolicy from gatehouse_app.models.user_security_policy import UserSecurityPolicy from gatehouse_app.models.mfa_policy_compliance import MfaPolicyCompliance +from gatehouse_app.models.department import ( + Department, + DepartmentMembership, + DepartmentPrincipal, +) +from gatehouse_app.models.principal import ( + Principal, + PrincipalMembership, +) __all__ = [ "BaseModel", @@ -41,4 +50,9 @@ __all__ = [ "OrganizationSecurityPolicy", "UserSecurityPolicy", "MfaPolicyCompliance", + "Department", + "DepartmentMembership", + "DepartmentPrincipal", + "Principal", + "PrincipalMembership", ] diff --git a/gatehouse_app/models/department.py b/gatehouse_app/models/department.py new file mode 100644 index 0000000..30d1a0f --- /dev/null +++ b/gatehouse_app/models/department.py @@ -0,0 +1,194 @@ +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel + + +class Department(BaseModel): + """Department model representing an organizational unit for SSH access control. + + Departments are used to group users and assign SSH principals (access levels) + to them. A user can be a member of multiple departments, and each department + can have multiple principals assigned. + + Example: + - Department: "Engineering" + - Members: user1@example.com, user2@example.com + - Principals: "eng-prod", "eng-staging" + - Users get access based on their principal assignments + """ + + __tablename__ = "departments" + + organization_id = db.Column( + db.String(36), + db.ForeignKey("organizations.id"), + nullable=False, + index=True, + ) + name = db.Column(db.String(255), nullable=False, index=True) + description = db.Column(db.Text, nullable=True) + + # Relationships + organization = db.relationship("Organization", back_populates="departments") + memberships = db.relationship( + "DepartmentMembership", + back_populates="department", + cascade="all, delete-orphan", + ) + principal_links = db.relationship( + "DepartmentPrincipal", + back_populates="department", + cascade="all, delete-orphan", + ) + + # Unique constraint: department name per organization + __table_args__ = ( + db.UniqueConstraint( + "organization_id", "name", name="uix_org_dept_name" + ), + ) + + def __repr__(self): + """String representation of Department.""" + return f"" + + def to_dict(self, exclude=None): + """Convert department to dictionary.""" + exclude = exclude or [] + data = super().to_dict(exclude=exclude) + + # Add member count + data["member_count"] = len([m for m in self.memberships if m.deleted_at is None]) + + # Add principal count + data["principal_count"] = len([p for p in self.principal_links if p.deleted_at is None]) + + return data + + def get_members(self, active_only=True): + """Get all members of this department. + + Args: + active_only: If True, exclude soft-deleted members + + Returns: + List of DepartmentMembership objects + """ + if active_only: + return [m for m in self.memberships if m.deleted_at is None] + return self.memberships + + def get_principals(self, active_only=True): + """Get all principals assigned to this department. + + Args: + active_only: If True, exclude soft-deleted principals + + Returns: + List of Principal objects via DepartmentPrincipal + """ + if active_only: + return [p.principal for p in self.principal_links if p.deleted_at is None and p.principal.deleted_at is None] + return [p.principal for p in self.principal_links] + + def is_member(self, user_id): + """Check if a user is a member of this department. + + Args: + user_id: ID of the user to check + + Returns: + True if user is an active member, False otherwise + """ + return ( + DepartmentMembership.query.filter_by( + user_id=user_id, + department_id=self.id, + deleted_at=None, + ).first() + is not None + ) + + def get_member_count(self): + """Get the count of active members in this department.""" + return len(self.get_members(active_only=True)) + + +class DepartmentMembership(BaseModel): + """Department membership model representing user membership in a department. + + When a user is added to a department, they become eligible for SSH principals + assigned to that department. + """ + + __tablename__ = "department_memberships" + + user_id = db.Column( + db.String(36), + db.ForeignKey("users.id"), + nullable=False, + index=True, + ) + department_id = db.Column( + db.String(36), + db.ForeignKey("departments.id"), + nullable=False, + index=True, + ) + + # Relationships + user = db.relationship("User", back_populates="department_memberships") + department = db.relationship("Department", back_populates="memberships") + + # Unique constraint: user can only be member of a department once + __table_args__ = ( + db.UniqueConstraint( + "user_id", "department_id", name="uix_user_dept" + ), + ) + + def __repr__(self): + """String representation of DepartmentMembership.""" + return f"" + + +class DepartmentPrincipal(BaseModel): + """Department principal assignment model. + + Represents the assignment of principals to departments. All members of a + department get access to its assigned principals (transitively). + + Example: + - Department: "Engineering" + - Principal: "eng-prod-servers" + - All engineering department members can SSH as "eng-prod-servers" + """ + + __tablename__ = "department_principals" + + department_id = db.Column( + db.String(36), + db.ForeignKey("departments.id"), + nullable=False, + index=True, + ) + principal_id = db.Column( + db.String(36), + db.ForeignKey("principals.id"), + nullable=False, + index=True, + ) + + # Relationships + department = db.relationship("Department", back_populates="principal_links") + principal = db.relationship("Principal", back_populates="department_links") + + # Unique constraint: principal can only be assigned to a department once + __table_args__ = ( + db.UniqueConstraint( + "department_id", "principal_id", name="uix_dept_principal" + ), + ) + + def __repr__(self): + """String representation of DepartmentPrincipal.""" + return f"" diff --git a/gatehouse_app/models/organization.py b/gatehouse_app/models/organization.py index 1c4170f..cf7c110 100644 --- a/gatehouse_app/models/organization.py +++ b/gatehouse_app/models/organization.py @@ -34,6 +34,12 @@ class Organization(BaseModel): cascade="all, delete-orphan", foreign_keys="OrganizationSecurityPolicy.organization_id", ) + departments = db.relationship( + "Department", back_populates="organization", cascade="all, delete-orphan" + ) + principals = db.relationship( + "Principal", back_populates="organization", cascade="all, delete-orphan" + ) def __repr__(self): """String representation of Organization.""" diff --git a/gatehouse_app/models/principal.py b/gatehouse_app/models/principal.py new file mode 100644 index 0000000..7783ec3 --- /dev/null +++ b/gatehouse_app/models/principal.py @@ -0,0 +1,220 @@ +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel + + +class Principal(BaseModel): + """Principal model representing an SSH principal (access level/role). + + In SSH CA terminology, a principal is a string like "eng-prod-servers" or + "devops-admins" that represents a set of machines or access level. Users + can be granted access to principals, either directly or via department membership. + + Example: + - Principal: "eng-prod-servers" + - Users with this principal can SSH to prod servers + - Can be assigned to departments or directly to users + """ + + __tablename__ = "principals" + + organization_id = db.Column( + db.String(36), + db.ForeignKey("organizations.id"), + nullable=False, + index=True, + ) + name = db.Column(db.String(255), nullable=False, index=True) + description = db.Column(db.Text, nullable=True) + + # Relationships + organization = db.relationship("Organization", back_populates="principals") + memberships = db.relationship( + "PrincipalMembership", + back_populates="principal", + cascade="all, delete-orphan", + ) + department_links = db.relationship( + "DepartmentPrincipal", + back_populates="principal", + cascade="all, delete-orphan", + ) + + # Unique constraint: principal name per organization + __table_args__ = ( + db.UniqueConstraint( + "organization_id", "name", name="uix_org_principal_name" + ), + ) + + def __repr__(self): + """String representation of Principal.""" + return f"" + + def to_dict(self, exclude=None): + """Convert principal to dictionary.""" + exclude = exclude or [] + data = super().to_dict(exclude=exclude) + + # Add member count + data["direct_member_count"] = len([m for m in self.memberships if m.deleted_at is None]) + + # Add department count + data["department_count"] = len([d for d in self.department_links if d.deleted_at is None]) + + return data + + def get_members(self, active_only=True): + """Get all users who are directly assigned to this principal. + + Does NOT include users who get access via department membership. + + Args: + active_only: If True, exclude soft-deleted members + + Returns: + List of PrincipalMembership objects + """ + if active_only: + return [m for m in self.memberships if m.deleted_at is None] + return self.memberships + + def get_all_members(self, active_only=True): + """Get all users who have access to this principal. + + Includes both direct members and users via department membership. + + Args: + active_only: If True, exclude soft-deleted members + + Returns: + Set of User objects with access to this principal + """ + from gatehouse_app.models.user import User + + all_users = set() + + # Add direct members + for membership in self.get_members(active_only=active_only): + if membership.user.deleted_at is None or not active_only: + all_users.add(membership.user) + + # Add members via department assignment + for dept_link in self.department_links: + if dept_link.deleted_at is None or not active_only: + for dept_member in dept_link.department.get_members(active_only=active_only): + if dept_member.user.deleted_at is None or not active_only: + all_users.add(dept_member.user) + + return all_users + + def get_departments(self, active_only=True): + """Get all departments this principal is assigned to. + + Args: + active_only: If True, exclude soft-deleted departments + + Returns: + List of Department objects + """ + if active_only: + return [d.department for d in self.department_links if d.deleted_at is None and d.department.deleted_at is None] + return [d.department for d in self.department_links] + + def is_member(self, user_id, include_via_department=True): + """Check if a user has access to this principal. + + Args: + user_id: ID of the user to check + include_via_department: If True, check department memberships too + + Returns: + True if user has access to this principal + """ + # Check direct membership + has_direct = ( + PrincipalMembership.query.filter_by( + user_id=user_id, + principal_id=self.id, + deleted_at=None, + ).first() + is not None + ) + + if has_direct: + return True + + # Check department membership if requested + if not include_via_department: + return False + + # Get all departments this principal is assigned to + depts = self.get_departments(active_only=True) + dept_ids = [d.id for d in depts] + + if not dept_ids: + return False + + # Check if user is a member of any of these departments + from gatehouse_app.models.department import DepartmentMembership + + return ( + DepartmentMembership.query.filter( + DepartmentMembership.user_id == user_id, + DepartmentMembership.department_id.in_(dept_ids), + DepartmentMembership.deleted_at == None, + ).first() + is not None + ) + + def get_member_count(self, include_via_department=True): + """Get the count of active members with access to this principal. + + Args: + include_via_department: If True, include members via department + + Returns: + Count of members + """ + if not include_via_department: + return len(self.get_members(active_only=True)) + + return len(self.get_all_members(active_only=True)) + + +class PrincipalMembership(BaseModel): + """Principal membership model representing direct user assignment to a principal. + + When a user is assigned directly to a principal, they get access to that principal + for SSH authentication. This is in addition to any principals they get via + department membership. + """ + + __tablename__ = "principal_memberships" + + user_id = db.Column( + db.String(36), + db.ForeignKey("users.id"), + nullable=False, + index=True, + ) + principal_id = db.Column( + db.String(36), + db.ForeignKey("principals.id"), + nullable=False, + index=True, + ) + + # Relationships + user = db.relationship("User", back_populates="principal_memberships") + principal = db.relationship("Principal", back_populates="memberships") + + # Unique constraint: user can only be member of a principal once + __table_args__ = ( + db.UniqueConstraint( + "user_id", "principal_id", name="uix_user_principal" + ), + ) + + def __repr__(self): + """String representation of PrincipalMembership.""" + return f"" diff --git a/gatehouse_app/models/user.py b/gatehouse_app/models/user.py index 474269f..4da83ca 100644 --- a/gatehouse_app/models/user.py +++ b/gatehouse_app/models/user.py @@ -43,6 +43,18 @@ class User(BaseModel): cascade="all, delete-orphan", foreign_keys="MfaPolicyCompliance.user_id", ) + department_memberships = db.relationship( + "DepartmentMembership", + back_populates="user", + cascade="all, delete-orphan", + foreign_keys="DepartmentMembership.user_id", + ) + principal_memberships = db.relationship( + "PrincipalMembership", + back_populates="user", + cascade="all, delete-orphan", + foreign_keys="PrincipalMembership.user_id", + ) def __repr__(self): """String representation of User.""" diff --git a/migrations/versions/006_add_departments_principals.py b/migrations/versions/006_add_departments_principals.py new file mode 100644 index 0000000..8022143 --- /dev/null +++ b/migrations/versions/006_add_departments_principals.py @@ -0,0 +1,127 @@ +"""Add Department and Principal models for SSH CA management. + +Revision ID: 006 +Revises: 005 +Create Date: 2026-02-27 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '006' +down_revision = '005' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### Department table ### + op.create_table('departments', + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id'), + sa.UniqueConstraint('organization_id', 'name', name='uix_org_dept_name') + ) + op.create_index(op.f('ix_departments_organization_id'), 'departments', ['organization_id'], unique=False) + op.create_index(op.f('ix_departments_name'), 'departments', ['name'], unique=False) + + # ### DepartmentMembership table ### + op.create_table('department_memberships', + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('department_id', sa.String(length=36), nullable=False), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['department_id'], ['departments.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id'), + sa.UniqueConstraint('user_id', 'department_id', name='uix_user_dept') + ) + op.create_index(op.f('ix_department_memberships_user_id'), 'department_memberships', ['user_id'], unique=False) + op.create_index(op.f('ix_department_memberships_department_id'), 'department_memberships', ['department_id'], unique=False) + + # ### Principal table ### + op.create_table('principals', + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id'), + sa.UniqueConstraint('organization_id', 'name', name='uix_org_principal_name') + ) + op.create_index(op.f('ix_principals_organization_id'), 'principals', ['organization_id'], unique=False) + op.create_index(op.f('ix_principals_name'), 'principals', ['name'], unique=False) + + # ### PrincipalMembership table ### + op.create_table('principal_memberships', + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('principal_id', sa.String(length=36), nullable=False), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['principal_id'], ['principals.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id'), + sa.UniqueConstraint('user_id', 'principal_id', name='uix_user_principal') + ) + op.create_index(op.f('ix_principal_memberships_user_id'), 'principal_memberships', ['user_id'], unique=False) + op.create_index(op.f('ix_principal_memberships_principal_id'), 'principal_memberships', ['principal_id'], unique=False) + + # ### DepartmentPrincipal table ### + op.create_table('department_principals', + sa.Column('department_id', sa.String(length=36), nullable=False), + sa.Column('principal_id', sa.String(length=36), nullable=False), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['department_id'], ['departments.id'], ), + sa.ForeignKeyConstraint(['principal_id'], ['principals.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id'), + sa.UniqueConstraint('department_id', 'principal_id', name='uix_dept_principal') + ) + op.create_index(op.f('ix_department_principals_department_id'), 'department_principals', ['department_id'], unique=False) + op.create_index(op.f('ix_department_principals_principal_id'), 'department_principals', ['principal_id'], unique=False) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_department_principals_principal_id'), table_name='department_principals') + op.drop_index(op.f('ix_department_principals_department_id'), table_name='department_principals') + op.drop_table('department_principals') + + op.drop_index(op.f('ix_principal_memberships_principal_id'), table_name='principal_memberships') + op.drop_index(op.f('ix_principal_memberships_user_id'), table_name='principal_memberships') + op.drop_table('principal_memberships') + + op.drop_index(op.f('ix_principals_name'), table_name='principals') + op.drop_index(op.f('ix_principals_organization_id'), table_name='principals') + op.drop_table('principals') + + op.drop_index(op.f('ix_department_memberships_department_id'), table_name='department_memberships') + op.drop_index(op.f('ix_department_memberships_user_id'), table_name='department_memberships') + op.drop_table('department_memberships') + + op.drop_index(op.f('ix_departments_name'), table_name='departments') + op.drop_index(op.f('ix_departments_organization_id'), table_name='departments') + op.drop_table('departments') + # ### end Alembic commands ###