Skip to content
Draft
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
209 changes: 209 additions & 0 deletions auth_api_key_provisioning/README.rst
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/OCA/server-auth/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 <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**>`_.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
-------

* Keboola

Contributors
------------

- Jiri Manas <jiri.manas@keboola.com>

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 <https://odoo-community.org/page/maintainer-role>`__:

|maintainer-manana2520|

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.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions auth_api_key_provisioning/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
24 changes: 24 additions & 0 deletions auth_api_key_provisioning/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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",
],
}
17 changes: 17 additions & 0 deletions auth_api_key_provisioning/data/ir_config_parameter.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2026 Keboola
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -->
<odoo noupdate="1">
<!-- Default lifetime applied when a mint request omits ttl_days. -->
<record id="param_default_ttl_days" model="ir.config_parameter">
<field name="key">auth_api_key_provisioning.default_ttl_days</field>
<field name="value">30</field>
</record>

<!-- Hard maximum lifetime; a larger requested ttl_days is clamped down to this.
A further absolute ceiling (HARD_MAX_TTL_DAYS) is enforced in code. -->
<record id="param_max_ttl_days" model="ir.config_parameter">
<field name="key">auth_api_key_provisioning.max_ttl_days</field>
<field name="value">90</field>
</record>
</odoo>
16 changes: 16 additions & 0 deletions auth_api_key_provisioning/data/ir_cron.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2026 Keboola
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -->
<odoo noupdate="1">
<record id="cron_revoke_drifted_apikeys" model="ir.cron">
<field name="name">Auth API Key Provisioning: revoke drifted keys</field>
<field name="model_id" ref="base.model_res_users" />
<field name="state">code</field>
<field name="code">model._cron_revoke_drifted_apikeys()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="nextcall" eval="(DateTime.now()).strftime('%Y-%m-%d 02:00:00')" />
<field name="user_id" ref="base.user_root" />
<field name="active" eval="True" />
</record>
</odoo>
3 changes: 3 additions & 0 deletions auth_api_key_provisioning/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import auth_api_key_provisioning_log
from . import res_groups
from . import res_users
66 changes: 66 additions & 0 deletions auth_api_key_provisioning/models/auth_api_key_provisioning_log.py
Original file line number Diff line number Diff line change
@@ -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())
24 changes: 24 additions & 0 deletions auth_api_key_provisioning/models/res_groups.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading