Skip to main content
Glama
organizations.py35.5 kB
""" Organization member management routes. This module implements Phase 2 of the Memory Sharing Implementation Plan: - Invite users to organizations - List organization members - Remove users from organizations - Transfer organization ownership """ import logging from datetime import timedelta from bson import ObjectId from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from pydantic import BaseModel, EmailStr, Field from pymongo.database import Database from ..config import config from ..dependencies import AuthContext, authenticate_api_key, mongo_db from ..utils.datetime_helpers import utc_now from ..utils.permission_helpers import get_user_by_kratos_id from ..utils.rate_limiter import limiter from ..utils.validators import validate_object_id from .invitations import generate_invitation_token, send_invitation_email logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/organizations", tags=["Organization Management"]) # Pydantic Models class OrganizationInvite(BaseModel): """Model for inviting a user to an organization with optional project assignments.""" email: EmailStr = Field(..., description="Email address of the user to invite") role: str = Field( default="member", description="Role in organization: owner, admin, or member", pattern="^(owner|admin|member)$", ) projectIds: list[str] = Field( default_factory=list, description="Optional list of project IDs to assign the user to", ) projectRoles: dict[str, str] = Field( default_factory=dict, description="Optional mapping of project ID to role (admin, editor, viewer)", ) class TransferOwnership(BaseModel): """Model for transferring organization ownership.""" new_owner_id: str = Field( ..., description="User ID of the new owner (must be an admin)" ) # Helper Functions def get_organization_member( db: Database, organization_id: ObjectId, user_id: ObjectId ) -> dict | None: """Get organization member record for a user.""" return db.organization_members.find_one( {"organizationId": organization_id, "userId": user_id, "status": "active"} ) def is_organization_admin( db: Database, organization_id: ObjectId, user_obj_id: ObjectId, kratos_user_id: str ) -> bool: """Check if user is an admin or owner of the organization.""" # Check if user is the organization owner # Check BOTH ownerId formats (frontend uses string, backend uses ObjectId) organization = db.organizations.find_one({"_id": organization_id}) if organization: org_owner_id = organization.get("ownerId") # Compare as both Kratos ID string and MongoDB ObjectId if org_owner_id in (kratos_user_id, user_obj_id): return True # Check if user is an admin member (using MongoDB ObjectId) member = get_organization_member(db, organization_id, user_obj_id) return member and member["role"] in ["owner", "admin"] def count_organization_admins(db: Database, organization_id: ObjectId) -> int: """Count number of admins (including owner) in organization.""" return db.organization_members.count_documents( { "organizationId": organization_id, "role": {"$in": ["owner", "admin"]}, "status": "active", } ) def get_user_by_email(db: Database, email: str) -> dict | None: """Find user by email address.""" return db.users.find_one({"email": email}) # API Endpoints @router.post("/{org_id}/invitations", summary="Invite user to organization") @limiter.limit(config.rate_limit.INVITATION_CREATE) def invite_user_to_organization( request: Request, org_id: str, invite: OrganizationInvite, background_tasks: BackgroundTasks, auth: AuthContext = Depends(authenticate_api_key), ): """ Invite a user to join an organization. Rate limited to prevent spam (5 requests per minute). Only organization admins and owners can invite users. Creates an invitation record that can be accepted by the invited user. """ org_obj_id = validate_object_id(org_id, "org_id") # Get user document using helper (handles migration dual-format) try: user = get_user_by_kratos_id(mongo_db, auth.user_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from e user_obj_id = user["_id"] # Verify organization exists organization = mongo_db.organizations.find_one({"_id": org_obj_id}) if not organization: raise HTTPException(status_code=404, detail="Organization not found") # Verify inviter is admin or owner if not is_organization_admin(mongo_db, org_obj_id, user_obj_id, auth.user_id): raise HTTPException( status_code=403, detail="Only organization admins can invite users", ) # Get inviter's email to prevent self-invitation inviter = mongo_db.users.find_one({"_id": user_obj_id}) if inviter and inviter.get("email") == invite.email: raise HTTPException( status_code=400, detail="You cannot invite yourself", ) # Check if invited user already exists invited_user = get_user_by_email(mongo_db, invite.email) # If user exists, check if already a member or owner if invited_user: # Check if user is the organization owner if str(organization["ownerId"]) == str(invited_user["_id"]): raise HTTPException( status_code=400, detail="User is the owner of this organization", ) # Check if user is already a member existing_member = get_organization_member( mongo_db, org_obj_id, invited_user["_id"] ) if existing_member: raise HTTPException( status_code=400, detail="User is already a member of this organization", ) # Check for pending invitation and handle expired or replace existing ones existing_invitation = mongo_db.invitations.find_one( { "email": invite.email, "organizationId": org_obj_id, "projectId": None, # Organization-level invitation "status": "pending", } ) if existing_invitation: # Check if the invitation has expired from .invitations import is_invitation_expired if is_invitation_expired(existing_invitation): # Mark as expired in the database mongo_db.invitations.update_one( {"_id": existing_invitation["_id"]}, {"$set": {"status": "expired"}}, ) logger.info( f"Marked expired invitation as 'expired': id={existing_invitation['_id']}, " f"email={invite.email}, org={org_id}" ) else: # Auto-cancel existing pending invitation and create new one mongo_db.invitations.delete_one({"_id": existing_invitation["_id"]}) logger.info( f"Auto-cancelled existing pending invitation: id={existing_invitation['_id']}, " f"email={invite.email}, org={org_id}, reason=new_invitation_requested" ) # Generate invitation token using shared helper invitation_token = generate_invitation_token() # Calculate expiration (24 hours) expires_at = utc_now() + timedelta(hours=24) # Validate project assignments if provided project_assignments = [] if invite.projectIds: for project_id_str in invite.projectIds: project_obj_id = validate_object_id(project_id_str, "project_id") # Verify project exists and belongs to this organization project = mongo_db.projects.find_one({"_id": project_obj_id}) if not project: raise HTTPException( status_code=404, detail=f"Project not found: {project_id_str}" ) if str(project["organizationId"]) != org_id: raise HTTPException( status_code=400, detail=f"Project {project_id_str} does not belong to this organization", ) # Get role for this project (default to viewer if not specified) project_role = invite.projectRoles.get(project_id_str, "viewer") if project_role not in ["admin", "editor", "viewer"]: raise HTTPException( status_code=400, detail=f"Invalid project role: {project_role}" ) project_assignments.append( { "projectId": project_id_str, "projectName": project["name"], "role": project_role, } ) # Create invitation record invitation_doc = { "email": invite.email, "organizationId": org_obj_id, "projectId": None, # Organization-level invitation "invitedBy": user_obj_id, "role": invite.role, "token": invitation_token, "status": "pending", "expiresAt": expires_at, "createdAt": utc_now(), "projectAssignments": project_assignments, # Store project assignments } result = mongo_db.invitations.insert_one(invitation_doc) invitation_id = str(result.inserted_id) logger.info( f"✅ Created organization invitation: org={org_id}, email={invite.email}, " f"inviter={auth.user_id}, role={invite.role}, projects={len(project_assignments)}" ) # Get inviter information for email inviter = mongo_db.users.find_one({"_id": user_obj_id}) inviter_email = inviter.get("email") if inviter else "Unknown" # Send invitation email in background (non-blocking) background_tasks.add_task( send_invitation_email, email=invite.email, organization_name=organization["name"], project_name=None, # Org-level invitation role=invite.role, invited_by_email=inviter_email, invitation_token=invitation_token, ) logger.info( f"✅ Invitation email queued for {invite.email} (will send in background)" ) return { "invitation_id": invitation_id, "email": invite.email, "organization_id": org_id, "role": invite.role, "project_assignments": project_assignments, "token": invitation_token, "expires_at": expires_at.isoformat(), "message": "Invitation created successfully", } @router.get("/{org_id}/members", summary="List organization members") def list_organization_members( org_id: str, auth: AuthContext = Depends(authenticate_api_key), ): """ List all members of an organization. Any organization member can view the member list. """ org_obj_id = validate_object_id(org_id, "org_id") # Get user document using helper (handles migration dual-format) try: user = get_user_by_kratos_id(mongo_db, auth.user_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from None user_obj_id = user["_id"] # Verify organization exists organization = mongo_db.organizations.find_one({"_id": org_obj_id}) if not organization: raise HTTPException(status_code=404, detail="Organization not found") # Verify requester is a member OR owner is_owner = str(organization["ownerId"]) == auth.user_id requester_member = get_organization_member(mongo_db, org_obj_id, user_obj_id) if not is_owner and not requester_member: raise HTTPException( status_code=403, detail="Access denied - not a member of this organization", ) # Get all active members members = list( mongo_db.organization_members.find( {"organizationId": org_obj_id, "status": "active"} ).sort("joinedAt", 1) ) # IMPORTANT: Ensure owner appears in members list even if not in organization_members table # This handles legacy organizations where owner wasn't added to organization_members owner_id = organization.get("ownerId") owner_in_members = any(str(m["userId"]) == str(owner_id) for m in members) if not owner_in_members: # Add owner to members list for display (and optionally persist to DB) owner_member_doc = { "organizationId": org_obj_id, "userId": owner_id, "role": "owner", "joinedAt": organization.get("createdAt", utc_now()), "status": "active", "invitedBy": None, } # Add to in-memory list for this response members.insert(0, owner_member_doc) # Optionally persist to database to fix legacy data try: mongo_db.organization_members.insert_one(owner_member_doc.copy()) logger.info( f"✅ Added missing owner to organization_members: org={org_id}, owner={owner_id}" ) except Exception as e: # Ignore duplicates or errors - owner is already in response logger.debug(f"Could not persist owner to organization_members: {e}") logger.info( f"🔍 Listing members: org={org_id}, owner={owner_id}, " f"owner_in_members={owner_in_members}, total_members={len(members)}" ) # Enrich member data with user information and project count enriched_members = [] for member in members: # Handle both ObjectId and Kratos ID string formats for userId member_user_id = member["userId"] user = mongo_db.users.find_one( { "$or": [ {"_id": member_user_id}, # MongoDB ObjectId {"kratosId": member_user_id}, # Kratos ID string ] } ) if user: # Count projects this member has access to in this organization projects_count = mongo_db.project_members.count_documents( { "userId": member["userId"], "organizationId": org_obj_id, } ) enriched_members.append( { "userId": str(member["userId"]), "email": user.get("email"), "name": user.get("name"), "role": member["role"], "joinedAt": member["joinedAt"].isoformat(), "invitedBy": str(member.get("invitedBy")) if member.get("invitedBy") else None, "projectsCount": projects_count, } ) # Get ALL pending invitations for this organization (org-level AND project-level) pending_invitations = list( mongo_db.invitations.find( {"organizationId": org_obj_id, "status": "pending"} ).sort("createdAt", -1) ) # Enrich invitation data enriched_invitations = [] for invitation in pending_invitations: # Get inviter details inviter = mongo_db.users.find_one({"_id": invitation["invitedBy"]}) inviter_email = inviter.get("email") if inviter else "Unknown" # Determine invitation type and get relevant info is_project_invitation = invitation.get("projectId") is not None project_name = None if is_project_invitation: # Project-level invitation project = mongo_db.projects.find_one({"_id": invitation["projectId"]}) project_name = project["name"] if project else "Unknown Project" project_assignments = [] else: # Organization-level invitation (may have project assignments) project_assignments = invitation.get("projectAssignments", []) enriched_invitations.append( { "invitationId": str(invitation["_id"]), "email": invitation["email"], "role": invitation["role"], "invitedBy": inviter_email, "invitedByUserId": str(invitation["invitedBy"]), "invitationType": "project" if is_project_invitation else "organization", "projectName": project_name, # Only for project invitations "projectAssignments": project_assignments, # Only for org invitations "projectsCount": len(project_assignments) if not is_project_invitation else 1, "createdAt": invitation["createdAt"].isoformat(), "expiresAt": invitation["expiresAt"].isoformat(), "status": invitation["status"], } ) logger.info( f"Listed organization members: org={org_id}, requester={auth.user_id}, " f"members={len(enriched_members)}, pending_invitations={len(enriched_invitations)}" ) return { "organization_id": org_id, "members": enriched_members, "pending_invitations": enriched_invitations, "total_members": len(enriched_members), "total_pending": len(enriched_invitations), } @router.put("/{org_id}/members/{user_id}", summary="Update organization member role") def update_organization_member_role( org_id: str, user_id: str, role_update: BaseModel, auth: AuthContext = Depends(authenticate_api_key), ): """ Update an organization member's role. - Only admins and owners can update roles - Cannot change organization owner's role - Cannot demote the last admin - Valid roles: admin, member """ from pydantic import Field class UpdateMemberRole(BaseModel): role: str = Field(..., pattern="^(admin|member)$") role_data = UpdateMemberRole( **role_update.dict() if hasattr(role_update, "dict") else role_update ) org_obj_id = validate_object_id(org_id, "org_id") target_user_obj_id = validate_object_id(user_id, "user_id") # Verify organization exists organization = mongo_db.organizations.find_one({"_id": org_obj_id}) if not organization: raise HTTPException(status_code=404, detail="Organization not found") # Verify requester is admin or owner (get MongoDB ObjectId from Kratos ID) try: requester_user = get_user_by_kratos_id(mongo_db, auth.user_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from None requester_obj_id = requester_user["_id"] if not is_organization_admin(mongo_db, org_obj_id, requester_obj_id, auth.user_id): raise HTTPException( status_code=403, detail="Only organization admins can update member roles", ) # Cannot change organization owner's role if str(organization["ownerId"]) == user_id: raise HTTPException( status_code=400, detail="Cannot change organization owner's role. Transfer ownership first.", ) # Verify target user is a member target_member = get_organization_member(mongo_db, org_obj_id, target_user_obj_id) if not target_member: raise HTTPException( status_code=404, detail="User is not a member of this organization", ) # Check if this would demote the last admin if target_member["role"] == "admin" and role_data.role != "admin": admin_count = count_organization_admins(mongo_db, org_obj_id) if admin_count <= 1: raise HTTPException( status_code=400, detail="Cannot demote the last admin. Promote another member first.", ) # Update role mongo_db.organization_members.update_one( {"organizationId": org_obj_id, "userId": target_user_obj_id}, {"$set": {"role": role_data.role}}, ) logger.info( f"Updated organization member role: org={org_id}, user={user_id}, " f"old_role={target_member['role']}, new_role={role_data.role}, " f"requester={auth.user_id}" ) return { "message": "Member role updated successfully", "organization_id": org_id, "user_id": user_id, "old_role": target_member["role"], "new_role": role_data.role, } @router.delete("/{org_id}/members/{user_id}", summary="Remove user from organization") def remove_user_from_organization( org_id: str, user_id: str, auth: AuthContext = Depends(authenticate_api_key), ): """ Remove a user from an organization. - Only admins and owners can remove members - Cannot remove the last admin - Cannot remove the organization owner - Cascades: removes from all projects, deactivates all API keys """ org_obj_id = validate_object_id(org_id, "org_id") target_user_obj_id = validate_object_id(user_id, "user_id") # Verify organization exists organization = mongo_db.organizations.find_one({"_id": org_obj_id}) if not organization: raise HTTPException(status_code=404, detail="Organization not found") # Verify requester is admin (get MongoDB ObjectId from Kratos ID) try: requester_user = get_user_by_kratos_id(mongo_db, auth.user_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from None requester_obj_id = requester_user["_id"] # Check if requester is admin OR removing themselves is_admin = is_organization_admin( mongo_db, org_obj_id, requester_obj_id, auth.user_id ) is_self_removal = str(requester_obj_id) == user_id or auth.user_id == user_id logger.info( f"🔍 Organization member removal check: org={org_id}, target={user_id}, " f"requester_obj_id={str(requester_obj_id)}, requester_kratos_id={auth.user_id}, " f"is_admin={is_admin}, is_self_removal={is_self_removal}" ) if not is_admin and not is_self_removal: raise HTTPException( status_code=403, detail="Only organization admins can remove other members", ) # Verify target user is a member target_member = get_organization_member(mongo_db, org_obj_id, target_user_obj_id) if not target_member: raise HTTPException( status_code=404, detail="User is not a member of this organization", ) # Cannot remove organization owner is_owner = str(organization["ownerId"]) == user_id if is_owner: raise HTTPException( status_code=400, detail="Cannot remove organization owner. Transfer ownership first.", ) # Check if this would remove the last admin # Allow removal if user is removing themselves (self-removal) since owner still has admin rights # IMPORTANT: The organization owner has implicit admin rights even if not in organization_members if target_member["role"] in ["owner", "admin"] and not is_self_removal: admin_count = count_organization_admins(mongo_db, org_obj_id) # Check if organization owner would remain after removal (owner has implicit admin rights) org_owner_id = organization["ownerId"] owner_is_being_removed = str(org_owner_id) == user_id # DEBUG: Log admin count and owner status logger.info( f"🔍 Admin removal check: org={org_id}, target={user_id}, " f"admin_count={admin_count}, org_owner={str(org_owner_id)}, " f"owner_is_being_removed={owner_is_being_removed}" ) # Only block if we're removing the last admin AND the owner is also being removed # If owner remains (and they're not being removed), they retain implicit admin rights if admin_count <= 1 and owner_is_being_removed: raise HTTPException( status_code=400, detail="Cannot remove the last admin. Promote another member first.", ) # CASCADE 1: Remove from organization_members mongo_db.organization_members.delete_one( {"organizationId": org_obj_id, "userId": target_user_obj_id} ) # CASCADE 2: Remove from ALL project_members in this organization project_removal_result = mongo_db.project_members.delete_many( {"organizationId": org_obj_id, "userId": target_user_obj_id} ) # CASCADE 3: Deactivate ALL API keys in this organization key_deactivation_result = mongo_db.api_keys.update_many( { "userId": target_user_obj_id, "projectId": { "$in": [ p["_id"] for p in mongo_db.projects.find( {"organizationId": org_obj_id}, {"_id": 1} ) ] }, }, {"$set": {"isActive": False, "deactivatedAt": utc_now()}}, ) logger.info( f"Removed user from organization: org={org_id}, user={user_id}, " f"requester={auth.user_id}, projects_removed={project_removal_result.deleted_count}, " f"keys_deactivated={key_deactivation_result.modified_count}" ) return { "message": "User removed from organization successfully", "organization_id": org_id, "user_id": user_id, "cascades": { "projects_removed": project_removal_result.deleted_count, "api_keys_deactivated": key_deactivation_result.modified_count, }, } @router.delete( "/{org_id}/invitations/{invitation_id}", summary="Cancel pending invitation" ) def cancel_organization_invitation( org_id: str, invitation_id: str, auth: AuthContext = Depends(authenticate_api_key), ): """ Cancel a pending organization invitation. - Only admins and owners can cancel invitations - Invitation must be pending - Invitation must belong to this organization """ org_obj_id = validate_object_id(org_id, "org_id") invitation_obj_id = validate_object_id(invitation_id, "invitation_id") # Verify organization exists organization = mongo_db.organizations.find_one({"_id": org_obj_id}) if not organization: raise HTTPException(status_code=404, detail="Organization not found") # Verify requester is admin (get user MongoDB ObjectId from Kratos ID) try: user = get_user_by_kratos_id(mongo_db, auth.user_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from None user_obj_id = user["_id"] if not is_organization_admin(mongo_db, org_obj_id, user_obj_id, auth.user_id): raise HTTPException( status_code=403, detail="Only organization admins can cancel invitations", ) # Find invitation invitation = mongo_db.invitations.find_one( { "_id": invitation_obj_id, "organizationId": org_obj_id, "projectId": None, # Organization-level only } ) if not invitation: raise HTTPException( status_code=404, detail="Invitation not found", ) if invitation["status"] != "pending": raise HTTPException( status_code=400, detail=f"Cannot cancel invitation with status: {invitation['status']}", ) # Delete the invitation mongo_db.invitations.delete_one({"_id": invitation_obj_id}) logger.info( f"Cancelled invitation: id={invitation_id}, org={org_id}, " f"email={invitation['email']}, cancelled_by={auth.user_id}" ) return { "message": "Invitation cancelled successfully", "invitation_id": invitation_id, "email": invitation["email"], } @router.put("/{org_id}/transfer-ownership", summary="Transfer organization ownership") def transfer_organization_ownership( org_id: str, transfer: TransferOwnership, auth: AuthContext = Depends(authenticate_api_key), ): """ Transfer organization ownership to another admin. - Only the current owner can transfer ownership - Target user must be an existing admin - Current owner remains as admin after transfer """ org_obj_id = validate_object_id(org_id, "org_id") new_owner_obj_id = validate_object_id(transfer.new_owner_id, "new_owner_id") # Verify organization exists organization = mongo_db.organizations.find_one({"_id": org_obj_id}) if not organization: raise HTTPException(status_code=404, detail="Organization not found") # Get current owner's ObjectId try: current_owner = get_user_by_kratos_id(mongo_db, auth.user_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from None current_owner_obj_id = current_owner["_id"] # Verify requester is current owner if str(organization["ownerId"]) != auth.user_id: raise HTTPException( status_code=403, detail="Only the organization owner can transfer ownership", ) # Verify target user is an admin target_member = get_organization_member(mongo_db, org_obj_id, new_owner_obj_id) if not target_member: raise HTTPException( status_code=404, detail="Target user is not a member of this organization", ) if target_member["role"] != "admin": raise HTTPException( status_code=400, detail="Target user must be an admin to receive ownership", ) # Update organization owner mongo_db.organizations.update_one( {"_id": org_obj_id}, { "$set": { "ownerId": new_owner_obj_id, "updatedAt": utc_now(), } }, ) # Update new owner's role to "owner" in organization_members mongo_db.organization_members.update_one( {"organizationId": org_obj_id, "userId": new_owner_obj_id}, {"$set": {"role": "owner"}}, ) # Downgrade current owner to "admin" mongo_db.organization_members.update_one( {"organizationId": org_obj_id, "userId": current_owner_obj_id}, {"$set": {"role": "admin"}}, ) logger.info( f"Transferred organization ownership: org={org_id}, " f"from={auth.user_id}, to={transfer.new_owner_id}" ) return { "message": "Organization ownership transferred successfully", "organization_id": org_id, "previous_owner_id": auth.user_id, "new_owner_id": transfer.new_owner_id, } @router.delete("/{org_id}", summary="Delete organization with cascade cleanup") def delete_organization( org_id: str, auth: AuthContext = Depends(authenticate_api_key), ): """ Delete an organization with complete cascade cleanup. **Cascade Actions:** 1. Delete ALL projects in the organization (which cascades to project_members, api_keys, invitations) 2. Remove ALL organization_members records 3. Cancel ALL pending organization-level invitations 4. Delete organization record 5. Memories remain (project-owned) **Restrictions:** - Only organization owner can delete the organization - Requires confirmation (this is destructive and affects all projects) """ org_obj_id = validate_object_id(org_id, "org_id") # Verify organization exists organization = mongo_db.organizations.find_one({"_id": org_obj_id}) if not organization: raise HTTPException(status_code=404, detail="Organization not found") # Verify requester is organization owner if str(organization["ownerId"]) != auth.user_id: raise HTTPException( status_code=403, detail="Only the organization owner can delete the organization", ) # Get all projects in this organization projects = list(mongo_db.projects.find({"organizationId": org_obj_id})) project_ids = [p["_id"] for p in projects] # CASCADE 1: Delete ALL projects (which cascades to project_members, api_keys, invitations) # For each project: remove project_members, deactivate api_keys, cancel invitations total_members_removed = 0 total_keys_deactivated = 0 total_project_invitations_cancelled = 0 for project_obj_id in project_ids: # Remove project_members members_result = mongo_db.project_members.delete_many( {"projectId": project_obj_id} ) total_members_removed += members_result.deleted_count # Deactivate API keys for this project keys_result = mongo_db.api_keys.update_many( {"projectId": project_obj_id, "isActive": True}, { "$set": { "isActive": False, "deactivatedAt": utc_now(), "deactivatedReason": "organization_deleted", } }, ) total_keys_deactivated += keys_result.modified_count # Cancel project invitations invitations_result = mongo_db.invitations.update_many( {"projectId": project_obj_id, "status": "pending"}, { "$set": { "status": "cancelled", "cancelledAt": utc_now(), "cancelledReason": "organization_deleted", } }, ) total_project_invitations_cancelled += invitations_result.modified_count # Delete all projects projects_deleted = mongo_db.projects.delete_many({"organizationId": org_obj_id}) # CASCADE 2: Remove ALL organization_members org_members_removed = mongo_db.organization_members.delete_many( {"organizationId": org_obj_id} ) # CASCADE 3: Cancel ALL organization-level invitations (projectId = None) org_invitations_cancelled = mongo_db.invitations.update_many( {"organizationId": org_obj_id, "projectId": None, "status": "pending"}, { "$set": { "status": "cancelled", "cancelledAt": utc_now(), "cancelledReason": "organization_deleted", } }, ) # CASCADE 4: Delete the organization itself mongo_db.organizations.delete_one({"_id": org_obj_id}) # Note: Memories are NOT deleted - they remain in vector store # This preserves data and allows for potential recovery scenarios logger.info( f"Organization deleted: org={org_id}, owner={auth.user_id}, " f"projects_deleted={projects_deleted.deleted_count}, " f"members_removed={total_members_removed}, " f"org_members_removed={org_members_removed.deleted_count}, " f"keys_deactivated={total_keys_deactivated}, " f"invitations_cancelled={total_project_invitations_cancelled + org_invitations_cancelled.modified_count}" ) return { "message": "Organization deleted successfully", "organization_id": org_id, "organization_name": organization["name"], "cascades": { "projects_deleted": projects_deleted.deleted_count, "project_members_removed": total_members_removed, "organization_members_removed": org_members_removed.deleted_count, "api_keys_deactivated": total_keys_deactivated, "invitations_cancelled": total_project_invitations_cancelled + org_invitations_cancelled.modified_count, }, "note": "All projects and their memories remain in the vector store but are no longer accessible.", }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/shrijayan/SelfMemory'

If you have feedback or need assistance with the MCP directory API, please join our Discord server