diff --git a/security_visualizer/README.rst b/security_visualizer/README.rst new file mode 100644 index 00000000000..761c73cf29f --- /dev/null +++ b/security_visualizer/README.rst @@ -0,0 +1,295 @@ +==================================== +Permissions & Access Rule Visualizer +==================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:d30227088b2ac95369cb4df9493b310d7b88fe6a848f15fd3307714a6417f71a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/16.0/security_visualizer + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-16-0/server-tools-16-0-security_visualizer + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-tools&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides a comprehensive security visualization and +debugging tool for Odoo. It makes Odoo's complex security system +(``ir.model.access`` and ``ir.rule``) understandable and debuggable. + +**Problem** + +Odoo's security system is powerful but notoriously difficult to +understand and debug: + +- Access rules are invisible and complex +- Debugging security is painful +- Small mistakes cause major data leaks or access blocks +- No clear way to answer "Why can't user X access record Y?" + +**Solution** + +This module provides: + +1. **Security Analyzer** - Detailed analysis of access decisions +2. **Access Matrix** - Visual grid showing user × model × operation + permissions +3. **Rule Explainer** - Step-by-step breakdown of security checks +4. **Safe Simulation** - Test access as any user without risk +5. **Multi-Company Analysis** - Understand company-specific security + rules +6. **Role-Based Access** - Analyze access through user roles (requires + base_user_role module) + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +No configuration needed. After installation, access the module via: + +**Menu Location**: Settings > Technical > Security > Security Visualizer + +1. **Security Dashboard** - Main interactive visualizer +2. **Quick Analyzer** - Simple wizard for quick analysis + +Access Control +-------------- + +By default, only users in the **Settings** group (``base.group_system``) +can access this module. + +This ensures that sensitive security information is only visible to +system administrators. + +Multi-Company Configuration +--------------------------- + +The multi-company analysis feature works automatically if your Odoo +instance uses multiple companies: + +- **No additional configuration needed** +- Analysis automatically detects models with ``company_id`` fields +- Shows which companies each user belongs to +- Identifies company-specific record rules + +Role-Based Access (Optional) +---------------------------- + +To use the role-based access analysis feature: + +1. **Install base_user_role module**: + + .. code:: bash + + # The module is typically available from OCA + # Add the OCA server-backend repository to your addons path + +2. **Assign roles to users**: + + - Go to **Settings > Users & Companies > Roles** + - Create roles with appropriate groups + - Assign roles to users + +3. **Use the analyzer**: + + - The Security Visualizer will automatically detect installed roles + - Analysis will include role information + - See which roles grant access to models + +If ``base_user_role`` is not installed: + +- The module works normally without role features +- Role-related methods return appropriate status messages +- All other features remain fully functional + +Usage +===== + +Analyze Specific Access +----------------------- + +1. Open **Security Visualizer** from **Settings > Technical > Security > + Security Dashboard** +2. Select a **User** from the dropdown +3. Select a **Model** (e.g., ``sale.order``) +4. Choose an **Operation** (read, write, create, delete) +5. Optionally enter a **Record ID** for specific record testing +6. Click **Analyze Access** +7. Review the detailed step-by-step explanation + +View Access Matrix +------------------ + +1. Open **Security Dashboard** from **Settings > Technical > Security** +2. Click the **Access Matrix** tab +3. Use the operation dropdown to filter by read/write/create/delete +4. Green checkmark = access allowed, Red X = access denied +5. Click any cell to see detailed analysis (coming in next version) + +Quick Analysis +-------------- + +1. Go to **Settings > Technical > Security > Quick Analyzer** +2. Fill in the form (user, model, operation, optional record ID) +3. Click **Analyze** +4. View results in HTML summary and JSON format + +Understanding the Analysis +-------------------------- + +**Step 1: Model-Level Access (ACL)** + +Shows all ``ir.model.access`` rules that apply: + +- Which groups grant permission +- Which specific CRUD operations are allowed +- Whether the user has the required group membership + +**Step 2: Record Rules** + +Shows ``ir.rule`` domain filters: + +- **Global rules** (no groups): ALL must be satisfied - AND logic +- **Group rules**: ANY can grant access - OR logic +- Displays actual domain syntax for each rule + +**Step 3: Simulation Result** + +If a record ID is provided: + +- Tests actual access on that specific record +- Safe, read-only simulation +- Clear explanation of final verdict (Allowed/Denied/Conditional) + +Multi-Company Security Analysis +------------------------------- + +**Analyze Company-Specific Access** + +1. Open **Security Visualizer** +2. Use the multi-company analysis feature (via RPC methods) +3. View which companies a user can access data from +4. See company-related record rules + +The analysis shows: + +- User's assigned companies +- Current active company +- Models with company_id field +- Company-specific record rules +- Which companies grant access to records + +**Company Access Matrix** + +Generate a matrix showing: + +- User x Company x Model permissions +- Which companies the user can access for each model +- Company-specific rule counts + +Role-Based Access Analysis +-------------------------- + +**Prerequisites** + +This feature requires the ``base_user_role`` module to be installed. + +**Analyze User Roles** + +1. Open **Security Visualizer** +2. Select a user +3. View their assigned roles +4. See which groups each role grants +5. Distinguish between role-based and direct group assignments + +The analysis shows: + +- All roles assigned to the user +- Groups granted by each role +- Groups assigned directly (not through roles) +- Total effective groups + +**Model Access with Roles** + +When analyzing model access: + +- See which roles grant access to the model +- Understand access through role hierarchy +- Identify if access is via role or direct group + +**Enhanced Explanations** + +Access decisions now include: + +- Step 0: User Roles (if roles are assigned) +- Which specific roles grant the required permission +- Whether access is role-based or direct + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Kobros-Tech + +Contributors +------------ + +- Mohamed Alkobrosli mohamed@kobros-tech.com + (`Kobros-Tech `__) + +Other credits +------------- + +**Development** + +This module was developed by **Kobros-Tech** (https://kobros-tech.com/) +to address the common challenge of understanding and debugging Odoo's +security system. + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/security_visualizer/__init__.py b/security_visualizer/__init__.py new file mode 100644 index 00000000000..5955dbbbb61 --- /dev/null +++ b/security_visualizer/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Kobros-Tech Ltd (http://kobros-tech.com). +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import models diff --git a/security_visualizer/__manifest__.py b/security_visualizer/__manifest__.py new file mode 100644 index 00000000000..77319593259 --- /dev/null +++ b/security_visualizer/__manifest__.py @@ -0,0 +1,31 @@ +# Copyright 2026 Kobros-Tech Ltd (http://kobros-tech.com). +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +{ + "name": "Permissions & Access Rule Visualizer", + "version": "16.0.1.0.0", + "category": "Tools", + "summary": "Visualize and debug Odoo security rules and access permissions", + "author": "Kobros-Tech, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-tools", + "license": "AGPL-3", + "depends": [ + "base", + "web", + ], + "data": [ + "security/ir.model.access.csv", + "views/security_visualizer_views.xml", + "views/security_visualizer_menus.xml", + ], + "assets": { + "web.assets_backend": [ + "security_visualizer/static/src/components/access_matrix/*", + "security_visualizer/static/src/components/rule_explainer/*", + "security_visualizer/static/src/components/security_visualizer/*", + ], + }, + "installable": True, + "application": True, + "auto_install": False, +} diff --git a/security_visualizer/models/__init__.py b/security_visualizer/models/__init__.py new file mode 100644 index 00000000000..d4ddac75352 --- /dev/null +++ b/security_visualizer/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 Kobros-Tech Ltd (http://kobros-tech.com) (http://kobros-tech.com). +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import security_analyzer +from . import security_visualizer_analysis diff --git a/security_visualizer/models/security_analyzer.py b/security_visualizer/models/security_analyzer.py new file mode 100644 index 00000000000..96699d65b55 --- /dev/null +++ b/security_visualizer/models/security_analyzer.py @@ -0,0 +1,1102 @@ +# Copyright 2026 Kobros-Tech Ltd (http://kobros-tech.com). +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import logging + +from odoo import _, api, models +from odoo.exceptions import AccessError + +_logger = logging.getLogger(__name__) + + +class SecurityAnalyzer(models.AbstractModel): + """Core security analysis logic - stateless pure functions""" + + _name = "security.analyzer" + _description = "Security Analysis Logic" + + @api.model + def _is_base_user_role_installed(self): + """Check if base_user_role module is installed""" + return "res.users.role" in self.env + + @api.model + def analyze_model_access(self, model_name, user_id, operation="read"): + """ + Analyze ACL (ir.model.access) for a user on a model. + + Args: + model_name (str): Technical model name (e.g., 'sale.order') + user_id (int): User ID to check + operation (str): One of 'read', 'write', 'create', 'unlink' + + Returns: + dict: { + 'has_access': bool, + 'applicable_rules': list of dicts with rule details, + 'explanation': str, + 'operation': str + } + """ + user = self.env["res.users"].browse(user_id) + user_groups = user.groups_id + + # Get all access rights for this model + access_rights = ( + self.env["ir.model.access"] + .sudo() + .search([("model_id.model", "=", model_name)]) + ) + + applicable_rules = [] + has_access = False + perm_field = f"perm_{operation}" + + for access in access_rights: + # Check if this access right applies to the user + applies = False + rule_info = { + "id": access.id, + "name": access.name, + "model": model_name, + "group": access.group_id.full_name + if access.group_id + else _("All Users (Global)"), + "group_id": access.group_id.id if access.group_id else False, + "permissions": { + "read": access.perm_read, + "write": access.perm_write, + "create": access.perm_create, + "unlink": access.perm_unlink, + }, + "grants_access": False, + "applies_to_user": False, + } + + # Global rule (no group) applies to everyone + if not access.group_id: + applies = True + # Group-specific rule + elif access.group_id in user_groups: + applies = True + + rule_info["applies_to_user"] = applies + + if applies and getattr(access, perm_field): + rule_info["grants_access"] = True + has_access = True + + applicable_rules.append(rule_info) + + # Generate explanation + if has_access: + granting_rules = [r for r in applicable_rules if r["grants_access"]] + explanation = _( + "User '%(user)s' has %(operation)s access to model" + " '%(model)s' via %(count)d access rule(s)." + ) % { + "user": user.name, + "operation": operation, + "model": model_name, + "count": len(granting_rules), + } + else: + explanation = _( + "User '%(user)s' does NOT have %(operation)s access" + " to model '%(model)s'. No applicable access rules" + " grant this permission." + ) % { + "user": user.name, + "operation": operation, + "model": model_name, + } + + return { + "has_access": has_access, + "applicable_rules": applicable_rules, + "explanation": explanation, + "operation": operation, + "user": user.name, + "model": model_name, + } + + @api.model + def analyze_record_rules(self, model_name, user_id, operation="read"): + """ + Analyze record rules (ir.rule) for a user on a model. + + Args: + model_name (str): Technical model name + user_id (int): User ID + operation (str): One of 'read', 'write', 'create', 'unlink' + + Returns: + dict: { + 'rules': list of rule details, + 'global_rules': list of global rules (AND logic), + 'group_rules': list of group rules (OR logic), + 'explanation': str + } + """ + user = self.env["res.users"].browse(user_id) + user_groups = user.groups_id + + # Get all record rules for this model + record_rules = ( + self.env["ir.rule"].sudo().search([("model_id.model", "=", model_name)]) + ) + + global_rules = [] + group_rules = [] + perm_field = f"perm_{operation}" + + for rule in record_rules: + # Check if this rule applies to the operation + if not getattr(rule, perm_field): + continue + + rule_info = { + "id": rule.id, + "name": rule.name, + "domain": rule.domain_force, + "global": getattr(rule, "global"), + "groups": [g.full_name for g in rule.groups], + "group_ids": rule.groups.ids, + "perm_read": rule.perm_read, + "perm_write": rule.perm_write, + "perm_create": rule.perm_create, + "perm_unlink": rule.perm_unlink, + "applies_to_user": False, + } + + # Global rules apply to everyone + if getattr(rule, "global"): + rule_info["applies_to_user"] = True + global_rules.append(rule_info) + # Group rules only apply if user is in one of the groups + elif any(group in user_groups for group in rule.groups): + rule_info["applies_to_user"] = True + group_rules.append(rule_info) + + # Generate explanation + explanation_parts = [] + + if global_rules: + explanation_parts.append( + _("%d global rule(s) apply (ALL must be satisfied - AND logic)") + % len(global_rules) + ) + + if group_rules: + explanation_parts.append( + _("%d group-specific rule(s) apply (ANY can grant access - OR logic)") + % len(group_rules) + ) + + if not global_rules and not group_rules: + explanation_parts.append(_("No record rules apply to this model")) + + return { + "rules": global_rules + group_rules, + "global_rules": global_rules, + "group_rules": group_rules, + "explanation": ". ".join(explanation_parts) + ".", + "user": user.name, + "model": model_name, + "operation": operation, + } + + @api.model + def explain_access_decision( + self, model_name, user_id, record_id=None, operation="read" + ): + """ + Comprehensive explanation of access decision. + + Args: + model_name (str): Technical model name + user_id (int): User ID + record_id (int, optional): Specific record ID to check + operation (str): Operation to check + + Returns: + dict: Complete analysis with step-by-step explanation + """ + # Step 1: Analyze model-level access (ACL) + model_access = self.analyze_model_access(model_name, user_id, operation) + + # Step 2: Analyze record rules + record_rules = self.analyze_record_rules(model_name, user_id, operation) + + # Step 3: If record_id provided, simulate actual access + simulation_result = None + if record_id: + simulation_result = self.simulate_user_access( + user_id, model_name, record_id, operation + ) + + # Generate final verdict + if not model_access["has_access"]: + final_verdict = "denied" + verdict_explanation = ( + _("Access DENIED: User lacks model-level %s permission") % operation + ) + elif record_rules["global_rules"] or record_rules["group_rules"]: + if simulation_result: + final_verdict = ( + "allowed" if simulation_result["has_access"] else "denied" + ) + verdict_explanation = simulation_result["explanation"] + else: + final_verdict = "conditional" + verdict_explanation = _( + "Model access granted, but record rules" + " apply. Specific records may be filtered." + ) + else: + final_verdict = "allowed" + verdict_explanation = _( + "Access ALLOWED: User has model permission" + " and no record rules restrict access" + ) + + return { + "model_access": model_access, + "record_rules": record_rules, + "simulation": simulation_result, + "final_verdict": final_verdict, + "verdict_explanation": verdict_explanation, + "steps": [ + { + "step": 1, + "title": _("Model-Level Access (ACL)"), + "result": _("Allowed") + if model_access["has_access"] + else _("Denied"), + "details": model_access["explanation"], + }, + { + "step": 2, + "title": _("Record Rules"), + "result": _("No rules") + if not record_rules["rules"] + else _("%d rules apply") % len(record_rules["rules"]), + "details": record_rules["explanation"], + }, + ], + } + + @api.model + def simulate_user_access(self, user_id, model_name, record_id, operation="read"): + """ + Simulate access check for a specific user and record (safe, read-only). + + Args: + user_id (int): User ID + model_name (str): Model name + record_id (int): Record ID + operation (str): Operation + + Returns: + dict: Simulation result with explanation + """ + user = self.env["res.users"].browse(user_id) + + try: + # Get the record + model = self.env[model_name] + record = model.browse(record_id).exists() + + if not record: + return { + "has_access": False, + "explanation": _( + "Record ID %(record_id)d does not exist" " in model '%(model)s'" + ) + % {"record_id": record_id, "model": model_name}, + "error": "record_not_found", + } + + # Simulate as the target user + record_as_user = record.with_user(user) + + # Check model-level access + try: + record_as_user.check_access_rights(operation) + except AccessError as e: + return { + "has_access": False, + "explanation": _("Model-level access denied: %s") % str(e), + "error": "model_access_denied", + } + + # Check record-level access + try: + record_as_user.check_access_rule(operation) + return { + "has_access": True, + "explanation": _( + "User '%(user)s' has %(operation)s access" + " to record #%(record_id)d of" + " model '%(model)s'" + ) + % { + "user": user.name, + "operation": operation, + "record_id": record_id, + "model": model_name, + }, + "error": None, + } + except AccessError as e: + return { + "has_access": False, + "explanation": _("Record rule denied access: %s") % str(e), + "error": "record_rule_denied", + } + + except Exception as e: + _logger.exception("Error simulating user access") + return { + "has_access": False, + "explanation": _("Simulation error: %s") % str(e), + "error": "simulation_error", + } + + @api.model + def get_access_matrix(self, user_ids=None, model_ids=None, operations=None): + """ + Generate access matrix for multiple users and models. + + Args: + user_ids (list, optional): List of user IDs. + If None or empty, return empty matrix. + model_ids (list, optional): List of model IDs. + If None or empty, return empty matrix. + operations (list, optional): List of operations. + Default: ['read', 'write', 'create', 'unlink'] + + Returns: + dict: Matrix data structure for visualization + """ + if operations is None: + operations = ["read", "write", "create", "unlink"] + + # Get users - NO DEFAULTS + if user_ids: + users = self.env["res.users"].browse(user_ids) + else: + users = self.env["res.users"] # Empty recordset + + # Get models - NO DEFAULTS + if model_ids: + models = self.env["ir.model"].browse(model_ids) + else: + models = self.env["ir.model"] # Empty recordset + + # Build matrix + matrix = { + "users": [{"id": u.id, "name": u.name, "login": u.login} for u in users], + "models": [{"id": m.id, "name": m.name, "model": m.model} for m in models], + "operations": operations, + "cells": {}, # Key: "user_id,model_id,operation" -> value: bool + } + + # Calculate access for each combination (only if both users and models exist) + for user in users: + for model in models: + for operation in operations: + key = f"{user.id},{model.id},{operation}" + analysis = self.analyze_model_access( + model.model, user.id, operation + ) + matrix["cells"][key] = { + "has_access": analysis["has_access"], + "rule_count": len(analysis["applicable_rules"]), + } + + return matrix + + @api.model + def get_user_accessible_models(self, user_id, operation="read"): + """ + List all models a user can access. + + Args: + user_id (int): User ID + operation (str): Operation to check + + Returns: + list: List of accessible model names with details + """ + user = self.env["res.users"].browse(user_id) + user_groups = user.groups_id + + # Get all access rights + access_rights = self.env["ir.model.access"].sudo().search([]) + perm_field = f"perm_{operation}" + + accessible_models = {} + + for access in access_rights: + if not getattr(access, perm_field): + continue + + # Check if applies to user + if not access.group_id or access.group_id in user_groups: + model_name = access.model_id.model + if model_name not in accessible_models: + accessible_models[model_name] = { + "model": model_name, + "name": access.model_id.name, + "access_rules": [], + } + accessible_models[model_name]["access_rules"].append(access.name) + + return list(accessible_models.values()) + + @api.model + def analyze_multicompany_access(self, model_name, user_id, operation="read"): + """ + Analyze multi-company security for a user on a model. + + Args: + model_name (str): Technical model name + user_id (int): User ID + operation (str): Operation to check + + Returns: + dict: Multi-company analysis with company-specific rules + """ + user = self.env["res.users"].browse(user_id) + + # Get user's companies + user_companies = user.company_ids + current_company = user.company_id + + # Check if model has company_id field + model_obj = self.env[model_name] + has_company_field = "company_id" in model_obj._fields + + # Get company-related record rules + record_rules = ( + self.env["ir.rule"].sudo().search([("model_id.model", "=", model_name)]) + ) + + company_rules = [] + perm_field = f"perm_{operation}" + + for rule in record_rules: + if not getattr(rule, perm_field): + continue + + # Check if rule has company-related domain + domain = rule.domain_force or "[]" + if "company_id" in domain or "company_ids" in domain: + rule_info = { + "id": rule.id, + "name": rule.name, + "domain": domain, + "global": getattr(rule, "global"), + "groups": [g.full_name for g in rule.groups], + "applies_to_user": False, + } + + # Check if rule applies to user + if getattr(rule, "global"): + rule_info["applies_to_user"] = True + elif any(group in user.groups_id for group in rule.groups): + rule_info["applies_to_user"] = True + + if rule_info["applies_to_user"]: + company_rules.append(rule_info) + + # Analyze accessible companies + accessible_companies = [] + if has_company_field: + # Try to determine which companies the user can access records from + for company in user_companies: + accessible_companies.append( + { + "id": company.id, + "name": company.name, + "is_current": company.id == current_company.id, + } + ) + + return { + "user": user.name, + "user_id": user_id, + "model": model_name, + "operation": operation, + "has_company_field": has_company_field, + "user_companies": [ + {"id": c.id, "name": c.name, "is_current": c.id == current_company.id} + for c in user_companies + ], + "current_company": {"id": current_company.id, "name": current_company.name}, + "company_rules": company_rules, + "accessible_companies": accessible_companies, + "explanation": self._generate_multicompany_explanation( + user, model_name, has_company_field, company_rules, user_companies + ), + } + + def _generate_multicompany_explanation( + self, user, model_name, has_company_field, company_rules, user_companies + ): + """Generate human-readable explanation of multi-company access""" + explanation_parts = [] + + explanation_parts.append( + _("User '%(user)s' belongs to" " %(count)d company/companies.") + % {"user": user.name, "count": len(user_companies)} + ) + + if has_company_field: + explanation_parts.append( + _("Model '%s' has a company_id field, so access is company-restricted.") + % model_name + ) + else: + explanation_parts.append( + _("Model '%s' does not have a company_id field (company-independent).") + % model_name + ) + + if company_rules: + explanation_parts.append( + _("%d company-related record rule(s) apply to this model.") + % len(company_rules) + ) + else: + explanation_parts.append( + _("No company-specific record rules found for this model.") + ) + + if has_company_field and user_companies: + company_names = ", ".join([c.name for c in user_companies]) + explanation_parts.append( + _("User can access records from these companies: %s") % company_names + ) + + return " ".join(explanation_parts) + + @api.model + def get_company_access_matrix(self, user_id, company_ids=None): + """ + Generate access matrix showing permissions per company. + + Args: + user_id (int): User ID + company_ids (list, optional): List of company IDs to analyze + + Returns: + dict: Matrix data showing access per company + """ + user = self.env["res.users"].browse(user_id) + + # Get companies to analyze + if company_ids: + companies = self.env["res.company"].browse(company_ids) + else: + companies = user.company_ids + + # Find models with company_id field + models_with_company = [] + common_models = [ + "sale.order", + "purchase.order", + "account.move", + "stock.picking", + "project.project", + "hr.employee", + ] + + for model_name in common_models: + try: + if ( + model_name in self.env + and "company_id" in self.env[model_name]._fields + ): + model = self.env["ir.model"].search( + [("model", "=", model_name)], limit=1 + ) + if model: + models_with_company.append( + {"id": model.id, "name": model.name, "model": model.model} + ) + except KeyError: + continue + + # Build matrix + matrix = { + "user": {"id": user.id, "name": user.name}, + "companies": [{"id": c.id, "name": c.name} for c in companies], + "models": models_with_company, + "cells": {}, # Key: "company_id,model_id" -> analysis + } + + # Analyze each combination + for company in companies: + for model_dict in models_with_company: + key = f"{company.id},{model_dict['id']}" + + analysis = self.analyze_multicompany_access( + model_dict["model"], user_id, "read" + ) + + matrix["cells"][key] = { + "has_access": company.id + in [c["id"] for c in analysis["user_companies"]], + "company_rules": len(analysis["company_rules"]), + } + + return matrix + + @api.model + def analyze_user_roles(self, user_id): + """ + Analyze user's roles if base_user_role module is installed. + + Args: + user_id (int): User ID + + Returns: + dict: Role analysis with groups granted by each role + """ + if not self._is_base_user_role_installed(): + return { + "module_installed": False, + "roles": [], + "explanation": _( + "Module 'base_user_role' is not installed." + " Install it to use role-based access" + " analysis." + ), + } + + user = self.env["res.users"].browse(user_id) + + # Get user's roles + user_roles = ( + self.env["res.users.role"] + .sudo() + .search([("line_ids.user_id", "=", user_id)]) + ) + + roles_data = [] + all_groups_from_roles = self.env["res.groups"] + + for role in user_roles: + role_groups = role.group_id + role.implied_ids + all_groups_from_roles |= role_groups + + roles_data.append( + { + "id": role.id, + "name": role.name, + "groups": [{"id": g.id, "name": g.full_name} for g in role_groups], + "group_count": len(role_groups), + } + ) + + # Groups granted directly (not through roles) + direct_groups = user.groups_id - all_groups_from_roles + + return { + "module_installed": True, + "user": user.name, + "user_id": user_id, + "roles": roles_data, + "role_count": len(user_roles), + "groups_from_roles": [ + {"id": g.id, "name": g.full_name} for g in all_groups_from_roles + ], + "direct_groups": [{"id": g.id, "name": g.full_name} for g in direct_groups], + "total_groups": len(user.groups_id), + "explanation": self._generate_role_explanation( + user, user_roles, all_groups_from_roles, direct_groups + ), + } + + def _generate_role_explanation(self, user, roles, groups_from_roles, direct_groups): + """Generate human-readable explanation of role-based access""" + explanation_parts = [] + + if roles: + explanation_parts.append( + _("User '%(user)s' has %(count)d" " role(s) assigned.") + % {"user": user.name, "count": len(roles)} + ) + + explanation_parts.append( + _("These roles grant %(count)d group(s) in total.") + % {"count": len(groups_from_roles)} + ) + else: + explanation_parts.append( + _("User '%(user)s' has no roles assigned.") % {"user": user.name} + ) + + if direct_groups: + explanation_parts.append( + _( + "Additionally, user has %(count)d group(s)" + " assigned directly (not through roles)." + ) + % {"count": len(direct_groups)} + ) + + explanation_parts.append( + _("Total effective groups: %(count)d") % {"count": len(user.groups_id)} + ) + + return " ".join(explanation_parts) + + @api.model + def analyze_model_access_with_roles(self, model_name, user_id, operation="read"): + """ + Extended model access analysis that includes role information. + + Args: + model_name (str): Technical model name + user_id (int): User ID + operation (str): Operation to check + + Returns: + dict: Enhanced analysis with role information + """ + # Get standard access analysis + base_analysis = self.analyze_model_access(model_name, user_id, operation) + + # Add role information if module is installed + if not self._is_base_user_role_installed(): + base_analysis["role_analysis"] = { + "module_installed": False, + "roles_granting_access": [], + } + return base_analysis + + # Analyze which roles grant access + user = self.env["res.users"].browse(user_id) + user_roles = ( + self.env["res.users.role"] + .sudo() + .search([("line_ids.user_id", "=", user_id)]) + ) + + roles_granting_access = [] + + for role in user_roles: + role_groups = role.group_id + role.implied_ids + + # Check if any of this role's groups grant access + for rule in base_analysis["applicable_rules"]: + if rule["grants_access"] and rule["applies_to_user"]: + if rule["group_id"] and rule["group_id"] in role_groups.ids: + if role.id not in [r["id"] for r in roles_granting_access]: + roles_granting_access.append( + { + "id": role.id, + "name": role.name, + "grants_via_group": rule["group"], + } + ) + + base_analysis["role_analysis"] = { + "module_installed": True, + "roles_granting_access": roles_granting_access, + "explanation": self._generate_role_access_explanation( + user, roles_granting_access, base_analysis["has_access"] + ), + } + + return base_analysis + + def _generate_role_access_explanation( + self, user, roles_granting_access, has_access + ): + """Generate explanation of role-based access grants""" + if not has_access: + return _( + "User does not have access (no roles grant the required permission)." + ) + + if not roles_granting_access: + return _("Access granted through direct group membership (not via roles).") + + role_names = ", ".join([r["name"] for r in roles_granting_access]) + return _("Access granted through %(count)d role(s):" " %(roles)s") % { + "count": len(roles_granting_access), + "roles": role_names, + } + + @api.model + def explain_access_decision_with_roles( + self, model_name, user_id, record_id=None, operation="read" + ): + """ + Comprehensive explanation including role information. + + Args: + model_name (str): Technical model name + user_id (int): User ID + record_id (int, optional): Specific record ID + operation (str): Operation to check + + Returns: + dict: Complete analysis with role information + """ + # Get base explanation + explanation = self.explain_access_decision( + model_name, user_id, record_id, operation + ) + + # Enhance with role analysis + if self._is_base_user_role_installed(): + role_analysis = self.analyze_user_roles(user_id) + model_access_with_roles = self.analyze_model_access_with_roles( + model_name, user_id, operation + ) + + explanation["role_analysis"] = role_analysis + explanation["model_access"]["role_details"] = model_access_with_roles.get( + "role_analysis", {} + ) + + # Add role step to explanation steps + if role_analysis["role_count"] > 0: + explanation["steps"].insert( + 0, + { + "step": 0, + "title": _("User Roles"), + "result": _("%d roles assigned") % role_analysis["role_count"], + "details": role_analysis["explanation"], + }, + ) + + return explanation + + @api.model + def analyze_crud_summary(self, model_name, user_id, record_id=None): + """ + Comprehensive CRUD analysis - all 4 operations with conflict detection. + + This is the KEY method that shows: + 1. Final YES/NO for each CRUD operation + 2. Conflicts between groups (Group A says YES, Group B says NO) + 3. How Odoo resolves conflicts (OR logic - ANY group grants access) + 4. Clear summary table + + Args: + model_name (str): Technical model name + user_id (int): User ID + record_id (int, optional): Specific record ID to test + + Returns: + dict: { + 'operations': { + 'create': {'allowed': bool, 'conflicts': list, 'explanation': str}, + 'read': {...}, + 'write': {...}, + 'unlink': {...} + }, + 'conflicts_detected': bool, + 'conflict_explanation': str, + 'summary_table': list of operation summaries + } + """ + user = self.env["res.users"].browse(user_id) + user_groups = user.groups_id + operations = ["create", "read", "write", "unlink"] + + results = {} + conflicts_detected = False + conflict_details = [] + + for operation in operations: + # Get all access rights for this model and operation + access_rights = ( + self.env["ir.model.access"] + .sudo() + .search([("model_id.model", "=", model_name)]) + ) + + perm_field = f"perm_{operation}" + granting_groups = [] + denying_groups = [] + global_grants = False + + for access in access_rights: + has_permission = getattr(access, perm_field) + + # Global rule (no group) + if not access.group_id: + if has_permission: + global_grants = True + granting_groups.append( + { + "name": _("All Users (Global)"), + "rule_name": access.name, + "grants": True, + } + ) + else: + denying_groups.append( + { + "name": _("All Users (Global)"), + "rule_name": access.name, + "grants": False, + } + ) + # Group-specific rule + elif access.group_id in user_groups: + if has_permission: + granting_groups.append( + { + "name": access.group_id.full_name, + "rule_name": access.name, + "grants": True, + } + ) + else: + denying_groups.append( + { + "name": access.group_id.full_name, + "rule_name": access.name, + "grants": False, + } + ) + + # ODOO LOGIC: If ANY group grants access, user has access (OR logic) + final_allowed = bool(granting_groups) or global_grants + + # Detect conflicts + has_conflict = bool(granting_groups) and bool(denying_groups) + if has_conflict: + conflicts_detected = True + conflict_details.append( + { + "operation": operation.upper(), + "granting": [g["name"] for g in granting_groups], + "denying": [d["name"] for d in denying_groups], + } + ) + + # Build explanation + if not granting_groups and not denying_groups: + explanation = ( + _("No access rules apply to this user for %s operation.") + % operation.upper() + ) + final_allowed = False + elif global_grants: + explanation = ( + _("✓ GRANTED: Global rule grants %s access to all users.") + % operation.upper() + ) + elif granting_groups and not has_conflict: + group_names = ", ".join([g["name"] for g in granting_groups]) + explanation = _( + "GRANTED: User's groups (%(groups)s)" " grant %(operation)s access." + ) % { + "groups": group_names, + "operation": operation.upper(), + } + elif has_conflict: + grant_names = ", ".join([g["name"] for g in granting_groups]) + deny_names = ", ".join([d["name"] for d in denying_groups]) + explanation = _( + "CONFLICT RESOLVED: User belongs to groups" + " that GRANT access (%(grant)s) and groups" + " that DENY access (%(deny)s). RESULT:" + " ACCESS GRANTED (Odoo uses OR logic - if" + " ANY group grants access, user can" + " %(operation)s)." + ) % { + "grant": grant_names, + "deny": deny_names, + "operation": operation.upper(), + } + elif denying_groups: + deny_names = ", ".join([d["name"] for d in denying_groups]) + explanation = _( + "DENIED: User's groups (%(groups)s) do not" + " grant %(operation)s access." + ) % { + "groups": deny_names, + "operation": operation.upper(), + } + else: + explanation = ( + _("✗ DENIED: No applicable rules grant %s access.") + % operation.upper() + ) + + results[operation] = { + "allowed": final_allowed, + "granting_groups": granting_groups, + "denying_groups": denying_groups, + "has_conflict": has_conflict, + "explanation": explanation, + } + + # Generate conflict summary + if conflicts_detected: + conflict_explanation = _( + "IMPORTANT: Conflicts detected! User belongs" + " to groups with contradicting permissions." + " Odoo resolves this using OR logic: if ANY" + " group grants access, the user CAN perform" + " the operation. This means granting groups" + " always win over denying groups." + ) + else: + conflict_explanation = _( + "✓ No conflicts: All user's groups have consistent permissions." + ) + + # Build summary table + summary_table = [] + for operation in operations: + op_data = results[operation] + summary_table.append( + { + "operation": operation.upper(), + "operation_display": { + "create": _("Create"), + "read": _("Read"), + "write": _("Write/Update"), + "unlink": _("Delete"), + }[operation], + "allowed": op_data["allowed"], + "has_conflict": op_data["has_conflict"], + "granting_count": len(op_data["granting_groups"]), + "denying_count": len(op_data["denying_groups"]), + "verdict": _("✓ ALLOWED") if op_data["allowed"] else _("✗ DENIED"), + "verdict_class": "success" if op_data["allowed"] else "danger", + } + ) + + return { + "user": user.name, + "user_id": user_id, + "model": model_name, + "operations": results, + "conflicts_detected": conflicts_detected, + "conflict_explanation": conflict_explanation, + "conflict_details": conflict_details, + "summary_table": summary_table, + "odoo_logic_explanation": _( + "Odoo Access Control Logic:\n" + "1. Model Access (ACL): ANY group granting" + " access = Access granted (OR logic)\n" + "2. Record Rules: GLOBAL rules use AND logic" + " (all must pass), GROUP rules use OR logic" + " (any can grant)\n" + "3. If you see conflicts, the granting" + " permission always wins at the ACL level." + ), + } diff --git a/security_visualizer/models/security_visualizer_analysis.py b/security_visualizer/models/security_visualizer_analysis.py new file mode 100644 index 00000000000..e0ec8432435 --- /dev/null +++ b/security_visualizer/models/security_visualizer_analysis.py @@ -0,0 +1,205 @@ +# Copyright 2026 Kobros-Tech Ltd (http://kobros-tech.com). +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import json + +from odoo import _, api, fields, models + + +class SecurityVisualizerAnalysis(models.TransientModel): + """Transient model for storing analysis sessions (temporary)""" + + _name = "security.visualizer.analysis" + _description = "Security Analysis Session" + + # Analysis parameters + target_user_id = fields.Many2one("res.users", required=True) + target_model_id = fields.Many2one("ir.model", required=True) + target_record_id = fields.Integer("Record ID") + operation = fields.Selection( + [ + ("read", "Read"), + ("write", "Write"), + ("create", "Create"), + ("unlink", "Delete"), + ], + default="read", + required=True, + ) + + # Results (computed/stored as JSON) + analysis_result = fields.Text("Analysis Result (JSON)", readonly=True) + has_access = fields.Boolean( + compute="_compute_has_access", + store=False, + ) + analysis_summary_html = fields.Html("Analysis Summary", compute="_compute_summary") + + @api.depends("analysis_result") + def _compute_has_access(self): + """Extract has_access from JSON result""" + for record in self: + if record.analysis_result: + try: + result = json.loads(record.analysis_result) + record.has_access = result.get("final_verdict") == "allowed" + except (ValueError, KeyError): + record.has_access = False + else: + record.has_access = False + + @api.depends("analysis_result") + def _compute_summary(self): + """Generate HTML summary from JSON result""" + for record in self: + if not record.analysis_result: + record.analysis_summary_html = "

No analysis performed yet.

" + continue + + try: + result = json.loads(record.analysis_result) + html = '
' + html += f'

{result.get("verdict_explanation", "")}

' + + # Model access section + if "model_access" in result: + ma = result["model_access"] + html += "

Model Access:

" + html += f'

{ma.get("explanation", "")}

' + + # Record rules section + if "record_rules" in result: + rr = result["record_rules"] + html += "

Record Rules:

" + html += f'

{rr.get("explanation", "")}

' + if rr.get("rules"): + html += "
    " + for rule in rr["rules"]: + html += ("
  • %s" ": %s
  • ") % ( + rule["name"], + rule["domain"], + ) + html += "
" + + html += "
" + record.analysis_summary_html = html + except (ValueError, KeyError) as e: + record.analysis_summary_html = ( + f"

Error parsing analysis: {str(e)}

" + ) + + def action_analyze(self): + """Perform the analysis and store results""" + self.ensure_one() + + analyzer = self.env["security.analyzer"] + + # Perform comprehensive analysis + if self.target_record_id: + result = analyzer.explain_access_decision( + self.target_model_id.model, + self.target_user_id.id, + self.target_record_id, + self.operation, + ) + else: + # Just model and rules analysis, no specific record + model_access = analyzer.analyze_model_access( + self.target_model_id.model, self.target_user_id.id, self.operation + ) + record_rules = analyzer.analyze_record_rules( + self.target_model_id.model, self.target_user_id.id, self.operation + ) + result = { + "model_access": model_access, + "record_rules": record_rules, + "final_verdict": "allowed" if model_access["has_access"] else "denied", + "verdict_explanation": model_access["explanation"], + } + + # Store as JSON + self.analysis_result = json.dumps(result, indent=2) + + return { + "type": "ir.actions.act_window", + "res_model": self._name, + "res_id": self.id, + "view_mode": "form", + "target": "new", + } + + @api.model + def action_open_analyzer(self): + """Open the analyzer form (called from menu)""" + return { + "name": _("Security Analyzer"), + "type": "ir.actions.act_window", + "res_model": self._name, + "view_mode": "form", + "target": "new", + "context": { + "default_operation": "read", + }, + } + + @api.model + def rpc_analyze_multicompany_access(self, model_name, user_id, operation="read"): + """ + RPC method for multi-company access analysis. + Called from frontend JavaScript. + """ + analyzer = self.env["security.analyzer"] + return analyzer.analyze_multicompany_access(model_name, user_id, operation) + + @api.model + def rpc_get_company_access_matrix(self, user_id, company_ids=None): + """ + RPC method for company access matrix. + Called from frontend JavaScript. + """ + analyzer = self.env["security.analyzer"] + return analyzer.get_company_access_matrix(user_id, company_ids) + + @api.model + def rpc_analyze_user_roles(self, user_id): + """ + RPC method for user role analysis. + Called from frontend JavaScript. + """ + analyzer = self.env["security.analyzer"] + return analyzer.analyze_user_roles(user_id) + + @api.model + def rpc_analyze_model_access_with_roles( + self, model_name, user_id, operation="read" + ): + """ + RPC method for model access analysis with role information. + Called from frontend JavaScript. + """ + analyzer = self.env["security.analyzer"] + return analyzer.analyze_model_access_with_roles(model_name, user_id, operation) + + @api.model + def rpc_explain_access_decision_with_roles( + self, model_name, user_id, record_id=None, operation="read" + ): + """ + RPC method for comprehensive access explanation with roles. + Called from frontend JavaScript. + """ + analyzer = self.env["security.analyzer"] + return analyzer.explain_access_decision_with_roles( + model_name, user_id, record_id, operation + ) + + @api.model + def rpc_analyze_crud_summary(self, model_name, user_id, record_id=None): + """ + RPC method for comprehensive CRUD summary with conflict detection. + Called from frontend JavaScript. + + This returns ALL 4 CRUD operations with conflict detection. + """ + analyzer = self.env["security.analyzer"] + return analyzer.analyze_crud_summary(model_name, user_id, record_id) diff --git a/security_visualizer/readme/CONFIGURE.md b/security_visualizer/readme/CONFIGURE.md new file mode 100644 index 00000000000..95b4fb333af --- /dev/null +++ b/security_visualizer/readme/CONFIGURE.md @@ -0,0 +1,46 @@ +No configuration needed. After installation, access the module via: + +**Menu Location**: Settings > Technical > Security > Security Visualizer + +1. **Security Dashboard** - Main interactive visualizer +2. **Quick Analyzer** - Simple wizard for quick analysis + +## Access Control + +By default, only users in the **Settings** group (`base.group_system`) can access this module. + +This ensures that sensitive security information is only visible to system administrators. + +## Multi-Company Configuration + +The multi-company analysis feature works automatically if your Odoo instance uses multiple companies: + +* **No additional configuration needed** +* Analysis automatically detects models with `company_id` fields +* Shows which companies each user belongs to +* Identifies company-specific record rules + +## Role-Based Access (Optional) + +To use the role-based access analysis feature: + +1. **Install base_user_role module**: + ```bash + # The module is typically available from OCA + # Add the OCA server-backend repository to your addons path + ``` + +2. **Assign roles to users**: + - Go to **Settings > Users & Companies > Roles** + - Create roles with appropriate groups + - Assign roles to users + +3. **Use the analyzer**: + - The Security Visualizer will automatically detect installed roles + - Analysis will include role information + - See which roles grant access to models + +If `base_user_role` is not installed: +- The module works normally without role features +- Role-related methods return appropriate status messages +- All other features remain fully functional diff --git a/security_visualizer/readme/CONTRIBUTORS.md b/security_visualizer/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..05bfd4bdb56 --- /dev/null +++ b/security_visualizer/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Mohamed Alkobrosli ([Kobros-Tech](https://kobros-tech.com/)) diff --git a/security_visualizer/readme/CREDITS.md b/security_visualizer/readme/CREDITS.md new file mode 100644 index 00000000000..c499eaf43e7 --- /dev/null +++ b/security_visualizer/readme/CREDITS.md @@ -0,0 +1,4 @@ +**Development** + +This module was developed by **Kobros-Tech** (https://kobros-tech.com/) to address the +common challenge of understanding and debugging Odoo's security system. diff --git a/security_visualizer/readme/DESCRIPTION.md b/security_visualizer/readme/DESCRIPTION.md new file mode 100644 index 00000000000..ccc371e3bb3 --- /dev/null +++ b/security_visualizer/readme/DESCRIPTION.md @@ -0,0 +1,23 @@ +This module provides a comprehensive security visualization and debugging tool for Odoo. +It makes Odoo's complex security system (`ir.model.access` and `ir.rule`) understandable +and debuggable. + +**Problem** + +Odoo's security system is powerful but notoriously difficult to understand and debug: + +* Access rules are invisible and complex +* Debugging security is painful +* Small mistakes cause major data leaks or access blocks +* No clear way to answer "Why can't user X access record Y?" + +**Solution** + +This module provides: + +1. **Security Analyzer** - Detailed analysis of access decisions +2. **Access Matrix** - Visual grid showing user × model × operation permissions +3. **Rule Explainer** - Step-by-step breakdown of security checks +4. **Safe Simulation** - Test access as any user without risk +5. **Multi-Company Analysis** - Understand company-specific security rules +6. **Role-Based Access** - Analyze access through user roles (requires base_user_role module) diff --git a/security_visualizer/readme/USAGE.md b/security_visualizer/readme/USAGE.md new file mode 100644 index 00000000000..1e020fe560c --- /dev/null +++ b/security_visualizer/readme/USAGE.md @@ -0,0 +1,104 @@ +## Analyze Specific Access + +1. Open **Security Visualizer** from **Settings > Technical > Security > Security Dashboard** +2. Select a **User** from the dropdown +3. Select a **Model** (e.g., `sale.order`) +4. Choose an **Operation** (read, write, create, delete) +5. Optionally enter a **Record ID** for specific record testing +6. Click **Analyze Access** +7. Review the detailed step-by-step explanation + +## View Access Matrix + +1. Open **Security Dashboard** from **Settings > Technical > Security** +2. Click the **Access Matrix** tab +3. Use the operation dropdown to filter by read/write/create/delete +4. Green checkmark = access allowed, Red X = access denied +5. Click any cell to see detailed analysis (coming in next version) + +## Quick Analysis + +1. Go to **Settings > Technical > Security > Quick Analyzer** +2. Fill in the form (user, model, operation, optional record ID) +3. Click **Analyze** +4. View results in HTML summary and JSON format + +## Understanding the Analysis + +**Step 1: Model-Level Access (ACL)** + +Shows all `ir.model.access` rules that apply: +- Which groups grant permission +- Which specific CRUD operations are allowed +- Whether the user has the required group membership + +**Step 2: Record Rules** + +Shows `ir.rule` domain filters: +- **Global rules** (no groups): ALL must be satisfied - AND logic +- **Group rules**: ANY can grant access - OR logic +- Displays actual domain syntax for each rule + +**Step 3: Simulation Result** + +If a record ID is provided: +- Tests actual access on that specific record +- Safe, read-only simulation +- Clear explanation of final verdict (Allowed/Denied/Conditional) + +## Multi-Company Security Analysis + +**Analyze Company-Specific Access** + +1. Open **Security Visualizer** +2. Use the multi-company analysis feature (via RPC methods) +3. View which companies a user can access data from +4. See company-related record rules + +The analysis shows: +- User's assigned companies +- Current active company +- Models with company_id field +- Company-specific record rules +- Which companies grant access to records + +**Company Access Matrix** + +Generate a matrix showing: +- User x Company x Model permissions +- Which companies the user can access for each model +- Company-specific rule counts + +## Role-Based Access Analysis + +**Prerequisites** + +This feature requires the `base_user_role` module to be installed. + +**Analyze User Roles** + +1. Open **Security Visualizer** +2. Select a user +3. View their assigned roles +4. See which groups each role grants +5. Distinguish between role-based and direct group assignments + +The analysis shows: +- All roles assigned to the user +- Groups granted by each role +- Groups assigned directly (not through roles) +- Total effective groups + +**Model Access with Roles** + +When analyzing model access: +- See which roles grant access to the model +- Understand access through role hierarchy +- Identify if access is via role or direct group + +**Enhanced Explanations** + +Access decisions now include: +- Step 0: User Roles (if roles are assigned) +- Which specific roles grant the required permission +- Whether access is role-based or direct diff --git a/security_visualizer/security/ir.model.access.csv b/security_visualizer/security/ir.model.access.csv new file mode 100644 index 00000000000..48f06b710ac --- /dev/null +++ b/security_visualizer/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink +access_security_analyzer_system,Security Analyzer - System,model_security_analyzer,base.group_system,1,0,0,0 +access_security_visualizer_analysis_system,Security Visualizer Analysis - System,model_security_visualizer_analysis,base.group_system,1,1,1,1 diff --git a/security_visualizer/static/description/icon.png b/security_visualizer/static/description/icon.png new file mode 100644 index 00000000000..f2394b14c1e Binary files /dev/null and b/security_visualizer/static/description/icon.png differ diff --git a/security_visualizer/static/description/index.html b/security_visualizer/static/description/index.html new file mode 100644 index 00000000000..519a4189ac5 --- /dev/null +++ b/security_visualizer/static/description/index.html @@ -0,0 +1,648 @@ + + + + + +Permissions & Access Rule Visualizer + + + +
+

Permissions & Access Rule Visualizer

+ + +

Beta License: AGPL-3 OCA/server-tools Translate me on Weblate Try me on Runboat

+

This module provides a comprehensive security visualization and +debugging tool for Odoo. It makes Odoo’s complex security system +(ir.model.access and ir.rule) understandable and debuggable.

+

Problem

+

Odoo’s security system is powerful but notoriously difficult to +understand and debug:

+
    +
  • Access rules are invisible and complex
  • +
  • Debugging security is painful
  • +
  • Small mistakes cause major data leaks or access blocks
  • +
  • No clear way to answer “Why can’t user X access record Y?”
  • +
+

Solution

+

This module provides:

+
    +
  1. Security Analyzer - Detailed analysis of access decisions
  2. +
  3. Access Matrix - Visual grid showing user × model × operation +permissions
  4. +
  5. Rule Explainer - Step-by-step breakdown of security checks
  6. +
  7. Safe Simulation - Test access as any user without risk
  8. +
  9. Multi-Company Analysis - Understand company-specific security +rules
  10. +
  11. Role-Based Access - Analyze access through user roles (requires +base_user_role module)
  12. +
+

Table of contents

+ +
+

Configuration

+

No configuration needed. After installation, access the module via:

+

Menu Location: Settings > Technical > Security > Security Visualizer

+
    +
  1. Security Dashboard - Main interactive visualizer
  2. +
  3. Quick Analyzer - Simple wizard for quick analysis
  4. +
+
+

Access Control

+

By default, only users in the Settings group (base.group_system) +can access this module.

+

This ensures that sensitive security information is only visible to +system administrators.

+
+
+

Multi-Company Configuration

+

The multi-company analysis feature works automatically if your Odoo +instance uses multiple companies:

+
    +
  • No additional configuration needed
  • +
  • Analysis automatically detects models with company_id fields
  • +
  • Shows which companies each user belongs to
  • +
  • Identifies company-specific record rules
  • +
+
+
+

Role-Based Access (Optional)

+

To use the role-based access analysis feature:

+
    +
  1. Install base_user_role module:

    +
    +# The module is typically available from OCA
    +# Add the OCA server-backend repository to your addons path
    +
    +
  2. +
  3. Assign roles to users:

    +
      +
    • Go to Settings > Users & Companies > Roles
    • +
    • Create roles with appropriate groups
    • +
    • Assign roles to users
    • +
    +
  4. +
  5. Use the analyzer:

    +
      +
    • The Security Visualizer will automatically detect installed roles
    • +
    • Analysis will include role information
    • +
    • See which roles grant access to models
    • +
    +
  6. +
+

If base_user_role is not installed:

+
    +
  • The module works normally without role features
  • +
  • Role-related methods return appropriate status messages
  • +
  • All other features remain fully functional
  • +
+
+
+
+

Usage

+
+

Analyze Specific Access

+
    +
  1. Open Security Visualizer from Settings > Technical > Security > +Security Dashboard
  2. +
  3. Select a User from the dropdown
  4. +
  5. Select a Model (e.g., sale.order)
  6. +
  7. Choose an Operation (read, write, create, delete)
  8. +
  9. Optionally enter a Record ID for specific record testing
  10. +
  11. Click Analyze Access
  12. +
  13. Review the detailed step-by-step explanation
  14. +
+
+
+

View Access Matrix

+
    +
  1. Open Security Dashboard from Settings > Technical > Security
  2. +
  3. Click the Access Matrix tab
  4. +
  5. Use the operation dropdown to filter by read/write/create/delete
  6. +
  7. Green checkmark = access allowed, Red X = access denied
  8. +
  9. Click any cell to see detailed analysis (coming in next version)
  10. +
+
+
+

Quick Analysis

+
    +
  1. Go to Settings > Technical > Security > Quick Analyzer
  2. +
  3. Fill in the form (user, model, operation, optional record ID)
  4. +
  5. Click Analyze
  6. +
  7. View results in HTML summary and JSON format
  8. +
+
+
+

Understanding the Analysis

+

Step 1: Model-Level Access (ACL)

+

Shows all ir.model.access rules that apply:

+
    +
  • Which groups grant permission
  • +
  • Which specific CRUD operations are allowed
  • +
  • Whether the user has the required group membership
  • +
+

Step 2: Record Rules

+

Shows ir.rule domain filters:

+
    +
  • Global rules (no groups): ALL must be satisfied - AND logic
  • +
  • Group rules: ANY can grant access - OR logic
  • +
  • Displays actual domain syntax for each rule
  • +
+

Step 3: Simulation Result

+

If a record ID is provided:

+
    +
  • Tests actual access on that specific record
  • +
  • Safe, read-only simulation
  • +
  • Clear explanation of final verdict (Allowed/Denied/Conditional)
  • +
+
+
+

Multi-Company Security Analysis

+

Analyze Company-Specific Access

+
    +
  1. Open Security Visualizer
  2. +
  3. Use the multi-company analysis feature (via RPC methods)
  4. +
  5. View which companies a user can access data from
  6. +
  7. See company-related record rules
  8. +
+

The analysis shows:

+
    +
  • User’s assigned companies
  • +
  • Current active company
  • +
  • Models with company_id field
  • +
  • Company-specific record rules
  • +
  • Which companies grant access to records
  • +
+

Company Access Matrix

+

Generate a matrix showing:

+
    +
  • User x Company x Model permissions
  • +
  • Which companies the user can access for each model
  • +
  • Company-specific rule counts
  • +
+
+
+

Role-Based Access Analysis

+

Prerequisites

+

This feature requires the base_user_role module to be installed.

+

Analyze User Roles

+
    +
  1. Open Security Visualizer
  2. +
  3. Select a user
  4. +
  5. View their assigned roles
  6. +
  7. See which groups each role grants
  8. +
  9. Distinguish between role-based and direct group assignments
  10. +
+

The analysis shows:

+
    +
  • All roles assigned to the user
  • +
  • Groups granted by each role
  • +
  • Groups assigned directly (not through roles)
  • +
  • Total effective groups
  • +
+

Model Access with Roles

+

When analyzing model access:

+
    +
  • See which roles grant access to the model
  • +
  • Understand access through role hierarchy
  • +
  • Identify if access is via role or direct group
  • +
+

Enhanced Explanations

+

Access decisions now include:

+
    +
  • Step 0: User Roles (if roles are assigned)
  • +
  • Which specific roles grant the required permission
  • +
  • Whether access is role-based or direct
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Kobros-Tech
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

Development

+

This module was developed by Kobros-Tech (https://kobros-tech.com/) +to address the common challenge of understanding and debugging Odoo’s +security system.

+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/server-tools project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/security_visualizer/static/src/components/access_matrix/access_matrix.esm.js b/security_visualizer/static/src/components/access_matrix/access_matrix.esm.js new file mode 100644 index 00000000000..045e75e6e5f --- /dev/null +++ b/security_visualizer/static/src/components/access_matrix/access_matrix.esm.js @@ -0,0 +1,34 @@ +/** @odoo-module **/ + +/** Copyright 2026 Kobros-Tech Ltd (http://kobros-tech.com). + * License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + */ + +import {Component} from "@odoo/owl"; + +export class AccessMatrix extends Component { + get operationLabels() { + return {create: "C", read: "R", write: "W", unlink: "D"}; + } + + get operations() { + return ["create", "read", "write", "unlink"]; + } + + /** + * Get cell data for user, model, operation combination + */ + getCellData(userId, modelId, operation) { + const key = `${userId},${modelId},${operation}`; + return this.props.matrixData.cells[key] || {has_access: false, rule_count: 0}; + } + + getCellClass(hasAccess) { + return hasAccess ? "access-allowed" : "access-denied"; + } +} + +AccessMatrix.template = "security_visualizer.AccessMatrix"; +AccessMatrix.props = { + matrixData: {type: Object}, +}; diff --git a/security_visualizer/static/src/components/access_matrix/access_matrix.scss b/security_visualizer/static/src/components/access_matrix/access_matrix.scss new file mode 100644 index 00000000000..73489504a3c --- /dev/null +++ b/security_visualizer/static/src/components/access_matrix/access_matrix.scss @@ -0,0 +1,51 @@ +.access_matrix_container { + .access-matrix-table { + font-size: 0.9em; + + .sticky-header { + position: sticky; + left: 0; + background-color: #fff; + z-index: 2; + font-weight: bold; + } + + .user-header { + font-weight: 600; + white-space: nowrap; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + border-bottom: 1px solid #dee2e6; + } + + .op-header { + font-size: 0.8em; + font-weight: 600; + padding: 0.25rem 0.4rem; + color: #6c757d; + } + + .model-name { + position: sticky; + left: 0; + background-color: #fff; + z-index: 1; + font-weight: 500; + max-width: 250px; + } + + .access-cell { + min-width: 30px; + padding: 0.25rem; + + &.access-allowed { + background-color: #d4edda; + } + + &.access-denied { + background-color: #f8d7da; + } + } + } +} diff --git a/security_visualizer/static/src/components/access_matrix/access_matrix.xml b/security_visualizer/static/src/components/access_matrix/access_matrix.xml new file mode 100644 index 00000000000..7a44dfd8a91 --- /dev/null +++ b/security_visualizer/static/src/components/access_matrix/access_matrix.xml @@ -0,0 +1,105 @@ + + + + + +
+ +
+ + Showing users x + models + (C = Create, R = Read, W = Write, D = Delete) +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Model / User + +
+ +
+ +
+ +
+ + + + + + +
+
+ + +
+ Legend: + = Allowed | + = Denied +
+ +
+
+ +
diff --git a/security_visualizer/static/src/components/rule_explainer/rule_explainer.css b/security_visualizer/static/src/components/rule_explainer/rule_explainer.css new file mode 100644 index 00000000000..b0bc086bf00 --- /dev/null +++ b/security_visualizer/static/src/components/rule_explainer/rule_explainer.css @@ -0,0 +1,49 @@ +/* Rule Explainer - Minimal Layout */ + +/* Collapsible step headers */ +.step-header { + cursor: pointer; + user-select: none; +} + +.step-header:hover { + background-color: #f0f0f0; +} + +.step-chevron { + width: 1em; + text-align: center; + font-size: 0.8em; + color: #6c757d; +} + +/* Rule display */ +.rule-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.rule-item { + background: #f8f9fa; + border-left: 3px solid var(--primary); + padding: 0.75rem 1rem; + border-radius: 0.25rem; +} + +.rule-name { + font-weight: 600; + margin-bottom: 0.5rem; +} + +.rule-domain { + background: #fff; + border: 1px solid #dee2e6; + border-radius: 0.25rem; + padding: 0.75rem; + margin: 0; + font-size: 0.85rem; + overflow-x: auto; + max-height: 300px; + overflow-y: auto; +} diff --git a/security_visualizer/static/src/components/rule_explainer/rule_explainer.esm.js b/security_visualizer/static/src/components/rule_explainer/rule_explainer.esm.js new file mode 100644 index 00000000000..9fbb39e1530 --- /dev/null +++ b/security_visualizer/static/src/components/rule_explainer/rule_explainer.esm.js @@ -0,0 +1,107 @@ +/** @odoo-module **/ + +/** Copyright 2026 Kobros-Tech Ltd (http://kobros-tech.com). + * License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + */ + +import {Component, useState} from "@odoo/owl"; + +export class RuleExplainer extends Component { + setup() { + this.state = useState({ + step1: false, + step2: false, + step3: true, + }); + } + + toggleStep(step) { + this.state[step] = !this.state[step]; + } + + formatDomain(domain) { + if (typeof domain === "string") { + return domain; + } + return JSON.stringify(domain, null, 2); + } + + get context() { + return this.props.analysisResult.context || {}; + } + + get crudSummary() { + return this.props.analysisResult.crud_summary || {}; + } + + get summaryTable() { + return this.crudSummary.summary_table || []; + } + + get recordRulesPerOp() { + return this.props.analysisResult.record_rules_per_op || {}; + } + + get simulations() { + return this.props.analysisResult.simulations || null; + } + + get hasConflicts() { + return this.crudSummary.conflicts_detected || false; + } + + get operations() { + return ["create", "read", "write", "unlink"]; + } + + get operationLabels() { + return {create: "Create", read: "Read", write: "Write", unlink: "Delete"}; + } + + get operationShortLabels() { + return {create: "C", read: "R", write: "W", unlink: "D"}; + } + + /** + * Quick verdict badges for Step 1 header (shown when collapsed) + */ + get step1Badges() { + return this.summaryTable.map((row) => ({ + label: row.operation.charAt(0), + allowed: row.allowed, + })); + } + + /** + * Count total unique record rules across all operations + */ + get totalRecordRules() { + const seen = new Set(); + for (const op of this.operations) { + const opRules = this.recordRulesPerOp[op]; + if (opRules && opRules.rules) { + for (const rule of opRules.rules) { + seen.add(rule.id); + } + } + } + return seen.size; + } + + /** + * Get record rules that apply to a specific operation + */ + getRulesForOp(op) { + return this.recordRulesPerOp[op] || {}; + } + + hasRulesForOp(op) { + const opRules = this.recordRulesPerOp[op]; + return opRules && opRules.rules && opRules.rules.length > 0; + } +} + +RuleExplainer.template = "security_visualizer.RuleExplainer"; +RuleExplainer.props = { + analysisResult: {type: Object}, +}; diff --git a/security_visualizer/static/src/components/rule_explainer/rule_explainer.xml b/security_visualizer/static/src/components/rule_explainer/rule_explainer.xml new file mode 100644 index 00000000000..6c78ee72cf4 --- /dev/null +++ b/security_visualizer/static/src/components/rule_explainer/rule_explainer.xml @@ -0,0 +1,318 @@ + + + + + +
+ + + +
+ Analyzing: + User + on model + () +
+
+ + +
+
+
+ + + Step 1: Model-Level Access (ACL) + + + + + + + + + Conflict + + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
OperationVerdictConflict?Granting GroupsDenying GroupsExplanation
+ + + + + + + + Yes + + + No + + + + + + + +
+
+ + + +
+ + +
+
+ +
+ + +
+
+
+
+
+
+ + +
+
+
+ + + Step 2: Record Rules (ir.rule) + + + rule(s) + + +
+
+ +
+ + + +
+ rules: +
+ + + +
+ + + Global (AND logic): + +
+ +
+
+
+
+
+
+
+
+ + + +
+ + + Group (OR logic): + +
+ +
+
+ + + () + +
+
+
+
+
+
+
+
+
+ + +
+ + No record rules apply to this model. All records are accessible if model access is granted. +
+
+
+
+
+ + + +
+
+
+ + + Step 3: Record Simulation + + + + + + + + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + +
OperationAccessExplanation
+ + GRANTED + DENIED + + + + + + + + +
+
+
+
+
+
+ +
+
+ +
diff --git a/security_visualizer/static/src/components/security_visualizer/security_visualizer.css b/security_visualizer/static/src/components/security_visualizer/security_visualizer.css new file mode 100644 index 00000000000..c874900e0a9 --- /dev/null +++ b/security_visualizer/static/src/components/security_visualizer/security_visualizer.css @@ -0,0 +1,26 @@ +/* Scroll to top button */ +.scroll-to-top { + position: fixed; + bottom: 2rem; + right: 2rem; + width: 40px; + height: 40px; + background: var(--primary); + color: white; + border: none; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s, visibility 0.3s; +} + +.scroll-to-top.visible { + opacity: 1; + visibility: visible; +} diff --git a/security_visualizer/static/src/components/security_visualizer/security_visualizer.esm.js b/security_visualizer/static/src/components/security_visualizer/security_visualizer.esm.js new file mode 100644 index 00000000000..f8613f407a2 --- /dev/null +++ b/security_visualizer/static/src/components/security_visualizer/security_visualizer.esm.js @@ -0,0 +1,350 @@ +/** @odoo-module **/ + +/** Copyright 2026 Kobros-Tech Ltd (http://kobros-tech.com). + * License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + */ + +import { + Component, + onMounted, + onWillStart, + onWillUnmount, + useRef, + useState, +} from "@odoo/owl"; +import {AutoComplete} from "@web/core/autocomplete/autocomplete"; +import {TagsList} from "@web/views/fields/many2many_tags/tags_list"; +import {AccessMatrix} from "../access_matrix/access_matrix.esm"; +import {RuleExplainer} from "../rule_explainer/rule_explainer.esm"; +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; + +export class SecurityVisualizer extends Component { + setup() { + this.orm = useService("orm"); + this.notification = useService("notification"); + + this.state = useState({ + currentView: "analyzer", + + // Analyzer single selectors + selectedUserId: null, + selectedUserDisplay: "", + selectedModelDisplay: "", + selectedModelTechnical: "", + recordId: null, + + // Analysis results + analysisResult: null, + isAnalyzing: false, + + // Matrix multi selectors + matrixUserIds: [], + matrixUserTags: [], + matrixModelIds: [], + matrixModelTags: [], + + // Matrix data + matrixData: null, + isLoadingMatrix: false, + + // UI state + showScrollTop: false, + }); + + onWillStart(async () => { + const currentUser = this.env.services.user; + this.state.selectedUserId = currentUser.userId; + // Fetch display name for current user + const users = await this.orm.read( + "res.users", + [currentUser.userId], + ["name"] + ); + if (users.length) { + this.state.selectedUserDisplay = users[0].name; + } + }); + + this.scrollRef = useRef("scrollContainer"); + this._onScroll = this.onScroll.bind(this); + + onMounted(() => { + if (this.scrollRef.el) { + this.scrollRef.el.addEventListener("scroll", this._onScroll); + } + }); + + onWillUnmount(() => { + if (this.scrollRef.el) { + this.scrollRef.el.removeEventListener("scroll", this._onScroll); + } + }); + } + + onScroll() { + const el = this.scrollRef.el; + this.state.showScrollTop = el && el.scrollTop > 300; + } + + scrollToTop() { + if (this.scrollRef.el) { + this.scrollRef.el.scrollTo({top: 0, behavior: "smooth"}); + } + } + + // --- AutoComplete sources --- + + get userSources() { + return [ + { + options: async (request) => { + const results = await this.orm.call( + "res.users", + "name_search", + [], + {name: request, limit: 8} + ); + return results.map(([id, name]) => ({ + resId: id, + label: name, + displayName: name, + })); + }, + }, + ]; + } + + get modelSources() { + return [ + { + options: async (request) => { + const results = await this.orm.call("ir.model", "name_search", [], { + name: request, + limit: 8, + }); + // Fetch technical names for disambiguation + const ids = results.map(([id]) => id); + const models = ids.length + ? await this.orm.read("ir.model", ids, ["model", "name"]) + : []; + const modelMap = {}; + for (const m of models) { + modelMap[m.id] = m; + } + return results.map(([id, name]) => { + const rec = modelMap[id]; + const technical = rec ? rec.model : ""; + return { + resId: id, + label: rec ? `${rec.name} (${technical})` : name, + displayName: rec ? `${rec.name} (${technical})` : name, + technicalName: technical, + }; + }); + }, + }, + ]; + } + + // --- Analyzer single selectors --- + + onUserSelected(option) { + this.state.selectedUserId = option.resId; + this.state.selectedUserDisplay = option.displayName; + this.state.analysisResult = null; + } + + onModelSelected(option) { + this.state.selectedModelDisplay = option.displayName; + this.state.selectedModelTechnical = option.technicalName; + this.state.analysisResult = null; + } + + // --- Matrix multi selectors --- + + onMatrixUserSelected(option) { + if (this.state.matrixUserIds.includes(option.resId)) { + return; + } + this.state.matrixUserTags.push({ + text: option.displayName, + resId: option.resId, + colorIndex: this.state.matrixUserTags.length % 11, + }); + this.state.matrixUserIds.push(option.resId); + this.state.matrixData = null; + } + + removeMatrixUser(userId) { + const idx = this.state.matrixUserIds.indexOf(userId); + if (idx !== -1) { + this.state.matrixUserIds.splice(idx, 1); + this.state.matrixUserTags.splice(idx, 1); + this.state.matrixData = null; + } + } + + onMatrixModelSelected(option) { + if (this.state.matrixModelIds.includes(option.resId)) { + return; + } + this.state.matrixModelTags.push({ + text: option.displayName, + resId: option.resId, + colorIndex: this.state.matrixModelTags.length % 11, + }); + this.state.matrixModelIds.push(option.resId); + this.state.matrixData = null; + } + + removeMatrixModel(modelId) { + const idx = this.state.matrixModelIds.indexOf(modelId); + if (idx !== -1) { + this.state.matrixModelIds.splice(idx, 1); + this.state.matrixModelTags.splice(idx, 1); + this.state.matrixData = null; + } + } + + get matrixUserTagsList() { + return this.state.matrixUserTags.map((tag) => ({ + text: tag.text, + id: tag.resId, + colorIndex: tag.colorIndex, + onDelete: () => this.removeMatrixUser(tag.resId), + })); + } + + get matrixModelTagsList() { + return this.state.matrixModelTags.map((tag) => ({ + text: tag.text, + id: tag.resId, + colorIndex: tag.colorIndex, + onDelete: () => this.removeMatrixModel(tag.resId), + })); + } + + // --- Record ID --- + + onRecordIdChange(ev) { + const value = ev.target.value; + this.state.recordId = value ? parseInt(value, 10) : null; + } + + // --- View switching --- + + switchView(viewName) { + this.state.currentView = viewName; + if (viewName === "matrix" && !this.state.matrixData) { + this.loadAccessMatrix(); + } + } + + // --- Analyzer: CRUD summary (all 4 operations) --- + + async analyzeAccess() { + if (!this.state.selectedUserId || !this.state.selectedModelTechnical) { + this.notification.add("Please select both a user and a model", { + type: "warning", + }); + return; + } + + this.state.isAnalyzing = true; + this.state.analysisResult = null; + + try { + const modelName = this.state.selectedModelTechnical; + + // Fetch CRUD summary (all 4 operations with conflict detection) + const crudSummary = await this.orm.call( + "security.visualizer.analysis", + "rpc_analyze_crud_summary", + [modelName, this.state.selectedUserId, this.state.recordId] + ); + + // Fetch record rules per operation + const ops = ["create", "read", "write", "unlink"]; + const recordRulesPerOp = {}; + for (const op of ops) { + recordRulesPerOp[op] = await this.orm.call( + "security.analyzer", + "analyze_record_rules", + [modelName, this.state.selectedUserId, op] + ); + } + + // If record ID is set, simulate access for all 4 operations + let simulations = null; + if (this.state.recordId) { + simulations = {}; + for (const op of ops) { + simulations[op] = await this.orm.call( + "security.analyzer", + "simulate_user_access", + [this.state.selectedUserId, modelName, this.state.recordId, op] + ); + } + } + + this.state.analysisResult = { + crud_summary: crudSummary, + record_rules_per_op: recordRulesPerOp, + simulations: simulations, + context: { + userName: this.state.selectedUserDisplay, + modelName: this.state.selectedModelDisplay, + technicalModel: modelName, + }, + }; + + this.notification.add("Analysis completed", {type: "success"}); + } catch (error) { + console.error("Analysis error:", error); + this.notification.add("Analysis failed: " + error.message, { + type: "danger", + }); + } finally { + this.state.isAnalyzing = false; + } + } + + // --- Matrix --- + + async loadAccessMatrix() { + this.state.isLoadingMatrix = true; + + try { + const userIds = + this.state.matrixUserIds.length > 0 ? this.state.matrixUserIds : null; + const modelIds = + this.state.matrixModelIds.length > 0 ? this.state.matrixModelIds : null; + + const matrixData = await this.orm.call( + "security.analyzer", + "get_access_matrix", + [userIds, modelIds, ["create", "read", "write", "unlink"]] + ); + + this.state.matrixData = matrixData; + } catch (error) { + console.error("Matrix loading error:", error); + this.notification.add("Failed to load access matrix: " + error.message, { + type: "danger", + }); + } finally { + this.state.isLoadingMatrix = false; + } + } +} + +SecurityVisualizer.template = "security_visualizer.SecurityVisualizer"; +SecurityVisualizer.components = { + AutoComplete, + TagsList, + RuleExplainer, + AccessMatrix, +}; + +registry.category("actions").add("security_visualizer", SecurityVisualizer); diff --git a/security_visualizer/static/src/components/security_visualizer/security_visualizer.xml b/security_visualizer/static/src/components/security_visualizer/security_visualizer.xml new file mode 100644 index 00000000000..318b7943a3d --- /dev/null +++ b/security_visualizer/static/src/components/security_visualizer/security_visualizer.xml @@ -0,0 +1,235 @@ + + + + + +
+
+ + +
+
+

Security Visualizer

+ +
+
+ + +
+
+ +
+
+
+
Analysis Parameters
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+ + +
+ + + + + +
+ + Select a user and model, then click "Analyze Access" to see a detailed CRUD security analysis. +
+
+
+
+
+ + +
+
+
+ +
+
+
+
+ + Matrix Filters +
+
+
+ +
+ +
+ +
+ + + Search and add users to compare + +
+ + +
+ +
+ +
+ + + Search and add models to compare + +
+ + + + +
+ + + Select users and models to compare their access rights across all CRUD operations. + +
+
+
+
+ + +
+ +
+ +

Loading access matrix...

+
+
+ + + + +
+
+ +
No Matrix Generated Yet
+

+ Select users and models from the filters, then click "Generate Access Matrix" to compare access rights. +

+
+
+
+
+
+
+
+ + + + +
+
+
+ +
diff --git a/security_visualizer/tests/__init__.py b/security_visualizer/tests/__init__.py new file mode 100644 index 00000000000..9afbdea3e95 --- /dev/null +++ b/security_visualizer/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Kobros-Tech Ltd (http://kobros-tech.com). +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import test_security_analyzer diff --git a/security_visualizer/tests/test_security_analyzer.py b/security_visualizer/tests/test_security_analyzer.py new file mode 100644 index 00000000000..67c1aa66b8d --- /dev/null +++ b/security_visualizer/tests/test_security_analyzer.py @@ -0,0 +1,255 @@ +# Copyright 2026 Kobros-Tech Ltd (http://kobros-tech.com). +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo.tests.common import TransactionCase + + +class TestSecurityAnalyzer(TransactionCase): + """Test cases for security.analyzer model""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Use existing demo user to avoid user creation issues + # Look for demo user or any non-admin internal user + cls.test_user = cls.env["res.users"].search( + [ + ("share", "=", False), # Internal user + ("id", "!=", 1), # Not OdooBot + ("id", "!=", 2), # Not admin + ], + limit=1, + ) + + # If no suitable user found, use admin as fallback + if not cls.test_user: + cls.test_user = cls.env.ref("base.user_admin") + + cls.test_model = "res.partner" + + def setUp(self): + super(TestSecurityAnalyzer, self).setUp() + self.analyzer = self.env["security.analyzer"] + + def test_analyze_model_access_allowed(self): + """Test analyzing model access when user has permission""" + result = self.analyzer.analyze_model_access( + self.test_model, self.test_user.id, "read" + ) + + self.assertTrue( + result["has_access"], "User should have read access to res.partner" + ) + self.assertEqual(result["operation"], "read") + self.assertEqual(result["model"], self.test_model) + self.assertIn("applicable_rules", result) + + def test_analyze_model_access_denied(self): + """Test analyzing model access when user lacks permission""" + # Use a model that test_user likely doesn't have access to + result = self.analyzer.analyze_model_access( + "ir.cron", # Scheduled actions - restricted model + self.test_user.id, + "write", + ) + + # This may pass or fail depending on configuration, just check structure + self.assertIn("has_access", result) + self.assertIn("applicable_rules", result) + self.assertIn("explanation", result) + + def test_analyze_record_rules(self): + """Test analyzing record rules for a model""" + result = self.analyzer.analyze_record_rules( + self.test_model, self.test_user.id, "read" + ) + + self.assertIn("rules", result) + self.assertIn("global_rules", result) + self.assertIn("group_rules", result) + self.assertIn("explanation", result) + self.assertIsInstance(result["rules"], list) + + def test_explain_access_decision(self): + """Test comprehensive access explanation""" + result = self.analyzer.explain_access_decision( + self.test_model, + self.test_user.id, + record_id=None, # No specific record + operation="read", + ) + + self.assertIn("model_access", result) + self.assertIn("record_rules", result) + self.assertIn("final_verdict", result) + self.assertIn("verdict_explanation", result) + self.assertIn("steps", result) + + # Check verdict is one of expected values + self.assertIn(result["final_verdict"], ["allowed", "denied", "conditional"]) + + def test_simulate_user_access_no_record(self): + """Test simulating access when record doesn't exist""" + result = self.analyzer.simulate_user_access( + self.test_user.id, self.test_model, 999999, "read" # Non-existent record + ) + + self.assertFalse(result["has_access"]) + self.assertEqual(result["error"], "record_not_found") + + def test_simulate_user_access_with_record(self): + """Test simulating access for an existing record""" + # Create a test partner record + partner = self.env["res.partner"].create( + { + "name": "Test Partner", + } + ) + + result = self.analyzer.simulate_user_access( + self.test_user.id, self.test_model, partner.id, "read" + ) + + self.assertIn("has_access", result) + self.assertIn("explanation", result) + + def test_get_access_matrix(self): + """Test generating access matrix""" + result = self.analyzer.get_access_matrix( + user_ids=[self.test_user.id], + model_ids=None, # Use defaults + operations=["read", "write"], + ) + + self.assertIn("users", result) + self.assertIn("models", result) + self.assertIn("operations", result) + self.assertIn("cells", result) + + self.assertEqual(len(result["users"]), 1) + self.assertEqual(result["operations"], ["read", "write"]) + self.assertIsInstance(result["cells"], dict) + + def test_get_user_accessible_models(self): + """Test getting list of accessible models for user""" + result = self.analyzer.get_user_accessible_models(self.test_user.id, "read") + + self.assertIsInstance(result, list) + # User should have access to at least some models + self.assertGreater(len(result), 0) + + # Check structure of returned items + if result: + item = result[0] + self.assertIn("model", item) + self.assertIn("name", item) + self.assertIn("access_rules", item) + + def test_analyze_multicompany_access(self): + """Test multi-company access analysis""" + result = self.analyzer.analyze_multicompany_access( + self.test_model, self.test_user.id, "read" + ) + + # Check structure + self.assertIn("user", result) + self.assertIn("model", result) + self.assertIn("has_company_field", result) + self.assertIn("user_companies", result) + self.assertIn("current_company", result) + self.assertIn("company_rules", result) + self.assertIn("explanation", result) + + # User should belong to at least one company + self.assertGreater(len(result["user_companies"]), 0) + + def test_get_company_access_matrix(self): + """Test company access matrix generation""" + result = self.analyzer.get_company_access_matrix( + self.test_user.id, company_ids=None # Use user's companies + ) + + # Check structure + self.assertIn("user", result) + self.assertIn("companies", result) + self.assertIn("models", result) + self.assertIn("cells", result) + + self.assertEqual(result["user"]["id"], self.test_user.id) + self.assertIsInstance(result["companies"], list) + self.assertIsInstance(result["models"], list) + self.assertIsInstance(result["cells"], dict) + + def test_analyze_user_roles_module_not_installed(self): + """Test role analysis when base_user_role is not installed""" + result = self.analyzer.analyze_user_roles(self.test_user.id) + + # Should return status about module not installed + self.assertIn("module_installed", result) + self.assertIn("explanation", result) + + # If module not installed, should have empty roles + if not result["module_installed"]: + self.assertEqual(len(result["roles"]), 0) + + def test_analyze_model_access_with_roles(self): + """Test model access analysis with role information""" + result = self.analyzer.analyze_model_access_with_roles( + self.test_model, self.test_user.id, "read" + ) + + # Should have all standard model access fields + self.assertIn("has_access", result) + self.assertIn("applicable_rules", result) + self.assertIn("explanation", result) + + # Plus role-specific information + self.assertIn("role_analysis", result) + self.assertIn("module_installed", result["role_analysis"]) + + def test_explain_access_decision_with_roles(self): + """Test comprehensive explanation with role information""" + result = self.analyzer.explain_access_decision_with_roles( + self.test_model, self.test_user.id, record_id=None, operation="read" + ) + + # Should have all standard explanation fields + self.assertIn("model_access", result) + self.assertIn("record_rules", result) + self.assertIn("final_verdict", result) + self.assertIn("steps", result) + + # Plus role analysis if module installed + if self.analyzer._is_base_user_role_installed(): + self.assertIn("role_analysis", result) + + def test_analyze_crud_summary(self): + """Test comprehensive CRUD summary with all 4 operations""" + result = self.analyzer.analyze_crud_summary(self.test_model, self.test_user.id) + + self.assertIn("operations", result) + self.assertIn("summary_table", result) + self.assertIn("conflicts_detected", result) + self.assertIn("conflict_explanation", result) + + # Should have exactly 4 operations in summary table + self.assertEqual(len(result["summary_table"]), 4) + + expected_ops = {"CREATE", "READ", "WRITE", "UNLINK"} + actual_ops = {row["operation"] for row in result["summary_table"]} + self.assertEqual(actual_ops, expected_ops) + + for row in result["summary_table"]: + self.assertIn("operation", row) + self.assertIn("allowed", row) + self.assertIn("has_conflict", row) + self.assertIn("verdict", row) + self.assertIn("granting_count", row) + self.assertIn("denying_count", row) + + # Check operations dict + for op in ["create", "read", "write", "unlink"]: + self.assertIn(op, result["operations"]) + op_data = result["operations"][op] + self.assertIn("allowed", op_data) + self.assertIn("explanation", op_data) diff --git a/security_visualizer/views/security_visualizer_menus.xml b/security_visualizer/views/security_visualizer_menus.xml new file mode 100644 index 00000000000..99a3b9999e5 --- /dev/null +++ b/security_visualizer/views/security_visualizer_menus.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + diff --git a/security_visualizer/views/security_visualizer_views.xml b/security_visualizer/views/security_visualizer_views.xml new file mode 100644 index 00000000000..ca21767a70c --- /dev/null +++ b/security_visualizer/views/security_visualizer_views.xml @@ -0,0 +1,99 @@ + + + + + + Security Visualizer + security_visualizer + current + + + + + Quick Security Analyzer + security.visualizer.analysis + form + new + { + 'default_operation': 'read', + } + + + + + security.visualizer.analysis.form + security.visualizer.analysis + +
+ +
+

Quick Security Analyzer

+

Analyze access rights for a specific user and model

+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ +
diff --git a/setup/security_visualizer/odoo/addons/security_visualizer b/setup/security_visualizer/odoo/addons/security_visualizer new file mode 120000 index 00000000000..ff39a7c7eb8 --- /dev/null +++ b/setup/security_visualizer/odoo/addons/security_visualizer @@ -0,0 +1 @@ +../../../../security_visualizer \ No newline at end of file diff --git a/setup/security_visualizer/setup.py b/setup/security_visualizer/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/security_visualizer/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)