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 (user belongs to one of the rule's groups) are OR-ed together // - The final domain is: original AND 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, domain_force parsed 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 ir.rule records from DB // Mirrors: odoo/addons/base/models/ir_rule.py IrRule._get_rules() + _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 is global (no groups assigned), // or the user belongs to one of the rule's groups via rule_group_rel. // Use a savepoint so that a failed query (e.g., missing table) doesn't abort the parent tx. 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) AS is_global 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 AND ( r."global" = true OR r.id IN ( SELECT rg.rule_group_id FROM rule_group_rel rg JOIN res_groups_users_rel gu ON gu.gid = rg.group_id WHERE gu.uid = $2 ) ) ORDER BY r.id`, m.Name(), env.UID()) if err != nil { log.Printf("orm: ir.rule query failed for %s: %v — denying access", m.Name(), err) sp.Rollback(env.ctx) return append(domain, Leaf("id", "=", -1)) // Deny all — no records match id=-1 } type ruleRow struct { id int64 domainForce *string global bool } var rules []ruleRow for rows.Next() { var r ruleRow if err := rows.Scan(&r.id, &r.domainForce, &r.global); err != nil { continue } rules = append(rules, r) } rows.Close() if err := sp.Commit(env.ctx); err != nil { // Non-fatal: rules already read _ = err } if len(rules) == 0 { return domain } // Parse domain_force strings and split into global vs. group rules. // Mirrors: odoo/addons/base/models/ir_rule.py IrRule._compute_domain() // global rules → AND together // group rules → OR together // final = original AND all_global AND (group_1 OR group_2 OR ...) var globalDomains []DomainNode var groupDomains []DomainNode parseErrors := 0 for _, r := range rules { if r.domainForce == nil || *r.domainForce == "" || *r.domainForce == "[]" { // Empty domain_force = match everything, skip continue } parsed, err := ParseDomainString(*r.domainForce, env) if err != nil { parseErrors++ log.Printf("orm: failed to parse domain_force for ir.rule %d: %v (raw: %s)", r.id, err, *r.domainForce) continue } if len(parsed) == 0 { continue } if r.global { // Global rule: wrap as a single node for AND-ing globalDomains = append(globalDomains, domainAsNode(parsed)) } else { // Group rule: wrap as a single node for OR-ing groupDomains = append(groupDomains, domainAsNode(parsed)) } } if parseErrors > 0 { log.Printf("orm: %d ir.rule domain_force parse error(s) for %s", parseErrors, m.Name()) } // Merge group domains with OR if len(groupDomains) > 0 { orDomain := Or(groupDomains...) globalDomains = append(globalDomains, domainAsNode(orDomain)) } // AND all rule domains into the original domain if len(globalDomains) > 0 { ruleDomain := And(globalDomains...) if len(domain) == 0 { domain = ruleDomain } else { result := Domain{OpAnd} result = append(result, domain...) result = append(result, ruleDomain...) domain = result } } return domain } // domainAsNode wraps a Domain (which is a []DomainNode) into a single DomainNode // so it can be used as an operand for And() / Or(). // If the domain has a single node, return it directly. // If multiple nodes, wrap in a domainGroup. func domainAsNode(d Domain) DomainNode { if len(d) == 1 { return d[0] } return domainGroup(d) } // domainGroup wraps a Domain as a single DomainNode for use in And()/Or() combinations. // When compiled, it produces the same SQL as the contained domain. type domainGroup Domain func (dg domainGroup) isDomainNode() {} // 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 { log.Printf("orm: record rule check failed for %s: %v", m.Name(), err) return fmt.Errorf("orm: access denied on %s (record rule check failed)", m.Name()) } if count < int64(len(ids)) { return fmt.Errorf("orm: access denied by record rules on %s (company filter)", m.Name()) } return nil }