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

86 lines
4.0 KiB
Python

import logging
from odoo import api, models
from odoo.addons.auth_signup.models.res_partner import SignupError
_logger = logging.getLogger(__name__)
OIDC_SYNCED_CATEGORY_XMLID = "auth_oidc_group_sync.module_category_oidc_synced"
TEMPLATE_USER_XMLID = "auth_oidc_group_sync.template_internal_user_id"
class ResUsers(models.Model):
_inherit = "res.users"
def _create_user_from_template(self, values):
# Applies to any OAuth-based signup.
if values.get("oauth_provider_id"):
if not values.get("login"):
raise ValueError(self.env._("Signup: no login given for new user"))
values["active"] = True
try:
with self.env.cr.savepoint():
template_user = self.env.ref(TEMPLATE_USER_XMLID)
return template_user.with_context(no_reset_password=True).copy(values)
except Exception as e:
raise SignupError(str(e)) from e
return super()._create_user_from_template(values)
@api.model
def _auth_oauth_signin(self, provider, validation, params):
login = super()._auth_oauth_signin(provider, validation, params)
if login:
user = self.env["res.users"].sudo().search([("login", "=", login)], limit=1)
if user:
user._sync_oidc_groups(validation)
return login
def _sync_oidc_groups(self, validation):
self.ensure_one()
if "groups" not in validation:
# Absent claim (misconfigured scope) must NOT be treated as an empty claim —
# that would revoke every auto-provisioned group on every login until fixed.
_logger.warning(
"OIDC login for %s: no 'groups' claim in token, skipping role sync", self.login
)
return
claim_groups = set(validation.get("groups") or [])
Groups = self.env["res.groups"].sudo()
category = self.env.ref(OIDC_SYNCED_CATEGORY_XMLID)
desired = Groups.browse()
for name in claim_groups:
group = Groups.search(
[("category_id", "=", category.id), ("name", "=", name)], limit=1
)
if not group:
group = Groups.create({"name": name, "category_id": category.id})
desired |= group
owned_universe = Groups.search([("category_id", "=", category.id)])
current_owned = self.groups_id & owned_universe
to_add = desired - current_owned
to_remove = current_owned - desired
if not (to_add or to_remove):
return
# Odoo materializes implied_ids into groups_id at write-time, not just at
# has_group()-check time, so revoking a group that implies others must also clean
# up whatever it materialized - but only what's not independently justified.
# independently_held excludes anything reachable from to_remove itself, so a
# multi-level chain (A implies B, B implies C) is unwound all the way rather than
# stopping at the first level once the removed group's own contribution is
# excluded from the "still granted" check. base.group_user is always kept in the
# base regardless: _create_user_from_template guarantees it to every SSO user
# independent of any claimed group, and Odoo has no way to tell that apart from a
# copy that only happens to also be implied by whatever's being revoked.
floor = self.env.ref("base.group_user")
floor_closure = floor | floor.trans_implied_ids
implied_by_removed = to_remove.trans_implied_ids
independently_held = self.groups_id - to_remove - implied_by_removed
final_base = independently_held | to_add | (self.groups_id & floor_closure)
still_implied = final_base | final_base.trans_implied_ids
to_unlink = to_remove | (implied_by_removed - still_implied)
self.write({
"groups_id": [(3, g.id) for g in to_unlink] + [(4, g.id) for g in to_add],
})