package postgres import ( "database/sql" "gardomatic.kleiax.de/internal/storage" ) // GardenMemberModel stores garden memberships in PostgreSQL. // GardenMemberModel implements storage.GardenMemberModelInterface for PostgreSQL. type GardenMemberModel struct{ DB *sql.DB } func (m GardenMemberModel) loadPermissions(member *storage.GardenMember) error { ctx, cancel := contextWithTimeout() defer cancel() rows, err := m.DB.QueryContext(ctx, ` SELECT permission, true AS granted FROM role_permissions WHERE role_name=$1 UNION ALL SELECT permission, granted FROM garden_role_permission_overrides WHERE garden_id=$2 AND role_name=$1 ORDER BY granted DESC`, member.Role, member.GardenID) if err != nil { return err } defer rows.Close() base := []string{} overrides := []storage.GardenRolePermissionOverride{} for rows.Next() { var permission storage.GardenPermission var granted bool if err := rows.Scan(&permission, &granted); err != nil { return err } if granted { base = append(base, string(permission)) } else { overrides = append(overrides, storage.GardenRolePermissionOverride{GardenID: member.GardenID, RoleName: string(member.Role), Permission: string(permission), Granted: false}) } } if err := rows.Err(); err != nil { return err } member.Permissions = storage.ResolveGardenPermissions(base, overrides) return nil } func scanGardenMember(s scanner) (storage.GardenMember, error) { var member storage.GardenMember err := s.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt) return member, err } // Insert adds a user to a garden. func (m GardenMemberModel) Insert(member storage.GardenMember) (storage.GardenMember, error) { ctx, cancel := contextWithTimeout() defer cancel() err := m.DB.QueryRowContext(ctx, ` INSERT INTO garden_members (garden_id, user_id, role) SELECT $1, $2, $3 WHERE EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1)) RETURNING joined_at`, member.GardenID, member.UserID, member.Role, ).Scan(&member.JoinedAt) if err != nil { return storage.GardenMember{}, recordError(err) } if err = m.loadPermissions(&member); err != nil { return storage.GardenMember{}, err } return member, nil } // Get returns a user's membership and effective permissions in a garden. func (m GardenMemberModel) Get(gardenID, userID int) (storage.GardenMember, error) { ctx, cancel := contextWithTimeout() defer cancel() member, err := scanGardenMember(m.DB.QueryRowContext(ctx, ` SELECT garden_id, user_id, role, joined_at FROM garden_members WHERE garden_id = $1 AND user_id = $2`, gardenID, userID)) if err != nil { return storage.GardenMember{}, recordError(err) } if err = m.loadPermissions(&member); err != nil { return storage.GardenMember{}, err } return member, nil } // GetAllForGarden lists garden members with effective permissions. func (m GardenMemberModel) GetAllForGarden(gardenID int) ([]storage.GardenMember, error) { ctx, cancel := contextWithTimeout() defer cancel() rows, err := m.DB.QueryContext(ctx, ` SELECT gm.garden_id, gm.user_id, gm.role, gm.joined_at, u.name, u.email FROM garden_members gm JOIN users u ON u.id = gm.user_id WHERE gm.garden_id = $1 ORDER BY joined_at, user_id`, gardenID) if err != nil { return nil, err } defer rows.Close() members := []storage.GardenMember{} for rows.Next() { var member storage.GardenMember err := rows.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt, &member.Name, &member.Email) if err != nil { return nil, err } members = append(members, member) if err = m.loadPermissions(&members[len(members)-1]); err != nil { return nil, err } } return members, rows.Err() } // Update changes a member's garden role. func (m GardenMemberModel) Update(member storage.GardenMember) (storage.GardenMember, error) { ctx, cancel := contextWithTimeout() defer cancel() tx, err := m.DB.BeginTx(ctx, nil) if err != nil { return storage.GardenMember{}, err } defer tx.Rollback() if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, member.GardenID); err != nil { return storage.GardenMember{}, err } var current storage.GardenRole if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, member.GardenID, member.UserID).Scan(¤t); err != nil { return storage.GardenMember{}, recordError(err) } if current == storage.GardenRoleOwner && member.Role != storage.GardenRoleOwner { var owners int if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, member.GardenID).Scan(&owners); err != nil { return storage.GardenMember{}, err } if owners < 2 { return storage.GardenMember{}, storage.ErrConflict } } result, err := tx.ExecContext(ctx, `UPDATE garden_members SET role=$1 WHERE garden_id=$2 AND user_id=$3 AND EXISTS (SELECT 1 FROM roles WHERE name=$1 AND scope='garden' AND (garden_id IS NULL OR garden_id=$2))`, member.Role, member.GardenID, member.UserID) if err != nil { return storage.GardenMember{}, err } if count, countErr := result.RowsAffected(); countErr != nil { return storage.GardenMember{}, countErr } else if count == 0 { return storage.GardenMember{}, storage.ErrRecordNotFound } if err = tx.Commit(); err != nil { return storage.GardenMember{}, err } if err = m.loadPermissions(&member); err != nil { return storage.GardenMember{}, err } return member, nil } // Delete removes a non-owner membership from a garden. func (m GardenMemberModel) Delete(gardenID, userID int) error { ctx, cancel := contextWithTimeout() defer cancel() tx, err := m.DB.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil { return err } var role storage.GardenRole if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID).Scan(&role); err != nil { return recordError(err) } if role == storage.GardenRoleOwner { var owners int if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, gardenID).Scan(&owners); err != nil { return err } if owners < 2 { return storage.ErrConflict } } if _, err = tx.ExecContext(ctx, `DELETE FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID); err != nil { return err } return tx.Commit() } // TransferOwnership swaps owner and member roles atomically while preserving // the invariant that a garden always has one owner. func (m GardenMemberModel) TransferOwnership(gardenID, fromUserID, toUserID int) error { if fromUserID == toUserID { return storage.ErrConflict } ctx, cancel := contextWithTimeout() defer cancel() tx, err := m.DB.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil { return err } var fromRole, toRole storage.GardenRole if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID).Scan(&fromRole); err != nil { return recordError(err) } if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID).Scan(&toRole); err != nil { return recordError(err) } if fromRole != storage.GardenRoleOwner { return storage.ErrConflict } if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='owner' WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID); err != nil { return err } if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='admin' WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID); err != nil { return err } return tx.Commit() }