Migrate GORM to generics API, simplify user-domain relationship, refactor models

This commit is contained in:
Christoph Haas 2026-03-28 17:21:00 +01:00
parent 19ce2224b8
commit 560b40503a
9 changed files with 192 additions and 117 deletions

View file

@ -6,27 +6,29 @@ package db
// =============================================================================
// GetImcUserByUsername looks up an admin user by their username.
// Returns the user with their associated domain permissions preloaded.
// Returns the user with their associated domain preloaded.
// username: the username to search for.
// Returns: the user and any error.
func (d *DB) GetImcUserByUsername(username string) (*ImcUser, error) {
var user ImcUser
// Preload loads related data (Domains) to avoid N+1 queries.
// Where creates a SQL WHERE clause, ? is a placeholder for the username.
if err := d.Preload("Domains.Domain").Where("username = ?", username).First(&user).Error; err != nil {
// Preload loads the related Domain to avoid N+1 queries.
// Where + First is the generics API pattern.
err := d.Where("username = ?", username).Preload("Domain").First(&user).Error
if err != nil {
return nil, err // Return nil user and the error
}
return &user, nil
}
// GetImcUserByID looks up an admin user by their ID.
// Returns the user with their associated domain permissions preloaded.
// Returns the user with their associated domain preloaded.
// id: the user's primary key ID.
// Returns: the user and any error.
func (d *DB) GetImcUserByID(id uint) (*ImcUser, error) {
var user ImcUser
// First(&user, id) looks up by primary key.
if err := d.Preload("Domains.Domain").First(&user, id).Error; err != nil {
// Where + First looks up by primary key using generics API.
err := d.Where("id = ?", id).Preload("Domain").First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
@ -36,15 +38,18 @@ func (d *DB) GetImcUserByID(id uint) (*ImcUser, error) {
// username: the login username (must be unique).
// passwordHash: the bcrypt hash of the password (NOT the plain password).
// role: "admin" or "user".
// domainID: the ID of the domain this user can manage.
// Returns: the created user and any error.
func (d *DB) CreateImcUser(username, passwordHash, role string) (*ImcUser, error) {
func (d *DB) CreateImcUser(username, passwordHash, role string, domainID uint) (*ImcUser, error) {
user := ImcUser{
Username: username,
PasswordHash: passwordHash,
Role: role,
DomainID: domainID,
}
// Create inserts the record into the database.
if err := d.Create(&user).Error; err != nil {
err := d.Create(&user).Error
if err != nil {
return nil, err
}
return &user, nil
@ -55,9 +60,8 @@ func (d *DB) CreateImcUser(username, passwordHash, role string) (*ImcUser, error
// passwordHash: the new bcrypt hash of the password.
func (d *DB) UpdateImcUserPassword(id uint, passwordHash string) error {
// Model specifies which table/struct to update.
// Where filters which rows to update.
// Update only changes the specified fields.
return d.Model(&ImcUser{}).Where("id = ?", id).Update("password_hash", passwordHash).Error
// Updates with map for generics API.
return d.Model(&ImcUser{}).Updates(map[string]any{"password_hash": passwordHash}).Error
}
// DeleteImcUser removes an admin user from the database.
@ -67,13 +71,13 @@ func (d *DB) DeleteImcUser(id uint) error {
}
// UpsertAdminUser creates the admin user if it doesn't exist, or updates the password if it does.
func (d *DB) UpsertAdminUser(username, passwordHash string) error {
func (d *DB) UpsertAdminUser(username, passwordHash string, domainID uint) error {
var user ImcUser
err := d.Where("username = ? AND role = ?", username, "admin").First(&user).Error
if err == nil {
return d.Model(&ImcUser{}).Where("id = ?", user.ID).Update("password_hash", passwordHash).Error
return d.Model(&ImcUser{}).Updates(map[string]any{"password_hash": passwordHash}).Error
}
_, err = d.CreateImcUser(username, passwordHash, "admin")
_, err = d.CreateImcUser(username, passwordHash, "admin", domainID)
return err
}
@ -83,7 +87,7 @@ func (d *DB) UpsertAdminUser(username, passwordHash string) error {
// =============================================================================
// GetUserAccessibleDomains returns all domains a user can access.
// Admin users can access all domains; regular users only their assigned domains.
// Admin users can access all domains; regular users get their primary domain plus any from many-to-many.
// userID: the ID of the user.
// isAdmin: whether the user has admin privileges.
// Returns: a list of accessible domains.
@ -91,29 +95,49 @@ func (d *DB) GetUserAccessibleDomains(userID uint, isAdmin bool) ([]VirtualDomai
// Admins get access to all domains.
if isAdmin {
var domains []VirtualDomain
if err := d.Order("name ASC").Find(&domains).Error; err != nil {
err := d.Order("name ASC").Find(&domains).Error
if err != nil {
return nil, err
}
return domains, nil
}
// Regular users query the imc_users2domains table.
// We use raw SQL with Joins because GORM's association methods
// don't handle our cross-database queries well.
var domains []VirtualDomain
err := d.Table("imc_users2domains"). // Query from this table
Select("virtual_domains.*"). // Select all columns from domains
Joins("JOIN virtual_domains ON virtual_domains.id = imc_users2domains.domain_id"). // Join to get domain details
Where("imc_users2domains.user_id = ?", userID). // Only this user's domains
Order("name ASC").
Find(&domains).Error
// Regular users get their primary domain from imc_users table.
var user ImcUser
err := d.Where("id = ?", userID).First(&user).Error
if err != nil {
return nil, err
}
if domains == nil {
domains = []VirtualDomain{} // Return empty slice, not nil
var primaryDomain VirtualDomain
err = d.Where("id = ?", user.DomainID).First(&primaryDomain).Error
if err != nil {
return nil, err
}
return domains, nil
// Also get additional domains from many-to-many table.
var additionalDomains []VirtualDomain
err = d.Table("imc_users2domains").
Select("virtual_domains.*").
Joins("JOIN virtual_domains ON virtual_domains.id = imc_users2domains.domain_id").
Where("imc_users2domains.user_id = ?", userID).
Find(&additionalDomains).Error
if err != nil {
return nil, err
}
// Merge primary domain with additional domains (avoiding duplicates).
domainMap := make(map[uint]VirtualDomain)
domainMap[primaryDomain.ID] = primaryDomain
for _, d := range additionalDomains {
domainMap[d.ID] = d
}
result := make([]VirtualDomain, 0, len(domainMap))
for _, d := range domainMap {
result = append(result, d)
}
return result, nil
}
// CanAccessDomain checks if a user can access a specific domain.
@ -127,20 +151,73 @@ func (d *DB) CanAccessDomain(userID uint, domainName string, isAdmin bool) (bool
return true, nil
}
// Count matching records - if count > 0, access is granted.
// Regular users check their primary domain first.
var user ImcUser
err := d.Where("id = ?", userID).First(&user).Error
if err != nil {
return false, err
}
var primaryDomain VirtualDomain
err = d.Where("id = ? AND name = ?", user.DomainID, domainName).First(&primaryDomain).Error
if err == nil {
return true, nil // Primary domain matches
}
// Also check the many-to-many table.
var count int64
err := d.Table("imc_users2domains").
Select("COUNT(*)"). // Count matching rows
Joins("JOIN virtual_domains ON virtual_domains.id = imc_users2domains.domain_id"). // Join to domain table
Where("imc_users2domains.user_id = ? AND virtual_domains.name = ?", userID, domainName). // Match user and domain name
err = d.Table("imc_users2domains").
Select("COUNT(*)").
Joins("JOIN virtual_domains ON virtual_domains.id = imc_users2domains.domain_id").
Where("imc_users2domains.user_id = ? AND virtual_domains.name = ?", userID, domainName).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil // True if at least one match found
return count > 0, nil
}
// AddUserToDomain grants a user access to a domain.
// GetUsersForDomain returns all admin users who can access a specific domain.
// domainID: the ID of the domain.
// Returns: a list of users who can access this domain (via primary domain or many-to-many).
func (d *DB) GetUsersForDomain(domainID uint) ([]ImcUser, error) {
var users []ImcUser
// First get users who have this as their primary domain.
err := d.Where("domain_id = ?", domainID).Find(&users).Error
if err != nil {
return nil, err
}
// Also get users from the many-to-many table.
var additionalUsers []ImcUser
err = d.Table("imc_users2domains").
Select("imc_users.*").
Joins("JOIN imc_users ON imc_users.id = imc_users2domains.user_id").
Where("imc_users2domains.domain_id = ?", domainID).
Find(&additionalUsers).Error
if err != nil {
return nil, err
}
// Merge the results (avoiding duplicates).
userMap := make(map[uint]ImcUser)
for _, u := range users {
userMap[u.ID] = u
}
for _, u := range additionalUsers {
if _, exists := userMap[u.ID]; !exists {
userMap[u.ID] = u
}
}
result := make([]ImcUser, 0, len(userMap))
for _, u := range userMap {
result = append(result, u)
}
return result, nil
}
// AddUserToDomain grants a user access to a domain via many-to-many relationship.
// userID: the ID of the user.
// domainID: the ID of the domain.
func (d *DB) AddUserToDomain(userID, domainID uint) error {
@ -151,34 +228,14 @@ func (d *DB) AddUserToDomain(userID, domainID uint) error {
return d.Create(&ud).Error
}
// RemoveUserFromDomain revokes a user's access to a domain.
// RemoveUserFromDomain revokes a user's access to a domain via many-to-many relationship.
// userID: the ID of the user.
// domainID: the ID of the domain.
func (d *DB) RemoveUserFromDomain(userID, domainID uint) error {
// Delete records matching both user_id and domain_id.
return d.Where("user_id = ? AND domain_id = ?", userID, domainID).Delete(&ImcUserDomain{}).Error
}
// GetUsersForDomain returns all admin users who can access a specific domain.
// domainID: the ID of the domain.
// Returns: a list of users who can access this domain.
func (d *DB) GetUsersForDomain(domainID uint) ([]ImcUser, error) {
var users []ImcUser
err := d.Table("imc_users2domains").
Select("imc_users.*"). // Select all columns from imc_users
Joins("JOIN imc_users ON imc_users.id = imc_users2domains.user_id"). // Join to users table
Where("imc_users2domains.domain_id = ?", domainID).
Find(&users).Error
if err != nil {
return nil, err
}
if users == nil {
users = []ImcUser{}
}
return users, nil
}
// IsUserInDomain checks if a user already has access to a domain.
// IsUserInDomain checks if a user already has access to a domain via many-to-many.
// userID: the ID of the user.
// domainID: the ID of the domain.
// Returns: true if the user already has access, false otherwise.