Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion spp_oauth/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "OpenSPP API: Oauth",
"summary": "The module establishes an OAuth 2.0 authentication framework, securing OpenSPP API communication for integrated systems and applications.",
"category": "OpenSPP",
"version": "19.0.2.0.0",
"version": "19.0.2.0.1",
"author": "OpenSPP.org",
"development_status": "Production/Stable",
"maintainers": ["jeremi", "gonzalesedwin1123", "reichie020212"],
Expand Down
18 changes: 18 additions & 0 deletions spp_oauth/models/res_config_settings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
from odoo import fields, models

# The OAuth signing keys are only meaningful to system administrators. Access to
# res.config.settings is restricted to base.group_system via Odoo core's ACL (this
# module no longer widens it), but default_get() reads config_parameter fields with
# sudo() and performs no access check, so it is guarded explicitly below.
OAUTH_KEY_FIELDS = ("oauth_priv_key", "oauth_pub_key")


class RegistryConfig(models.TransientModel):
_inherit = "res.config.settings"
Expand All @@ -12,3 +18,15 @@ class RegistryConfig(models.TransientModel):
string="OAuth Public Key",
config_parameter="spp_oauth.oauth_pub_key",
)

def default_get(self, fields_list):
res = super().default_get(fields_list)
# default_get sources config_parameter values via sudo(), bypassing model
# and field access checks. Never expose the OAuth signing keys to a user
# who is not a system administrator. Superuser mode (self.env.su) is a
# trusted server-side context that already bypasses ACLs, so honour it here
# too — env.user stays the original (possibly non-admin) user under sudo().
if not self.env.su and not self.env.user.has_group("base.group_system"):
for field_name in OAUTH_KEY_FIELDS:
res.pop(field_name, None)
return res
1 change: 0 additions & 1 deletion spp_oauth/security/ir.model.access.csv
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_res_config_settings_spp_oauth_user,res.config.settings spp_oauth user,base.model_res_config_settings,base.group_user,1,1,0,0
1 change: 1 addition & 0 deletions spp_oauth/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
from . import test_rsa_encode_decode
from . import test_oauth_errors
from . import test_config_settings_acl
87 changes: 87 additions & 0 deletions spp_oauth/tests/test_config_settings_acl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
"""Security: OAuth signing keys must be restricted to system administrators.

Regression test for "OAuth signing keys exposed to all internal users": the
module granted ``base.group_user`` read/write on ``res.config.settings``, which
exposes ``oauth_priv_key`` (the RS256 JWT signing key) and ``oauth_pub_key``.
Any internal user could therefore:
- read the model directly (``read``/``search``) via RPC, and
- retrieve the keys through ``res.config.settings.default_get()``, which reads
the backing ``ir.config_parameter`` values with ``sudo()`` and performs no
model-ACL or field-group check.

Access must require a system administrator (``base.group_system``), and the
signing keys must not leak through ``default_get`` to non-admins.
"""

from odoo import Command
from odoo.exceptions import AccessError
from odoo.tests import TransactionCase, tagged

PRIV_KEY_SENTINEL = "TEST-OAUTH-PRIVATE-KEY-DO-NOT-LEAK"
PUB_KEY_SENTINEL = "TEST-OAUTH-PUBLIC-KEY"


@tagged("post_install", "-at_install")
class TestOAuthConfigSettingsAcl(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
icp = cls.env["ir.config_parameter"].sudo()
icp.set_param("spp_oauth.oauth_priv_key", PRIV_KEY_SENTINEL)
icp.set_param("spp_oauth.oauth_pub_key", PUB_KEY_SENTINEL)

# A plain internal user: base.group_user only, no system access.
cls.plain_user = cls.env["res.users"].create(
{
"name": "Plain Internal User",
"login": "plain_internal_oauth_test",
"group_ids": [Command.link(cls.env.ref("base.group_user").id)],
}
)
# A system administrator.
cls.admin_user = cls.env["res.users"].create(
{
"name": "System Admin User",
"login": "system_admin_oauth_test",
"group_ids": [Command.link(cls.env.ref("base.group_system").id)],
}
)

def test_plain_user_cannot_access_settings_model(self):
"""base.group_user (any internal user) must NOT have read access to
res.config.settings — that is the model exposing the signing keys."""
with self.assertRaises(AccessError):
self.env["res.config.settings"].with_user(self.plain_user).check_access("read")

def test_plain_user_cannot_read_keys_via_default_get(self):
"""The signing keys must not leak to a non-admin through default_get(),
which reads the backing config parameters with sudo()."""
settings = self.env["res.config.settings"].with_user(self.plain_user)
values = settings.default_get(["oauth_priv_key", "oauth_pub_key"])
self.assertNotIn("oauth_priv_key", values)
self.assertNotIn("oauth_pub_key", values)

def test_superuser_mode_bypasses_key_filter(self):
"""Superuser mode (sudo) is a trusted server-side context that already
bypasses ACLs; default_get must not strip the keys there, even though
env.user remains the original non-admin user. The RPC attack path is
never in superuser mode, so this does not reopen the exposure."""
settings = self.env["res.config.settings"].with_user(self.plain_user).sudo()
self.assertTrue(settings.env.su)
values = settings.default_get(["oauth_priv_key", "oauth_pub_key"])
self.assertEqual(values.get("oauth_priv_key"), PRIV_KEY_SENTINEL)
self.assertEqual(values.get("oauth_pub_key"), PUB_KEY_SENTINEL)

def test_admin_can_access_settings_model(self):
"""A system administrator retains read access to res.config.settings."""
# Raises AccessError only if admin access was wrongly removed.
self.env["res.config.settings"].with_user(self.admin_user).check_access("read")

def test_admin_can_read_keys_via_default_get(self):
"""A system administrator can still read the signing keys, so the
Settings UI keeps working for admins."""
settings = self.env["res.config.settings"].with_user(self.admin_user)
values = settings.default_get(["oauth_priv_key", "oauth_pub_key"])
self.assertEqual(values.get("oauth_priv_key"), PRIV_KEY_SENTINEL)
self.assertEqual(values.get("oauth_pub_key"), PUB_KEY_SENTINEL)
Loading