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

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:

GET /api/students/list
Authorization: Bearer <token>
X-Team-ID: <team-uuid>

Team Middleware

The middleware validates team access and sets context:

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:

type BaseModel struct {
    // ...
    TeamId string `gorm:"size:36;index" json:"team_id"`
}

Automatic Filtering

The repository applies team filtering automatically:

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:

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:

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:

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:

type User struct {
    BaseModel
    Teams []Team `gorm:"many2many:user_teams" json:"teams"`
}

Checking Team Access

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

curl -X POST http://localhost:8080/api/students/list \
  -H "Authorization: Bearer <token>" \
  -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

curl -X POST http://localhost:8080/api/students \
  -H "Authorization: Bearer <token>" \
  -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

curl http://localhost:8080/api/auth/me \
  -H "Authorization: Bearer <token>"

Response includes the user’s accessible teams:

{
  "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

curl -X POST http://localhost:8080/api/teams \
  -H "Authorization: Bearer <admin-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "New Campus",
    "code": "CAMPUS-C",
    "parent_id": "root-team-uuid"
  }'

Assign User to Team

curl -X PUT http://localhost:8080/api/teams/<team-id>/assign \
  -H "Authorization: Bearer <admin-token>" \
  -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:

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
}