# Multi-tenancy The B12 SIS backend implements multi-tenancy through a hierarchical team structure, enabling complete data isolation between organizations while supporting parent-child team relationships. ## Overview Multi-tenancy is achieved through: 1. **Team Model**: Represents organizations, campuses, or departments 2. **Team ID Column**: Every data record has a `team_id` foreign key 3. **Team Middleware**: Validates and injects team context into requests 4. **Automatic Filtering**: Queries are automatically scoped to the user's teams ## Team Model ```go type Team struct { BaseModel Code string `gorm:"size:50;uniqueIndex" json:"code"` ParentId *string `gorm:"size:36;index" json:"parent_id"` Parent *Team `gorm:"foreignKey:ParentId" json:"parent"` Children []Team `gorm:"foreignKey:ParentId" json:"children"` Level int `gorm:"default:0" json:"level"` Path string `gorm:"size:500" json:"path"` // e.g., "/root-id/parent-id/team-id/" } ``` ### Team Hierarchy Example ``` Root Organization (Level 0) ├── Campus A (Level 1) │ ├── Primary School (Level 2) │ └── Secondary School (Level 2) └── Campus B (Level 1) ├── Primary School (Level 2) └── Secondary School (Level 2) ``` ## Request Context Every protected request must include the team context: ```http GET /api/students/list Authorization: Bearer X-Team-ID: ``` ## Team Middleware The middleware validates team access and sets context: ```go func TeamMiddleware() gin.HandlerFunc { return func(c *gin.Context) { teamID := c.GetHeader("X-Team-ID") if teamID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "X-Team-ID header required"}) c.Abort() return } // Verify user has access to this team userID := c.GetString("userID") if !userHasTeamAccess(userID, teamID) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to this team"}) c.Abort() return } // Set team context c.Set("teamID", teamID) c.Set("teamIDs", getTeamAndChildrenIDs(teamID)) c.Next() } } ``` ## Data Isolation ### BaseModel Team Field Every model inherits the team field: ```go type BaseModel struct { // ... TeamId string `gorm:"size:36;index" json:"team_id"` } ``` ### Automatic Filtering The repository applies team filtering automatically: ```go func (r *BaseRepository[T]) buildQuery(ctx context.Context) *gorm.DB { query := r.DB.WithContext(ctx) // Check if model disables team filtering var entity T if !entity.DisableTeamFilter() { // Get team IDs from context (includes children) teamIDs := utils.GetTeamIDsFromContext(ctx) query = query.Where("team_id IN ?", teamIDs) } return query } ``` ### Hierarchical Access Users at a parent team can access child team data: ```go func GetTeamAndChildrenIDs(ctx context.Context, teamID string) []string { var team Team db.Where("id = ?", teamID).Preload("Children").First(&team) ids := []string{team.Id} for _, child := range team.Children { ids = append(ids, GetTeamAndChildrenIDs(ctx, child.Id)...) } return ids } ``` ## Creating Records When creating records, the team ID is automatically set: ```go func (s *BaseService[T, CI, UI]) Create(ctx context.Context, input CI) (*T, error) { entity, _ := s.mapInputToEntity(input) // Set team ID from context teamID := utils.GetTeamIDFromContext(ctx) reflect.ValueOf(entity).Elem().FieldByName("TeamId").SetString(teamID) return s.Repository.Create(ctx, entity) } ``` ## Disabling Team Filtering Some models need to be shared across teams: ```go type SystemSetting struct { BaseModel Key string `json:"key"` Value string `json:"value"` } // DisableTeamFilter allows system settings to be accessed globally func (s *SystemSetting) DisableTeamFilter() bool { return true } ``` ## User-Team Association Users are associated with teams through a many-to-many relationship: ```go type User struct { BaseModel Teams []Team `gorm:"many2many:user_teams" json:"teams"` } ``` ### Checking Team Access ```go func userHasTeamAccess(userID, teamID string) bool { var count int64 db.Table("user_teams"). Where("user_id = ? AND team_id IN ?", userID, getTeamAndAncestorIDs(teamID)). Count(&count) return count > 0 } ``` ## API Examples ### List Records for Current Team ```bash curl -X POST http://localhost:8080/api/students/list \ -H "Authorization: Bearer " \ -H "X-Team-ID: campus-a-uuid" \ -H "Content-Type: application/json" \ -d '{"page": 1, "limit": 20}' ``` This returns only students belonging to "Campus A" and its sub-teams. ### Create Record in Current Team ```bash curl -X POST http://localhost:8080/api/students \ -H "Authorization: Bearer " \ -H "X-Team-ID: primary-school-uuid" \ -H "Content-Type: application/json" \ -d '{"first_name": "John", "last_name": "Doe"}' ``` The student is automatically assigned to "Primary School" team. ### Get User's Teams ```bash curl http://localhost:8080/api/auth/me \ -H "Authorization: Bearer " ``` Response includes the user's accessible teams: ```json { "user": { "id": "user-uuid", "teams": [ { "id": "campus-a-uuid", "name": "Campus A", "level": 1, "children": [ {"id": "primary-uuid", "name": "Primary School"}, {"id": "secondary-uuid", "name": "Secondary School"} ] } ] } } ``` ## Team Management ### Create Team ```bash curl -X POST http://localhost:8080/api/teams \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "New Campus", "code": "CAMPUS-C", "parent_id": "root-team-uuid" }' ``` ### Assign User to Team ```bash curl -X PUT http://localhost:8080/api/teams//assign \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"user_ids": ["user-uuid-1", "user-uuid-2"]}' ``` ## Best Practices 1. **Always Check Team Context**: Never assume team ID; always get it from context 2. **Respect Hierarchy**: Parent teams can access child data, not vice versa 3. **Use DisableTeamFilter Sparingly**: Only for truly global data 4. **Team Path for Quick Lookups**: Use the `path` field for efficient ancestor queries 5. **Index team_id**: Ensure all tables have an index on `team_id` ## Cross-Team Queries (Admin Only) For administrative reports that need cross-team data: ```go func (r *BaseRepository[T]) FindAllCrossTeam(ctx context.Context) ([]T, error) { // Only for admin users if !utils.IsAdmin(ctx) { return nil, errors.New("unauthorized") } var entities []T err := r.DB.WithContext(ctx).Find(&entities).Error return entities, err } ```