Skip to content

Commit 8a13bc4

Browse files
manana2520manana2520
authored andcommitted
[ADD] auth_api_key_provisioning: mint scoped API keys for other users
Admin-gated, least-privilege module that mints short-lived, rpc-scoped API keys on behalf of another internal user, for delegated per-user identity (MCP servers, AI agents, backend services acting as the user with that user's own ACLs and correct create_uid). Security model: - caller gated on a dedicated provisioning group (not group_system) - targets restricted to an explicit mintable allowlist group - superuser / group_system / group_erp_manager always refused - portal/share and archived users refused - keys are rpc-scoped with a TTL clamped to a configurable maximum - privilege-drift protection: provisioned keys are auto-revoked when a target is promoted into an elevated group (user-side or group-side) or archived, with a daily cron backstop - every mint is recorded in an auditable provisioning log; revocation is by stored provenance, never by name matching
1 parent 3a5062a commit 8a13bc4

21 files changed

Lines changed: 1964 additions & 0 deletions
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
.. image:: https://odoo-community.org/readme-banner-image
2+
:target: https://odoo-community.org/get-involved?utm_source=readme
3+
:alt: Odoo Community Association
4+
5+
=========================
6+
Auth API Key Provisioning
7+
=========================
8+
9+
..
10+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
11+
!! This file is generated by oca-gen-addon-readme !!
12+
!! changes will be overwritten. !!
13+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
14+
!! source digest: sha256:0cb9fbc9eba209dde189ea2ccd2b6b6dd60771cae3bfc626fa86889274af611f
15+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
16+
17+
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
18+
:target: https://odoo-community.org/page/development-status
19+
:alt: Beta
20+
.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png
21+
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
22+
:alt: License: LGPL-3
23+
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github
24+
:target: https://github.com/OCA/server-auth/tree/18.0/auth_api_key_provisioning
25+
:alt: OCA/server-auth
26+
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
27+
:target: https://translation.odoo-community.org/projects/server-auth-18-0/server-auth-18-0-auth_api_key_provisioning
28+
:alt: Translate me on Weblate
29+
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
30+
:target: https://runboat.odoo-community.org/builds?repo=OCA/server-auth&target_branch=18.0
31+
:alt: Try me on Runboat
32+
33+
|badge1| |badge2| |badge3| |badge4| |badge5|
34+
35+
This module lets a trusted provisioning/service account **mint a
36+
short-lived, ``rpc``-scoped API key on behalf of another internal
37+
user**, over RPC.
38+
39+
It exists for *delegated per-user identity*: when an external
40+
integration (an MCP server, an AI agent, or any backend service) needs
41+
to act **as an end user** rather than as a single shared service
42+
account. A key minted for the target user carries only that user's own
43+
permissions, so subsequent calls apply Odoo's native record rules and
44+
record the real user as ``create_uid``/``write_uid`` — instead of
45+
attributing everything to one shared account.
46+
47+
Stock Odoo has no supported way to mint an API key *for another user*
48+
over RPC: ``res.users.apikeys.generate`` is not exposed as an RPC method
49+
and ``_generate`` is private. This module adds two narrowly-scoped,
50+
group-gated methods on ``res.users`` to close that gap safely.
51+
52+
What it adds
53+
------------
54+
55+
- ``res.users.mint_apikey(name=None, ttl_days=None) -> str`` — called on
56+
the target user recordset; returns a freshly generated ``rpc``-scoped
57+
key once.
58+
- ``res.users.revoke_provisioned_apikeys() -> int`` — revokes all keys
59+
this module minted for that user.
60+
- An auditable log (``auth.api.key.provisioning.log``) of every mint,
61+
visible under *Settings → Users → Provisioned API Keys*.
62+
63+
MCP / AI-agent usage is the motivating example, but the module itself is
64+
generic.
65+
66+
**Table of contents**
67+
68+
.. contents::
69+
:local:
70+
71+
Configuration
72+
=============
73+
74+
After installing the module:
75+
76+
1. Add your integration / service account to the **API Key
77+
Provisioning** group (*Settings → Users & Companies → Users*). Do
78+
**not** use a system administrator for this — the whole point is
79+
least privilege.
80+
2. Add each user that integrations may act as to the **API Key Mintable
81+
Target** group.
82+
83+
Two system parameters (*Settings → Technical → System Parameters*) tune
84+
the lifetime:
85+
86+
- ``auth_api_key_provisioning.default_ttl_days`` (default ``30``) —
87+
applied when a mint request omits ``ttl_days``.
88+
- ``auth_api_key_provisioning.max_ttl_days`` (default ``90``) —
89+
requested lifetimes are clamped down to this. An absolute ceiling is
90+
also enforced in code.
91+
92+
Usage
93+
=====
94+
95+
From a provisioning/service account (a member of *API Key
96+
Provisioning*), call the method over RPC on the target user. The target
97+
must be an internal user that is a member of the *API Key Mintable
98+
Target* group.
99+
100+
.. code:: python
101+
102+
import xmlrpc.client
103+
104+
common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common")
105+
uid = common.authenticate(db, "prov-svc", password, {})
106+
models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object")
107+
108+
# Mint a 7-day rpc key for user id 42:
109+
api_key = models.execute_kw(
110+
db, uid, password,
111+
"res.users", "mint_apikey", [[42]],
112+
{"name": "my-integration", "ttl_days": 7},
113+
)
114+
115+
# Later, revoke everything this module minted for that user:
116+
models.execute_kw(db, uid, password, "res.users", "revoke_provisioned_apikeys", [[42]])
117+
118+
The integration then authenticates as user 42 using ``api_key`` as the
119+
password on RPC/``/xmlrpc/2/object`` calls; Odoo applies that user's own
120+
ACLs and record rules.
121+
122+
Security model
123+
--------------
124+
125+
- **Caller gating** — only members of *API Key Provisioning* may mint or
126+
revoke; this is a dedicated least-privilege group, **not**
127+
``base.group_system``.
128+
- **Target allowlist** — keys are minted only for users explicitly
129+
placed in *API Key Mintable Target*. An allowlist is used rather than
130+
a blocklist because custom modules add their own high-privilege groups
131+
that no fixed blocklist could enumerate.
132+
- **Elevated targets refused** — minting is always refused for the
133+
superuser and for any member of ``base.group_system`` /
134+
``base.group_erp_manager``, even if mis-added to the allowlist.
135+
Portal/public (share) and archived users are refused too.
136+
- **Privilege-drift protection** — API keys carry no permission
137+
snapshot; they authenticate with the user's *current* groups. If a
138+
target with minted keys is later promoted into an elevated group (from
139+
the user side or the group side) or archived, its provisioned keys are
140+
revoked immediately; a daily cron is a backstop for changes that
141+
bypass the ORM hooks.
142+
- **Bounded lifetime** — keys are always ``rpc``-scoped and expiring.
143+
The TTL defaults to 30 days and is clamped to a configurable maximum
144+
(90 days), with an absolute code ceiling.
145+
- **Auditable** — every mint is logged with who/for-whom/when and a
146+
revocation timestamp.
147+
148+
Residual risks (by design)
149+
--------------------------
150+
151+
- Like all Odoo API keys, a minted key is **not** invalidated by a
152+
target password reset. Use ``revoke_provisioned_apikeys`` (or archive
153+
the user) on a suspected compromise.
154+
- A compromised provisioning account can mint keys for any *mintable*
155+
(non-elevated) user. Keep that group's membership minimal and monitor
156+
the provisioning log.
157+
158+
Bug Tracker
159+
===========
160+
161+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-auth/issues>`_.
162+
In case of trouble, please check there if your issue has already been reported.
163+
If you spotted it first, help us to smash it by providing a detailed and welcomed
164+
`feedback <https://github.com/OCA/server-auth/issues/new?body=module:%20auth_api_key_provisioning%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
165+
166+
Do not contact contributors directly about support or help with technical issues.
167+
168+
Credits
169+
=======
170+
171+
Authors
172+
-------
173+
174+
* AI Cognitive Leap
175+
176+
Contributors
177+
------------
178+
179+
- Jiri Manas <jiri.manas@keboola.com>
180+
181+
Other credits
182+
-------------
183+
184+
The development of this module was funded by AI Cognitive Leap.
185+
186+
Maintainers
187+
-----------
188+
189+
This module is maintained by the OCA.
190+
191+
.. image:: https://odoo-community.org/logo.png
192+
:alt: Odoo Community Association
193+
:target: https://odoo-community.org
194+
195+
OCA, or the Odoo Community Association, is a nonprofit organization whose
196+
mission is to support the collaborative development of Odoo features and
197+
promote its widespread use.
198+
199+
.. |maintainer-manana2520| image:: https://github.com/manana2520.png?size=40px
200+
:target: https://github.com/manana2520
201+
:alt: manana2520
202+
203+
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
204+
205+
|maintainer-manana2520|
206+
207+
This module is part of the `OCA/server-auth <https://github.com/OCA/server-auth/tree/18.0/auth_api_key_provisioning>`_ project on GitHub.
208+
209+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import models
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Copyright 2026 AI Cognitive Leap
2+
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
3+
4+
{
5+
"name": "Auth API Key Provisioning",
6+
"summary": """
7+
Admin-gated minting of short-lived, rpc-scoped API keys on behalf of
8+
another (non-elevated) internal user, for delegated per-user identity.""",
9+
"version": "18.0.1.0.0",
10+
"license": "LGPL-3",
11+
"author": "AI Cognitive Leap, Odoo Community Association (OCA)",
12+
"website": "https://github.com/OCA/server-auth",
13+
"development_status": "Beta",
14+
"maintainers": ["manana2520"],
15+
"category": "Tools",
16+
"depends": ["base"],
17+
"data": [
18+
"security/auth_api_key_provisioning_groups.xml",
19+
"security/ir.model.access.csv",
20+
"data/ir_config_parameter.xml",
21+
"data/ir_cron.xml",
22+
"views/auth_api_key_provisioning_log_views.xml",
23+
],
24+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<!-- Copyright 2026 AI Cognitive Leap
3+
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -->
4+
<odoo noupdate="1">
5+
<!-- Default lifetime applied when a mint request omits ttl_days. -->
6+
<record id="param_default_ttl_days" model="ir.config_parameter">
7+
<field name="key">auth_api_key_provisioning.default_ttl_days</field>
8+
<field name="value">30</field>
9+
</record>
10+
11+
<!-- Hard maximum lifetime; a larger requested ttl_days is clamped down to this.
12+
A further absolute ceiling (HARD_MAX_TTL_DAYS) is enforced in code. -->
13+
<record id="param_max_ttl_days" model="ir.config_parameter">
14+
<field name="key">auth_api_key_provisioning.max_ttl_days</field>
15+
<field name="value">90</field>
16+
</record>
17+
</odoo>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<!-- Copyright 2026 AI Cognitive Leap
3+
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -->
4+
<odoo noupdate="1">
5+
<record id="cron_revoke_drifted_apikeys" model="ir.cron">
6+
<field name="name">Auth API Key Provisioning: revoke drifted keys</field>
7+
<field name="model_id" ref="base.model_res_users" />
8+
<field name="state">code</field>
9+
<field name="code">model._cron_revoke_drifted_apikeys()</field>
10+
<field name="interval_number">1</field>
11+
<field name="interval_type">days</field>
12+
<field name="nextcall" eval="(DateTime.now()).strftime('%Y-%m-%d 02:00:00')" />
13+
<field name="user_id" ref="base.user_root" />
14+
<field name="active" eval="True" />
15+
</record>
16+
</odoo>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from . import auth_api_key_provisioning_log
2+
from . import res_groups
3+
from . import res_users
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Copyright 2026 AI Cognitive Leap
2+
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
3+
4+
from odoo import api, fields, models
5+
6+
7+
class AuthApiKeyProvisioningLog(models.Model):
8+
"""Provenance + audit trail for keys minted by this module.
9+
10+
``res.users.apikeys`` is a raw ``_auto=False`` table with a fixed schema, so we
11+
cannot tag the key record itself with extra columns. Instead every minted key is
12+
recorded here, which gives us (a) a reliable handle to revoke by -- never by name
13+
matching -- and (b) an auditable history of who minted what, for whom, and when.
14+
15+
The link to the key uses ``ondelete="set null"`` so the audit row survives the key
16+
being removed (by revocation, native expiry GC, or manual deletion).
17+
"""
18+
19+
_name = "auth.api.key.provisioning.log"
20+
_description = "Auth API Key Provisioning Log"
21+
_order = "create_date desc, id desc"
22+
_rec_name = "key_name"
23+
24+
apikey_id = fields.Many2one(
25+
"res.users.apikeys",
26+
string="API Key",
27+
ondelete="set null",
28+
readonly=True,
29+
help="The minted key. Empty once the key has been revoked, expired or removed.",
30+
)
31+
user_id = fields.Many2one(
32+
"res.users",
33+
string="Target User",
34+
required=True,
35+
readonly=True,
36+
ondelete="cascade",
37+
index=True,
38+
help="User the key was minted for; the key carries this user's "
39+
"own permissions.",
40+
)
41+
minted_by_id = fields.Many2one(
42+
"res.users",
43+
required=True,
44+
readonly=True,
45+
help="User (the provisioning/service account) that requested the mint.",
46+
)
47+
key_name = fields.Char(string="Label", readonly=True)
48+
scope = fields.Char(readonly=True, default="rpc")
49+
expiration = fields.Datetime(readonly=True)
50+
revoked_on = fields.Datetime(
51+
readonly=True,
52+
help="Set when this module actively revoked the key "
53+
"(manual revoke or automatic privilege-drift / offboarding revoke).",
54+
)
55+
is_live = fields.Boolean(
56+
string="Live",
57+
compute="_compute_is_live",
58+
help="True while the underlying key record still exists.",
59+
)
60+
61+
@api.depends("apikey_id")
62+
def _compute_is_live(self):
63+
# res.users.apikeys is _auto=False (no FK on apikey_id), so a key removed
64+
# outside this module leaves a dangling id; check real existence.
65+
for log in self:
66+
log.is_live = bool(log.apikey_id and log.apikey_id.exists())
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Copyright 2026 AI Cognitive Leap
2+
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
3+
4+
from odoo import models
5+
6+
7+
class ResGroups(models.Model):
8+
_inherit = "res.groups"
9+
10+
def write(self, vals):
11+
res = super().write(vals)
12+
# Privilege can be granted from the group side without ever calling
13+
# res.users.write: by adding members (``users``) or by making a group imply an
14+
# elevated one (``implied_ids``). The set of users affected by an implied_ids
15+
# change is transitive and awkward to compute exactly, so fall back to the full
16+
# sweep (narrowed to live-key holders, hence cheap). A pure ``users`` change
17+
# only affects the listed members, so re-check just those.
18+
if "implied_ids" in vals:
19+
self.env["res.users"]._apikey_provisioning_sweep_all()
20+
elif "users" in vals:
21+
users = self.mapped("users")
22+
if users:
23+
users._apikey_provisioning_revoke_if_unsafe()
24+
return res

0 commit comments

Comments
 (0)