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:
Team Model: Represents organizations, campuses, or departments
Team ID Column: Every data record has a
team_idforeign keyTeam Middleware: Validates and injects team context into requests
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
Always Check Team Context: Never assume team ID; always get it from context
Respect Hierarchy: Parent teams can access child data, not vice versa
Use DisableTeamFilter Sparingly: Only for truly global data
Team Path for Quick Lookups: Use the
pathfield for efficient ancestor queriesIndex 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
}