package orm import ( "fmt" "log" ) // ApplyRecordRules adds ir.rule domain filters to a search. // Mirrors: odoo/addons/base/models/ir_rule.py IrRule._compute_domain() // // Rules work as follows: // - Global rules (no groups) are AND-ed together // - Group rules are OR-ed within the group set // - The final domain is: global_rules AND (group_rule_1 OR group_rule_2 OR ...) // // Implementation: // 1. Built-in company filter (for models with company_id) // 2. Custom ir.rule records loaded from the database func ApplyRecordRules(env *Environment, m *Model, domain Domain) Domain { if env.su { return domain // Superuser bypasses record rules } // 1. Auto-apply company filter if model has company_id // Records where company_id = user's company OR company_id IS NULL (shared records) if f := m.GetField("company_id"); f != nil && f.Type == TypeMany2one { myCompany := Leaf("company_id", "=", env.CompanyID()) noCompany := Leaf("company_id", "=", nil) companyFilter := Or(myCompany, noCompany) if len(domain) == 0 { domain = companyFilter } else { // AND the company filter with existing domain result := Domain{OpAnd} result = append(result, domain...) result = append(result, companyFilter...) domain = result } } // 2. Load custom ir.rule records from DB // Mirrors: odoo/addons/base/models/ir_rule.py IrRule._compute_domain() // // Query rules that apply to this model for the current user: // - Rule must be active and have perm_read = true // - Either the rule has no group restriction (global rule), // or the user belongs to one of the rule's groups. // Use a savepoint so that a failed query (e.g., missing junction table) // doesn't abort the parent transaction. sp, spErr := env.tx.Begin(env.ctx) if spErr != nil { return domain } rows, err := sp.Query(env.ctx, `SELECT r.id, r.domain_force, COALESCE(r.global, false) FROM ir_rule r JOIN ir_model m ON m.id = r.model_id WHERE m.model = $1 AND r.active = true AND r.perm_read = true`, m.Name()) if err != nil { sp.Rollback(env.ctx) return domain } defer func() { rows.Close() sp.Commit(env.ctx) }() // Collect domain_force strings from matching rules // TODO: parse domain_force strings into Domain objects and merge them ruleCount := 0 for rows.Next() { var ruleID int64 var domainForce *string var global bool if err := rows.Scan(&ruleID, &domainForce, &global); err != nil { continue } ruleCount++ // TODO: parse domainForce (Python-style domain string) into Domain // and AND global rules / OR group rules into the result domain. // For now, rules are loaded but domain parsing is deferred. _ = domainForce _ = global } if ruleCount > 0 { log.Printf("orm: loaded %d ir.rule record(s) for %s (domain parsing pending)", ruleCount, m.Name()) } return domain } // CheckRecordRuleAccess verifies the user can access specific record IDs. // Returns an error if any record is not accessible. func CheckRecordRuleAccess(env *Environment, m *Model, ids []int64, perm string) error { if env.su || len(ids) == 0 { return nil } // Check company_id if the model has it f := m.GetField("company_id") if f == nil || f.Type != TypeMany2one { return nil } // Count records that match the company filter placeholders := make([]string, len(ids)) args := make([]interface{}, len(ids)) for i, id := range ids { args[i] = id placeholders[i] = fmt.Sprintf("$%d", i+1) } args = append(args, env.CompanyID()) query := fmt.Sprintf( `SELECT COUNT(*) FROM %q WHERE "id" IN (%s) AND ("company_id" = $%d OR "company_id" IS NULL)`, m.Table(), joinStrings(placeholders, ", "), len(ids)+1, ) var count int64 err := env.tx.QueryRow(env.ctx, query, args...).Scan(&count) if err != nil { return nil // Fail open on error } if count < int64(len(ids)) { return fmt.Errorf("orm: access denied by record rules on %s (company filter)", m.Name()) } return nil }