Files
dannygroenewegen 238fbdc3fc Add auth_oidc_group_sync module
Fixes new SSO signups landing as Portal users instead of Internal
(unconditionally, for any OAuth provider, once installed), and syncs
Odoo groups from an OIDC provider's groups claim on every login.
2026-09-29 14:12:18 +02:00

129 lines
6.2 KiB
Python

from odoo.addons.base.tests.common import BaseCommon
class TestRoleSync(BaseCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.category = cls.env.ref("auth_oidc_group_sync.module_category_oidc_synced")
cls.user = cls.env["res.users"].create({
"name": "SSO Test User",
"login": "sso_test_user",
})
def test_first_claim_creates_group(self):
self.user._sync_oidc_groups({"groups": ["TestSyncAccountingTeam"]})
group = self.env["res.groups"].search([
("category_id", "=", self.category.id), ("name", "=", "TestSyncAccountingTeam"),
])
self.assertEqual(len(group), 1)
self.assertIn(group, self.user.groups_id)
def test_second_login_reuses_existing_group(self):
self.user._sync_oidc_groups({"groups": ["TestSyncAccountingTeam"]})
self.user._sync_oidc_groups({"groups": ["TestSyncAccountingTeam"]})
groups = self.env["res.groups"].search([
("category_id", "=", self.category.id), ("name", "=", "TestSyncAccountingTeam"),
])
self.assertEqual(len(groups), 1)
def test_add_and_remove_diffs_correctly(self):
self.user._sync_oidc_groups({"groups": ["TestSyncAccountingTeam", "TestSyncHRTeam"]})
accounting = self.env["res.groups"].search([
("category_id", "=", self.category.id), ("name", "=", "TestSyncAccountingTeam"),
])
hr = self.env["res.groups"].search([
("category_id", "=", self.category.id), ("name", "=", "TestSyncHRTeam"),
])
self.assertIn(accounting, self.user.groups_id)
self.assertIn(hr, self.user.groups_id)
self.user._sync_oidc_groups({"groups": ["TestSyncHRTeam"]})
self.assertNotIn(accounting, self.user.groups_id)
self.assertIn(hr, self.user.groups_id)
def test_missing_claim_skips_sync(self):
self.user._sync_oidc_groups({"groups": ["TestSyncAccountingTeam"]})
self.user._sync_oidc_groups({}) # no 'groups' key at all
group = self.env["res.groups"].search([
("category_id", "=", self.category.id), ("name", "=", "TestSyncAccountingTeam"),
])
self.assertIn(group, self.user.groups_id, "missing claim must not revoke anything")
def test_untracked_group_survives_sync(self):
manual_group = self.env["res.groups"].create({"name": "Manually Granted"})
self.user.write({"groups_id": [(4, manual_group.id)]})
self.user._sync_oidc_groups({"groups": ["TestSyncAccountingTeam"]})
self.assertIn(manual_group, self.user.groups_id)
def test_admin_bootstrap_group_syncs_like_any_other(self):
admin_group = self.env["res.groups"].create({
"name": "TestSyncAdminGroup",
"category_id": self.category.id,
"implied_ids": [(4, self.env.ref("base.group_system").id)],
})
self.user._sync_oidc_groups({"groups": ["TestSyncAdminGroup"]})
self.assertTrue(self.user.has_group("base.group_system"))
self.user._sync_oidc_groups({"groups": []})
self.assertNotIn(admin_group, self.user.groups_id)
self.assertFalse(self.user.has_group("base.group_system"))
def test_multi_level_chain_fully_unwound_when_unjustified(self):
# base.group_system itself implies base.group_erp_manager (Access Rights) and
# base.group_sanitize_override, a two-level chain. Revoking the top group must
# strip the whole chain when nothing else justifies any part of it.
admin_group = self.env["res.groups"].create({
"name": "TestSyncCascadeAdmin",
"category_id": self.category.id,
"implied_ids": [(4, self.env.ref("base.group_system").id)],
})
self.user._sync_oidc_groups({"groups": ["TestSyncCascadeAdmin"]})
self.assertTrue(self.user.has_group("base.group_erp_manager"))
self.user._sync_oidc_groups({"groups": []})
self.assertFalse(self.user.has_group("base.group_system"))
self.assertFalse(self.user.has_group("base.group_erp_manager"))
def test_group_user_floor_survives_even_if_also_implied_by_revoked_group(self):
# _create_user_from_template guarantees base.group_user to every SSO user,
# independent of any claimed group. A revoked group that happens to also imply
# base.group_user (however unlikely) must never strip it.
weird_group = self.env["res.groups"].create({
"name": "TestSyncImpliesInternal",
"category_id": self.category.id,
"implied_ids": [(4, self.env.ref("base.group_user").id)],
})
self.user._sync_oidc_groups({"groups": ["TestSyncImpliesInternal"]})
self.user._sync_oidc_groups({"groups": []})
self.assertNotIn(weird_group, self.user.groups_id)
self.assertTrue(self.user.has_group("base.group_user"))
def test_implied_group_survives_if_still_granted_elsewhere(self):
# Odoo materializes implied_ids into groups_id at write-time (confirmed live -
# granting a group that implies base.group_system also adds base.group_system as
# a separate real row). Revoking the granting group must not strip an implied
# group that's still legitimately held via something else the user keeps - here,
# a second, independently-granted group implying the same base.group_system.
admin_group = self.env["res.groups"].create({
"name": "TestSyncAdminGroup",
"category_id": self.category.id,
"implied_ids": [(4, self.env.ref("base.group_system").id)],
})
other_admin_group = self.env["res.groups"].create({
"name": "Manually Granted Admin",
"implied_ids": [(4, self.env.ref("base.group_system").id)],
})
self.user.write({"groups_id": [(4, other_admin_group.id)]})
self.user._sync_oidc_groups({"groups": ["TestSyncAdminGroup"]})
self.assertTrue(self.user.has_group("base.group_system"))
self.user._sync_oidc_groups({"groups": []})
self.assertNotIn(admin_group, self.user.groups_id)
self.assertIn(other_admin_group, self.user.groups_id)
self.assertTrue(
self.user.has_group("base.group_system"),
"still granted via other_admin_group, must survive the revoke",
)