diff --git a/auth_api_key_provisioning/README.rst b/auth_api_key_provisioning/README.rst new file mode 100644 index 0000000000..529b54108d --- /dev/null +++ b/auth_api_key_provisioning/README.rst @@ -0,0 +1,209 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +========================= +Auth API Key Provisioning +========================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:998201133fa4d8d4022d96d02aefbeb8188f755c4d30cb1ea9a9cd707626d877 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github + :target: https://github.com/OCA/server-auth/tree/18.0/auth_api_key_provisioning + :alt: OCA/server-auth +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-auth-18-0/server-auth-18-0-auth_api_key_provisioning + :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-auth&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module lets a trusted provisioning/service account **mint a +short-lived, ``rpc``-scoped API key on behalf of another internal +user**, over RPC. + +It exists for *delegated per-user identity*: when an external +integration (an MCP server, an AI agent, or any backend service) needs +to act **as an end user** rather than as a single shared service +account. A key minted for the target user carries only that user's own +permissions, so subsequent calls apply Odoo's native record rules and +record the real user as ``create_uid``/``write_uid`` — instead of +attributing everything to one shared account. + +Stock Odoo has no supported way to mint an API key *for another user* +over RPC: ``res.users.apikeys.generate`` is not exposed as an RPC method +and ``_generate`` is private. This module adds two narrowly-scoped, +group-gated methods on ``res.users`` to close that gap safely. + +What it adds +------------ + +- ``res.users.mint_apikey(name=None, ttl_days=None) -> str`` — called on + the target user recordset; returns a freshly generated ``rpc``-scoped + key once. +- ``res.users.revoke_provisioned_apikeys() -> int`` — revokes all keys + this module minted for that user. +- An auditable log (``auth.api.key.provisioning.log``) of every mint, + visible under *Settings → Users → Provisioned API Keys*. + +MCP / AI-agent usage is the motivating example, but the module itself is +generic. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +After installing the module: + +1. Add your integration / service account to the **API Key + Provisioning** group (*Settings → Users & Companies → Users*). Do + **not** use a system administrator for this — the whole point is + least privilege. +2. Add each user that integrations may act as to the **API Key Mintable + Target** group. + +Two system parameters (*Settings → Technical → System Parameters*) tune +the lifetime: + +- ``auth_api_key_provisioning.default_ttl_days`` (default ``30``) — + applied when a mint request omits ``ttl_days``. +- ``auth_api_key_provisioning.max_ttl_days`` (default ``90``) — + requested lifetimes are clamped down to this. An absolute ceiling is + also enforced in code. + +Usage +===== + +From a provisioning/service account (a member of *API Key +Provisioning*), call the method over RPC on the target user. The target +must be an internal user that is a member of the *API Key Mintable +Target* group. + +.. code:: python + + import xmlrpc.client + + common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common") + uid = common.authenticate(db, "prov-svc", password, {}) + models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object") + + # Mint a 7-day rpc key for user id 42: + api_key = models.execute_kw( + db, uid, password, + "res.users", "mint_apikey", [[42]], + {"name": "my-integration", "ttl_days": 7}, + ) + + # Later, revoke everything this module minted for that user: + models.execute_kw(db, uid, password, "res.users", "revoke_provisioned_apikeys", [[42]]) + +The integration then authenticates as user 42 using ``api_key`` as the +password on RPC/``/xmlrpc/2/object`` calls; Odoo applies that user's own +ACLs and record rules. + +Security model +-------------- + +- **Caller gating** — only members of *API Key Provisioning* may mint or + revoke; this is a dedicated least-privilege group, **not** + ``base.group_system``. +- **Target allowlist** — keys are minted only for users explicitly + placed in *API Key Mintable Target*. An allowlist is used rather than + a blocklist because custom modules add their own high-privilege groups + that no fixed blocklist could enumerate. +- **Elevated targets refused** — minting is always refused for the + superuser and for any member of ``base.group_system`` / + ``base.group_erp_manager``, even if mis-added to the allowlist. + Portal/public (share) and archived users are refused too. +- **Privilege-drift protection** — API keys carry no permission + snapshot; they authenticate with the user's *current* groups. If a + target with minted keys is later promoted into an elevated group (from + the user side or the group side) or archived, its provisioned keys are + revoked immediately; a daily cron is a backstop for changes that + bypass the ORM hooks. +- **Bounded lifetime** — keys are always ``rpc``-scoped and expiring. + The TTL defaults to 30 days and is clamped to a configurable maximum + (90 days), with an absolute code ceiling. +- **Auditable** — every mint is logged with who/for-whom/when and a + revocation timestamp. + +Residual risks (by design) +-------------------------- + +- Like all Odoo API keys, a minted key is **not** invalidated by a + target password reset. Use ``revoke_provisioned_apikeys`` (or archive + the user) on a suspected compromise. +- A compromised provisioning account can mint keys for any *mintable* + (non-elevated) user. Keep that group's membership minimal and monitor + the provisioning log. + +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 +------- + +* Keboola + +Contributors +------------ + +- Jiri Manas + +Other credits +------------- + +The development of this module was funded by Keboola. + +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. + +.. |maintainer-manana2520| image:: https://github.com/manana2520.png?size=40px + :target: https://github.com/manana2520 + :alt: manana2520 + +Current `maintainer `__: + +|maintainer-manana2520| + +This module is part of the `OCA/server-auth `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/auth_api_key_provisioning/__init__.py b/auth_api_key_provisioning/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/auth_api_key_provisioning/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/auth_api_key_provisioning/__manifest__.py b/auth_api_key_provisioning/__manifest__.py new file mode 100644 index 0000000000..5ef8eb50ab --- /dev/null +++ b/auth_api_key_provisioning/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2026 Keboola +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Auth API Key Provisioning", + "summary": """ + Admin-gated minting of short-lived, rpc-scoped API keys on behalf of + another (non-elevated) internal user, for delegated per-user identity.""", + "version": "18.0.1.0.0", + "license": "LGPL-3", + "author": "Keboola, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-auth", + "development_status": "Beta", + "maintainers": ["manana2520"], + "category": "Tools", + "depends": ["base"], + "data": [ + "security/auth_api_key_provisioning_groups.xml", + "security/ir.model.access.csv", + "data/ir_config_parameter.xml", + "data/ir_cron.xml", + "views/auth_api_key_provisioning_log_views.xml", + ], +} diff --git a/auth_api_key_provisioning/data/ir_config_parameter.xml b/auth_api_key_provisioning/data/ir_config_parameter.xml new file mode 100644 index 0000000000..4daeeb4a13 --- /dev/null +++ b/auth_api_key_provisioning/data/ir_config_parameter.xml @@ -0,0 +1,17 @@ + + + + + + auth_api_key_provisioning.default_ttl_days + 30 + + + + + auth_api_key_provisioning.max_ttl_days + 90 + + diff --git a/auth_api_key_provisioning/data/ir_cron.xml b/auth_api_key_provisioning/data/ir_cron.xml new file mode 100644 index 0000000000..09711905ac --- /dev/null +++ b/auth_api_key_provisioning/data/ir_cron.xml @@ -0,0 +1,16 @@ + + + + + Auth API Key Provisioning: revoke drifted keys + + code + model._cron_revoke_drifted_apikeys() + 1 + days + + + + + diff --git a/auth_api_key_provisioning/models/__init__.py b/auth_api_key_provisioning/models/__init__.py new file mode 100644 index 0000000000..e315d64c89 --- /dev/null +++ b/auth_api_key_provisioning/models/__init__.py @@ -0,0 +1,3 @@ +from . import auth_api_key_provisioning_log +from . import res_groups +from . import res_users diff --git a/auth_api_key_provisioning/models/auth_api_key_provisioning_log.py b/auth_api_key_provisioning/models/auth_api_key_provisioning_log.py new file mode 100644 index 0000000000..9297781ba6 --- /dev/null +++ b/auth_api_key_provisioning/models/auth_api_key_provisioning_log.py @@ -0,0 +1,66 @@ +# Copyright 2026 Keboola +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models + + +class AuthApiKeyProvisioningLog(models.Model): + """Provenance + audit trail for keys minted by this module. + + ``res.users.apikeys`` is a raw ``_auto=False`` table with a fixed schema, so we + cannot tag the key record itself with extra columns. Instead every minted key is + recorded here, which gives us (a) a reliable handle to revoke by -- never by name + matching -- and (b) an auditable history of who minted what, for whom, and when. + + The link to the key uses ``ondelete="set null"`` so the audit row survives the key + being removed (by revocation, native expiry GC, or manual deletion). + """ + + _name = "auth.api.key.provisioning.log" + _description = "Auth API Key Provisioning Log" + _order = "create_date desc, id desc" + _rec_name = "key_name" + + apikey_id = fields.Many2one( + "res.users.apikeys", + string="API Key", + ondelete="set null", + readonly=True, + help="The minted key. Empty once the key has been revoked, expired or removed.", + ) + user_id = fields.Many2one( + "res.users", + string="Target User", + required=True, + readonly=True, + ondelete="cascade", + index=True, + help="User the key was minted for; the key carries this user's " + "own permissions.", + ) + minted_by_id = fields.Many2one( + "res.users", + required=True, + readonly=True, + help="User (the provisioning/service account) that requested the mint.", + ) + key_name = fields.Char(string="Label", readonly=True) + scope = fields.Char(readonly=True, default="rpc") + expiration = fields.Datetime(readonly=True) + revoked_on = fields.Datetime( + readonly=True, + help="Set when this module actively revoked the key " + "(manual revoke or automatic privilege-drift / offboarding revoke).", + ) + is_live = fields.Boolean( + string="Live", + compute="_compute_is_live", + help="True while the underlying key record still exists.", + ) + + @api.depends("apikey_id") + def _compute_is_live(self): + # res.users.apikeys is _auto=False (no FK on apikey_id), so a key removed + # outside this module leaves a dangling id; check real existence. + for log in self: + log.is_live = bool(log.apikey_id and log.apikey_id.exists()) diff --git a/auth_api_key_provisioning/models/res_groups.py b/auth_api_key_provisioning/models/res_groups.py new file mode 100644 index 0000000000..a0e0920214 --- /dev/null +++ b/auth_api_key_provisioning/models/res_groups.py @@ -0,0 +1,24 @@ +# Copyright 2026 Keboola +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import models + + +class ResGroups(models.Model): + _inherit = "res.groups" + + def write(self, vals): + res = super().write(vals) + # Privilege can be granted from the group side without ever calling + # res.users.write: by adding members (``users``) or by making a group imply an + # elevated one (``implied_ids``). The set of users affected by an implied_ids + # change is transitive and awkward to compute exactly, so fall back to the full + # sweep (narrowed to live-key holders, hence cheap). A pure ``users`` change + # only affects the listed members, so re-check just those. + if "implied_ids" in vals: + self.env["res.users"]._apikey_provisioning_sweep_all() + elif "users" in vals: + users = self.mapped("users") + if users: + users._apikey_provisioning_revoke_if_unsafe() + return res diff --git a/auth_api_key_provisioning/models/res_users.py b/auth_api_key_provisioning/models/res_users.py new file mode 100644 index 0000000000..a42c64889f --- /dev/null +++ b/auth_api_key_provisioning/models/res_users.py @@ -0,0 +1,281 @@ +# Copyright 2026 Keboola +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import logging +from datetime import timedelta + +from odoo import SUPERUSER_ID, _, api, fields, models +from odoo.exceptions import AccessError, UserError + +_logger = logging.getLogger(__name__) + +# Group whose members are allowed to mint/revoke keys for other users (the caller). +# Deliberately NOT base.group_system: a dedicated least-privilege capability. +PROVISIONING_GROUP = "auth_api_key_provisioning.group_apikey_provisioning" + +# Allowlist group: a key may only be minted for a user placed in this group. An +# allowlist (not blocklist) because Odoo's modular ecosystem makes any fixed blocklist +# of "dangerous" groups incomplete -- custom modules add their own privileged groups. +MINTABLE_TARGET_GROUP = "auth_api_key_provisioning.group_apikey_mintable_target" + +# Defence-in-depth: even if mis-added to the allowlist, these targets are refused. +ELEVATED_GROUPS = ("base.group_system", "base.group_erp_manager") + +# ir.config_parameter keys (operator-tunable) and the absolute code ceiling. +PARAM_DEFAULT_TTL = "auth_api_key_provisioning.default_ttl_days" +PARAM_MAX_TTL = "auth_api_key_provisioning.max_ttl_days" +HARD_MAX_TTL_DAYS = 365 # absolute upper bound regardless of misconfiguration + +# Mirrors odoo.addons.base.models.res_users.INDEX_SIZE (hex digits of the key used as a +# public lookup index). Used to locate the freshly minted key record precisely. +APIKEY_INDEX_SIZE = 8 + + +def _safe_int(value, fallback): + """int(value) but never raises -- returns ``fallback`` on bad/empty input.""" + try: + return int(value) + except (TypeError, ValueError): + return fallback + + +class ResUsers(models.Model): + _inherit = "res.users" + + # ------------------------------------------------------------------ + # Public RPC API + # ------------------------------------------------------------------ + def mint_apikey(self, name=None, ttl_days=None): + """Mint a fresh ``rpc``-scoped API key for ``self`` and return it once. + + Intended to be called over RPC by a provisioning/service account (a member of + the *API Key Provisioning* group), e.g. ``execute_kw('res.users', + 'mint_apikey', [[target_uid]], {'name': ..., 'ttl_days': ...})``. The minted key + authenticates as ``self`` with that user's own record rules and correct + ``create_uid`` -- it does NOT carry the caller's privileges. + + :param str name: optional human label for the key. + :param int ttl_days: optional lifetime in days; clamped to the configured max. + :returns str: the freshly generated API key (shown only once). + :raises AccessError: caller not in the provisioning group; target is the + superuser / elevated user; or target not in the mintable allowlist. + :raises UserError: target is archived or not an internal user. + """ + self._apikey_provisioning_assert_caller() + self.ensure_one() + # The caller is authorized by the provisioning-group gate above; inspect the + # target under sudo. The provisioning account is deliberately low-privilege and + # need not have read access to res.users, and Odoo 18 only allows has_group() + # for the current user unless in a sudo (superuser) environment. + target = self.sudo() + + # Anti-escalation refusal first (strongest, and independent of active/share): + # never mint for the superuser or an admin/elevated user, even if they were + # mistakenly added to the mintable allowlist. + if target.id == SUPERUSER_ID or any( + target.has_group(g) for g in ELEVATED_GROUPS + ): + raise AccessError( + _("Refusing to mint an API key for an elevated/admin user (%s).") + % target.login + ) + if not target.active: + raise UserError( + _("Cannot mint an API key for an archived user (%s).") % target.login + ) + if target.share: + raise UserError( + _("Cannot mint an API key for a non-internal user (%s).") % target.login + ) + if not target.has_group(MINTABLE_TARGET_GROUP): + raise AccessError( + _( + "User %s is not in the 'API Key Mintable Target' group; " + "refusing to mint a key for them." + ) + % target.login + ) + + days = target._apikey_provisioning_clamped_ttl(ttl_days) + expiration = fields.Datetime.now() + timedelta(days=days) + label = name or _("Provisioned key") + + # Generate as the target user (with_user) so the key belongs to them; sudo() so + # the low-level _generate may set an explicit expiration regardless of the + # target's own api_key_duration policy. with_user keeps uid == target, so the + # key row's user_id is the target (not the superuser). + apikeys_sudo = self.env["res.users.apikeys"].with_user(target).sudo() + # Watermark the table id before generating so we can identify *exactly* the row + # _generate inserts (it returns only the plaintext, not the record). + self.env.cr.execute("SELECT COALESCE(MAX(id), 0) FROM res_users_apikeys") + max_id_before = self.env.cr.fetchone()[0] + api_key = apikeys_sudo._generate("rpc", label, expiration) + + minted = target._apikey_provisioning_find_minted_key(api_key, max_id_before) + if not minted: + # We could not positively identify the row we just created, so we cannot + # track (and therefore later revoke) it. Refuse rather than leave an + # untracked live key: raising rolls back the _generate INSERT in this + # transaction. Should be unreachable in practice. + raise UserError( + _("Could not register the minted API key for tracking; aborted.") + ) + self.env["auth.api.key.provisioning.log"].sudo().create( + { + "apikey_id": minted.id, + "user_id": target.id, + "minted_by_id": self.env.uid, + "key_name": label, + "scope": "rpc", + "expiration": expiration, + } + ) + _logger.info( + "Provisioned rpc API key for user_id=%s by uid=%s (label=%s, ttl_days=%s)", + target.id, + self.env.uid, + label, + days, + ) + return api_key + + def revoke_provisioned_apikeys(self): + """Revoke every key this module minted for ``self``; returns the count.""" + self._apikey_provisioning_assert_caller() + return self._apikey_provisioning_revoke() + + # ------------------------------------------------------------------ + # Privilege-drift / offboarding protection + # ------------------------------------------------------------------ + def write(self, vals): + res = super().write(vals) + # An API key carries no permission snapshot: it authenticates as the user with + # their *current* groups. If a user with provisioned keys is promoted into an + # elevated group or archived after minting, those keys would silently inherit + # the new power -- so revoke them immediately. The group-side path (editing + # res.groups.users) is covered in res_groups.write and, as a backstop, by cron. + if {"groups_id", "active", "share"} & set(vals): + self._apikey_provisioning_revoke_if_unsafe() + return res + + def _apikey_provisioning_revoke_if_unsafe(self): + """Revoke provisioned keys for users in ``self`` no longer safe as targets. + + Work is bounded to users that actually hold a live provisioned key, so this + stays cheap even when called for every member of a large group. + """ + if not self: + return + log_model = self.env["auth.api.key.provisioning.log"].sudo() + users_with_keys = log_model.search( + [("user_id", "in", self.ids), ("apikey_id", "!=", False)] + ).mapped("user_id") + # sudo: this may run for users other than the current one (group writes, cron), + # and Odoo 18 only allows has_group() for the current user outside sudo. + for user in users_with_keys.sudo(): + unsafe = ( + not user.active + or user.id == SUPERUSER_ID + or user.share + or any(user.has_group(g) for g in ELEVATED_GROUPS) + or not user.has_group(MINTABLE_TARGET_GROUP) + ) + if unsafe: + count = user._apikey_provisioning_revoke() + if count: + _logger.warning( + "Auto-revoked %s provisioned API key(s) for user_id=%s " + "(privilege drift / offboarding).", + count, + user.id, + ) + + @api.model + def _apikey_provisioning_sweep_all(self): + """Re-check every user that currently holds a live provisioned key. + + Used as the daily cron and as the safe catch-all for group changes whose exact + set of affected users is awkward to compute (e.g. transitive ``implied_ids``). + Cheap: the live-key holder set is small in practice. + """ + logs = ( + self.env["auth.api.key.provisioning.log"] + .sudo() + .search([("apikey_id", "!=", False)]) + ) + users = logs.mapped("user_id") + if users: + users._apikey_provisioning_revoke_if_unsafe() + + @api.model + def _cron_revoke_drifted_apikeys(self): + """Backstop sweep: revoke provisioned keys for any now-unsafe target. + + Catches drift introduced by paths that bypass the write() hooks (e.g. raw + SQL, or group changes applied in ways the ORM hooks miss). + """ + self._apikey_provisioning_sweep_all() + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + def _apikey_provisioning_assert_caller(self): + if not self.env.user.has_group(PROVISIONING_GROUP): + raise AccessError( + _( + "Only members of the 'API Key Provisioning' group may mint or " + "revoke API keys on behalf of other users." + ) + ) + + def _apikey_provisioning_clamped_ttl(self, ttl_days): + icp = self.env["ir.config_parameter"].sudo() + # A malformed system parameter must not be able to block minting: fall back to + # the documented defaults instead of raising. + default_ttl = _safe_int(icp.get_param(PARAM_DEFAULT_TTL), 30) + max_ttl = _safe_int(icp.get_param(PARAM_MAX_TTL), 90) + max_ttl = max(1, min(max_ttl, HARD_MAX_TTL_DAYS)) + days = _safe_int(ttl_days, default_ttl) if ttl_days else default_ttl + return max(1, min(days, max_ttl)) + + def _apikey_provisioning_find_minted_key(self, plaintext_key, max_id_before): + """Locate the apikey record just created for ``self`` by ``_generate``. + + ``_generate`` performs a raw SQL INSERT and returns only the plaintext key, not + the record. We identify the new row deterministically: it is the only row with + ``id`` greater than the pre-insert watermark for this user and the rpc scope. + The public ``index`` prefix (first ``APIKEY_INDEX_SIZE`` hex chars) is matched + too as a defensive sanity check. Returns an empty recordset if not found. + """ + self.ensure_one() + index = plaintext_key[:APIKEY_INDEX_SIZE] + self.env.cr.execute( + """ + SELECT id FROM res_users_apikeys + WHERE id > %s AND user_id = %s AND scope = 'rpc' AND index = %s + ORDER BY id DESC LIMIT 1 + """, + (max_id_before, self.id, index), + ) + row = self.env.cr.fetchone() + if not row: + return self.env["res.users.apikeys"] + return self.env["res.users.apikeys"].sudo().browse(row[0]) + + def _apikey_provisioning_revoke(self): + """Unlink the live keys recorded for ``self`` and stamp their audit rows.""" + log_model = self.env["auth.api.key.provisioning.log"].sudo() + logs = log_model.search( + [("user_id", "in", self.ids), ("apikey_id", "!=", False)] + ) + if not logs: + return 0 + # res.users.apikeys is an _auto=False table, so no FK is created for apikey_id + # and ondelete="set null" never fires -- we must clear the link explicitly. + # .exists() guards against rows whose key was already removed natively + # (e.g. Odoo's expired-key autovacuum), which leave a dangling id behind. + keys = logs.mapped("apikey_id").exists() + count = len(keys) + keys.sudo().unlink() + logs.write({"apikey_id": False, "revoked_on": fields.Datetime.now()}) + return count diff --git a/auth_api_key_provisioning/pyproject.toml b/auth_api_key_provisioning/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/auth_api_key_provisioning/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/auth_api_key_provisioning/readme/CONFIGURE.md b/auth_api_key_provisioning/readme/CONFIGURE.md new file mode 100644 index 0000000000..dbcea3d712 --- /dev/null +++ b/auth_api_key_provisioning/readme/CONFIGURE.md @@ -0,0 +1,13 @@ +After installing the module: + +1. Add your integration / service account to the **API Key Provisioning** group + (_Settings → Users & Companies → Users_). Do **not** use a system administrator for + this — the whole point is least privilege. +2. Add each user that integrations may act as to the **API Key Mintable Target** group. + +Two system parameters (_Settings → Technical → System Parameters_) tune the lifetime: + +- `auth_api_key_provisioning.default_ttl_days` (default `30`) — applied when a mint + request omits `ttl_days`. +- `auth_api_key_provisioning.max_ttl_days` (default `90`) — requested lifetimes are + clamped down to this. An absolute ceiling is also enforced in code. diff --git a/auth_api_key_provisioning/readme/CONTRIBUTORS.md b/auth_api_key_provisioning/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..fbbbc1fea7 --- /dev/null +++ b/auth_api_key_provisioning/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Jiri Manas \<\> diff --git a/auth_api_key_provisioning/readme/CREDITS.md b/auth_api_key_provisioning/readme/CREDITS.md new file mode 100644 index 0000000000..b88012cce0 --- /dev/null +++ b/auth_api_key_provisioning/readme/CREDITS.md @@ -0,0 +1 @@ +The development of this module was funded by Keboola. diff --git a/auth_api_key_provisioning/readme/DESCRIPTION.md b/auth_api_key_provisioning/readme/DESCRIPTION.md new file mode 100644 index 0000000000..764be5ed23 --- /dev/null +++ b/auth_api_key_provisioning/readme/DESCRIPTION.md @@ -0,0 +1,25 @@ +This module lets a trusted provisioning/service account **mint a short-lived, +`rpc`-scoped API key on behalf of another internal user**, over RPC. + +It exists for _delegated per-user identity_: when an external integration (an MCP +server, an AI agent, or any backend service) needs to act **as an end user** rather than +as a single shared service account. A key minted for the target user carries only that +user's own permissions, so subsequent calls apply Odoo's native record rules and record +the real user as `create_uid`/`write_uid` — instead of attributing everything to one +shared account. + +Stock Odoo has no supported way to mint an API key _for another user_ over RPC: +`res.users.apikeys.generate` is not exposed as an RPC method and `_generate` is private. +This module adds two narrowly-scoped, group-gated methods on `res.users` to close that +gap safely. + +## What it adds + +- `res.users.mint_apikey(name=None, ttl_days=None) -> str` — called on the target user + recordset; returns a freshly generated `rpc`-scoped key once. +- `res.users.revoke_provisioned_apikeys() -> int` — revokes all keys this module minted + for that user. +- An auditable log (`auth.api.key.provisioning.log`) of every mint, visible under + _Settings → Users → Provisioned API Keys_. + +MCP / AI-agent usage is the motivating example, but the module itself is generic. diff --git a/auth_api_key_provisioning/readme/USAGE.md b/auth_api_key_provisioning/readme/USAGE.md new file mode 100644 index 0000000000..64da9cd00b --- /dev/null +++ b/auth_api_key_provisioning/readme/USAGE.md @@ -0,0 +1,53 @@ +From a provisioning/service account (a member of _API Key Provisioning_), call the +method over RPC on the target user. The target must be an internal user that is a member +of the _API Key Mintable Target_ group. + +```python +import xmlrpc.client + +common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common") +uid = common.authenticate(db, "prov-svc", password, {}) +models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object") + +# Mint a 7-day rpc key for user id 42: +api_key = models.execute_kw( + db, uid, password, + "res.users", "mint_apikey", [[42]], + {"name": "my-integration", "ttl_days": 7}, +) + +# Later, revoke everything this module minted for that user: +models.execute_kw(db, uid, password, "res.users", "revoke_provisioned_apikeys", [[42]]) +``` + +The integration then authenticates as user 42 using `api_key` as the password on +RPC/`/xmlrpc/2/object` calls; Odoo applies that user's own ACLs and record rules. + +## Security model + +- **Caller gating** — only members of _API Key Provisioning_ may mint or revoke; this is + a dedicated least-privilege group, **not** `base.group_system`. +- **Target allowlist** — keys are minted only for users explicitly placed in _API Key + Mintable Target_. An allowlist is used rather than a blocklist because custom modules + add their own high-privilege groups that no fixed blocklist could enumerate. +- **Elevated targets refused** — minting is always refused for the superuser and for any + member of `base.group_system` / `base.group_erp_manager`, even if mis-added to the + allowlist. Portal/public (share) and archived users are refused too. +- **Privilege-drift protection** — API keys carry no permission snapshot; they + authenticate with the user's _current_ groups. If a target with minted keys is later + promoted into an elevated group (from the user side or the group side) or archived, + its provisioned keys are revoked immediately; a daily cron is a backstop for changes + that bypass the ORM hooks. +- **Bounded lifetime** — keys are always `rpc`-scoped and expiring. The TTL defaults to + 30 days and is clamped to a configurable maximum (90 days), with an absolute code + ceiling. +- **Auditable** — every mint is logged with who/for-whom/when and a revocation + timestamp. + +## Residual risks (by design) + +- Like all Odoo API keys, a minted key is **not** invalidated by a target password + reset. Use `revoke_provisioned_apikeys` (or archive the user) on a suspected + compromise. +- A compromised provisioning account can mint keys for any _mintable_ (non-elevated) + user. Keep that group's membership minimal and monitor the provisioning log. diff --git a/auth_api_key_provisioning/security/auth_api_key_provisioning_groups.xml b/auth_api_key_provisioning/security/auth_api_key_provisioning_groups.xml new file mode 100644 index 0000000000..7310e0fbd5 --- /dev/null +++ b/auth_api_key_provisioning/security/auth_api_key_provisioning_groups.xml @@ -0,0 +1,23 @@ + + + + + API Key Provisioning + Delegated minting of per-user API keys. + 20 + + + + + API Key Provisioning + + + + + + API Key Mintable Target + + + diff --git a/auth_api_key_provisioning/security/ir.model.access.csv b/auth_api_key_provisioning/security/ir.model.access.csv new file mode 100644 index 0000000000..532b770fba --- /dev/null +++ b/auth_api_key_provisioning/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_provisioning_log_provisioning,auth.api.key.provisioning.log provisioning,model_auth_api_key_provisioning_log,group_apikey_provisioning,1,0,0,0 +access_provisioning_log_system,auth.api.key.provisioning.log system,model_auth_api_key_provisioning_log,base.group_system,1,1,1,1 diff --git a/auth_api_key_provisioning/static/description/index.html b/auth_api_key_provisioning/static/description/index.html new file mode 100644 index 0000000000..a29a8670c2 --- /dev/null +++ b/auth_api_key_provisioning/static/description/index.html @@ -0,0 +1,853 @@ + + + + + + Auth API Key Provisioning + + + +
+ + Odoo Community Association + +
+

Auth API Key Provisioning

+ +

+ Beta + License: LGPL-3 + OCA/server-auth + Translate me on Weblate + Try me on Runboat +

+

+ This module lets a trusted provisioning/service account + mint a short-lived, ``rpc``-scoped API key on behalf of another internal + user, over RPC. +

+

+ It exists for delegated per-user identity: when an external + integration (an MCP server, an AI agent, or any backend service) needs to act + as an end user rather than as a single shared service + account. A key minted for the target user carries only that user’s own + permissions, so subsequent calls apply Odoo’s native record rules and record + the real user as create_uid/write_uid + — instead of attributing everything to one shared account. +

+

+ Stock Odoo has no supported way to mint an API key + for another user over RPC: + res.users.apikeys.generate is not exposed as + an RPC method and _generate is private. This + module adds two narrowly-scoped, group-gated methods on + res.users to close that gap safely. +

+
+

What it adds

+
    +
  • + res.users.mint_apikey(name=None, ttl_days=None) + -> str + — called on the target user recordset; returns a freshly generated + rpc-scoped key once. +
  • +
  • + res.users.revoke_provisioned_apikeys() + -> int + — revokes all keys this module minted for that user. +
  • +
  • + An auditable log (auth.api.key.provisioning.log) of every mint, visible under + Settings → Users → Provisioned API Keys. +
  • +
+

+ MCP / AI-agent usage is the motivating example, but the module itself is + generic. +

+

Table of contents

+
+ +
+
+

Configuration

+

After installing the module:

+
    +
  1. + Add your integration / service account to the + API Key Provisioning group (Settings → Users & Companies → Users). Do not use a system administrator for this — the + whole point is least privilege. +
  2. +
  3. + Add each user that integrations may act as to the + API Key Mintable Target group. +
  4. +
+

+ Two system parameters (Settings → Technical → System Parameters) + tune the lifetime: +

+
    +
  • + auth_api_key_provisioning.default_ttl_days + (default 30) — applied when a mint + request omits ttl_days. +
  • +
  • + auth_api_key_provisioning.max_ttl_days + (default 90) — requested lifetimes are + clamped down to this. An absolute ceiling is also enforced in code. +
  • +
+
+
+

Usage

+

+ From a provisioning/service account (a member of + API Key Provisioning), call the method over RPC on the target + user. The target must be an internal user that is a member of the + API Key Mintable Target group. +

+
+import xmlrpc.client
+
+common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common")
+uid = common.authenticate(db, "prov-svc", password, {})
+models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object")
+
+# Mint a 7-day rpc key for user id 42:
+api_key = models.execute_kw(
+    db, uid, password,
+    "res.users", "mint_apikey", [[42]],
+    {"name": "my-integration", "ttl_days": 7},
+)
+
+# Later, revoke everything this module minted for that user:
+models.execute_kw(db, uid, password, "res.users", "revoke_provisioned_apikeys", [[42]])
+
+

+ The integration then authenticates as user 42 using + api_key as the password on RPC//xmlrpc/2/object + calls; Odoo applies that user’s own ACLs and record rules. +

+
+
+
+

Security model

+
    +
  • + Caller gating — only members of + API Key Provisioning may mint or revoke; this is a dedicated + least-privilege group, not + base.group_system. +
  • +
  • + Target allowlist — keys are minted only for users + explicitly placed in API Key Mintable Target. An allowlist is + used rather than a blocklist because custom modules add their own + high-privilege groups that no fixed blocklist could enumerate. +
  • +
  • + Elevated targets refused — minting is always refused for + the superuser and for any member of + base.group_system / + base.group_erp_manager, even if + mis-added to the allowlist. Portal/public (share) and archived users are + refused too. +
  • +
  • + Privilege-drift protection — API keys carry no permission + snapshot; they authenticate with the user’s current groups. If a + target with minted keys is later promoted into an elevated group (from the + user side or the group side) or archived, its provisioned keys are revoked + immediately; a daily cron is a backstop for changes that bypass the ORM + hooks. +
  • +
  • + Bounded lifetime — keys are always + rpc-scoped and expiring. The TTL + defaults to 30 days and is clamped to a configurable maximum (90 days), + with an absolute code ceiling. +
  • +
  • + Auditable — every mint is logged with who/for-whom/when + and a revocation timestamp. +
  • +
+
+
+

Residual risks (by design)

+
    +
  • + Like all Odoo API keys, a minted key is not invalidated + by a target password reset. Use + revoke_provisioned_apikeys (or archive + the user) on a suspected compromise. +
  • +
  • + A compromised provisioning account can mint keys for any + mintable (non-elevated) user. Keep that group’s membership + minimal and monitor the provisioning log. +
  • +
+
+

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

+
    +
  • Keboola
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The development of this module was funded by Keboola.

+
+
+

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. +

+

+ Current + maintainer: +

+

+ manana2520 +

+

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

+

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

+
+
+
+ + diff --git a/auth_api_key_provisioning/tests/__init__.py b/auth_api_key_provisioning/tests/__init__.py new file mode 100644 index 0000000000..c055b5f015 --- /dev/null +++ b/auth_api_key_provisioning/tests/__init__.py @@ -0,0 +1 @@ +from . import test_auth_api_key_provisioning diff --git a/auth_api_key_provisioning/tests/test_auth_api_key_provisioning.py b/auth_api_key_provisioning/tests/test_auth_api_key_provisioning.py new file mode 100644 index 0000000000..2a73d7423b --- /dev/null +++ b/auth_api_key_provisioning/tests/test_auth_api_key_provisioning.py @@ -0,0 +1,253 @@ +# Copyright 2026 Keboola +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from datetime import timedelta + +from odoo import fields +from odoo.exceptions import AccessError, UserError +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + +# Auto-revocation intentionally logs at WARNING (a security-relevant event in +# production); mute it on the tests that deliberately trigger it so the OCA test +# runner does not flag the expected warnings as "errors in log". +_REVOKE_LOGGER = "odoo.addons.auth_api_key_provisioning.models.res_users" + + +class TestAuthApiKeyProvisioning(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ResUsers = cls.env["res.users"] + cls.Log = cls.env["auth.api.key.provisioning.log"] + cls.ApiKeys = cls.env["res.users.apikeys"] + + cls.group_provisioning = cls.env.ref( + "auth_api_key_provisioning.group_apikey_provisioning" + ) + cls.group_mintable = cls.env.ref( + "auth_api_key_provisioning.group_apikey_mintable_target" + ) + cls.group_internal = cls.env.ref("base.group_user") + cls.group_system = cls.env.ref("base.group_system") + cls.group_portal = cls.env.ref("base.group_portal") + + # Caller / service account: allowed to mint, nothing more. + cls.caller = cls.ResUsers.create( + { + "name": "Provisioning Service", + "login": "prov-svc", + "groups_id": [(6, 0, [cls.group_provisioning.id])], + } + ) + # A regular internal user explicitly marked as a mintable target. + cls.target = cls.ResUsers.create( + { + "name": "Target User", + "login": "target-user", + "groups_id": [(6, 0, [cls.group_internal.id, cls.group_mintable.id])], + } + ) + # An internal user NOT in the mintable allowlist. + cls.non_mintable = cls.ResUsers.create( + { + "name": "Non Mintable", + "login": "non-mintable", + "groups_id": [(6, 0, [cls.group_internal.id])], + } + ) + + def _mint_model(self): + """res.users model bound to the caller (the provisioning service account).""" + return self.ResUsers.with_user(self.caller) + + # ------------------------------------------------------------------ + # Happy path + # ------------------------------------------------------------------ + def test_mint_happy_path(self): + target = self.target.with_user(self.caller) + key = target.mint_apikey(name="ci", ttl_days=10) + self.assertIsInstance(key, str) + self.assertTrue(key) + + log = self.Log.search([("user_id", "=", self.target.id)]) + self.assertEqual(len(log), 1) + self.assertEqual(log.minted_by_id, self.caller) + self.assertEqual(log.scope, "rpc") + self.assertTrue(log.apikey_id) + self.assertTrue(log.is_live) + self.assertFalse(log.revoked_on) + # Key belongs to the target, scoped rpc, with an expiration ~10 days out. + self.assertEqual(log.apikey_id.user_id, self.target) + self.assertEqual(log.apikey_id.scope, "rpc") + delta = log.apikey_id.expiration_date - fields.Datetime.now() + self.assertLess(abs(delta - timedelta(days=10)), timedelta(minutes=5)) + + def test_default_and_clamped_ttl(self): + target = self.target.with_user(self.caller) + # No ttl -> default 30. + target.mint_apikey(name="default-ttl") + log = self.Log.search( + [("user_id", "=", self.target.id)], order="id desc", limit=1 + ) + delta = log.apikey_id.expiration_date - fields.Datetime.now() + self.assertLess(abs(delta - timedelta(days=30)), timedelta(minutes=5)) + + # Excessive ttl -> clamped to max (90). + target.mint_apikey(name="huge-ttl", ttl_days=9999) + log = self.Log.search( + [("user_id", "=", self.target.id)], order="id desc", limit=1 + ) + delta = log.apikey_id.expiration_date - fields.Datetime.now() + self.assertLess(abs(delta - timedelta(days=90)), timedelta(minutes=5)) + + # ------------------------------------------------------------------ + # Authorization / refusals + # ------------------------------------------------------------------ + def test_mint_requires_caller_group(self): + # non_mintable is a plain internal user, not in the provisioning group. + target = self.target.with_user(self.non_mintable) + with self.assertRaises(AccessError): + target.mint_apikey(name="nope") + + def test_mint_refuses_non_mintable_target(self): + target = self.non_mintable.with_user(self.caller) + with self.assertRaises(AccessError): + target.mint_apikey(name="nope") + + def test_mint_refuses_elevated_target(self): + self.target.write({"groups_id": [(4, self.group_system.id)]}) + target = self.target.with_user(self.caller) + with self.assertRaises(AccessError): + target.mint_apikey(name="nope") + + def test_mint_refuses_superuser(self): + root = self.env.ref("base.user_root") + # Put root in the mintable group to prove the SUPERUSER guard still fires. + root.sudo().write({"groups_id": [(4, self.group_mintable.id)]}) + with self.assertRaises(AccessError): + root.with_user(self.caller).mint_apikey(name="nope") + + def test_mint_refuses_portal_target(self): + portal = self.ResUsers.create( + { + "name": "Portal User", + "login": "portal-user", + "groups_id": [(6, 0, [self.group_portal.id])], + } + ) + # Force into the mintable allowlist to prove the share guard still fires. + portal.write({"groups_id": [(4, self.group_mintable.id)]}) + with self.assertRaises(UserError): + portal.with_user(self.caller).mint_apikey(name="nope") + + def test_mint_refuses_archived_target(self): + self.target.write({"active": False}) + # Re-read to avoid acting on a stale recordset. + target = self.target.with_user(self.caller) + with self.assertRaises(UserError): + target.mint_apikey(name="nope") + + # ------------------------------------------------------------------ + # Revocation + # ------------------------------------------------------------------ + def test_manual_revoke(self): + target = self.target.with_user(self.caller) + target.mint_apikey(name="to-revoke") + log = self.Log.search([("user_id", "=", self.target.id)]) + self.assertTrue(log.apikey_id) + + count = target.revoke_provisioned_apikeys() + self.assertEqual(count, 1) + log.invalidate_recordset() + self.assertFalse(log.apikey_id) + self.assertTrue(log.revoked_on) + + def test_revoke_requires_caller_group(self): + with self.assertRaises(AccessError): + self.target.with_user(self.non_mintable).revoke_provisioned_apikeys() + + # ------------------------------------------------------------------ + # Privilege drift (the critical case) + # ------------------------------------------------------------------ + @mute_logger(_REVOKE_LOGGER) + def test_drift_user_side_promotion_revokes(self): + target = self.target.with_user(self.caller) + target.mint_apikey(name="drift-user") + log = self.Log.search([("user_id", "=", self.target.id)]) + self.assertTrue(log.apikey_id) + + # Promote target into an elevated group via the user record (res.users.write). + self.target.write({"groups_id": [(4, self.group_system.id)]}) + log.invalidate_recordset() + self.assertFalse( + log.apikey_id, "minted key must be revoked when target is promoted" + ) + self.assertTrue(log.revoked_on) + + @mute_logger(_REVOKE_LOGGER) + def test_drift_group_side_promotion_revokes(self): + target = self.target.with_user(self.caller) + target.mint_apikey(name="drift-group") + log = self.Log.search([("user_id", "=", self.target.id)]) + self.assertTrue(log.apikey_id) + + # Promote via the group record (res.groups.write with users command). + self.group_system.write({"users": [(4, self.target.id)]}) + log.invalidate_recordset() + self.assertFalse( + log.apikey_id, + "minted key must be revoked when target is added to elevated group " + "from the group side", + ) + + @mute_logger(_REVOKE_LOGGER) + def test_drift_implied_group_promotion_revokes(self): + # Target holds an innocuous intermediate group... + inter = self.env["res.groups"].create({"name": "Intermediate"}) + self.target.write({"groups_id": [(4, inter.id)]}) + target = self.target.with_user(self.caller) + target.mint_apikey(name="drift-implied") + log = self.Log.search([("user_id", "=", self.target.id)]) + self.assertTrue(log.apikey_id) + + # ...which is then made to imply an elevated group (no res.users.write, no + # change to res.groups.users -- only implied_ids). + inter.write({"implied_ids": [(4, self.group_system.id)]}) + log.invalidate_recordset() + self.assertFalse( + log.apikey_id, + "minted key must be revoked when the target is elevated via implied_ids", + ) + + @mute_logger(_REVOKE_LOGGER) + def test_drift_archive_revokes(self): + target = self.target.with_user(self.caller) + target.mint_apikey(name="drift-archive") + log = self.Log.search([("user_id", "=", self.target.id)]) + self.assertTrue(log.apikey_id) + + self.target.write({"active": False}) + log.invalidate_recordset() + self.assertFalse(log.apikey_id) + + @mute_logger(_REVOKE_LOGGER) + def test_cron_backstop_revokes_drift(self): + target = self.target.with_user(self.caller) + target.mint_apikey(name="drift-cron") + log = self.Log.search([("user_id", "=", self.target.id)]) + self.assertTrue(log.apikey_id) + + # Simulate drift the ORM write hooks did not see: drop the target from the + # mintable allowlist via the relation table directly. has_group resolves + # membership through an ormcache (_get_group_ids) that raw SQL does not + # invalidate, so clear it to mimic a fresh cron process, then run the cron. + self.env.cr.execute( + "DELETE FROM res_groups_users_rel WHERE uid = %s AND gid = %s", + (self.target.id, self.group_mintable.id), + ) + self.env.registry.clear_cache() + self.env.invalidate_all() + self.ResUsers._cron_revoke_drifted_apikeys() + log.invalidate_recordset() + self.assertFalse(log.apikey_id, "cron must revoke keys for a now-unsafe target") diff --git a/auth_api_key_provisioning/views/auth_api_key_provisioning_log_views.xml b/auth_api_key_provisioning/views/auth_api_key_provisioning_log_views.xml new file mode 100644 index 0000000000..2eb1b37fb8 --- /dev/null +++ b/auth_api_key_provisioning/views/auth_api_key_provisioning_log_views.xml @@ -0,0 +1,94 @@ + + + + + auth.api.key.provisioning.log.tree + auth.api.key.provisioning.log + + + + + + + + + + + + + + + + auth.api.key.provisioning.log.form + auth.api.key.provisioning.log + +
+ + + + + + + + + + + + + + + + +
+
+
+ + + auth.api.key.provisioning.log.search + auth.api.key.provisioning.log + + + + + + + + + + + + + + + + Provisioned API Keys + auth.api.key.provisioning.log + list,form + + + +