This commit is contained in:
gsinghpal
2026-05-04 02:14:34 -04:00
parent 3cc393454d
commit 586f05d567
43 changed files with 3656 additions and 112 deletions

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import models

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
{
'name': 'Fusion Helpdesk Central — Client API Keys',
'version': '19.0.1.0.2',
'category': 'Productivity',
'summary': 'Admin UI on the central Odoo for issuing per-client API '
'keys used by fusion_helpdesk client deployments.',
'description': """
Fusion Helpdesk Central
=======================
Companion to `fusion_helpdesk`. Install on the central Odoo (the one
running the Helpdesk app) to manage **per-client API keys** instead of
shipping a shared bot password to every client deployment.
Each row in *Helpdesk → Configuration → Client API Keys* maps a client
label (e.g. ENTECH, MOBILITY) to a real `res.users.apikeys` row on the
shared bot user. The plaintext key is shown ONCE on creation; revoke
in one click if a deployment is compromised.
Depends only on `helpdesk`. No client-side install needed.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'license': 'OPL-1',
'depends': ['helpdesk'],
'data': [
'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml',
'views/fusion_helpdesk_client_key_views.xml',
],
'installable': True,
'auto_install': False,
'application': False,
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
-->
<odoo noupdate="1">
<record id="fhc_default_bot_login" model="ir.config_parameter">
<field name="key">fusion_helpdesk.bot_login</field>
<field name="value">helpdesk_bot@nexasystems.ca</field>
</record>
</odoo>

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import fusion_helpdesk_client_key

View File

@@ -0,0 +1,186 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Per-client API key registry for fusion_helpdesk.
Each row links a client deployment label (e.g. ENTECH) to a real
`res.users.apikeys` row on a shared bot account. The plaintext key is
shown ONCE on creation, then cleared. Revoking a row deletes the
underlying API key so the client deployment can no longer authenticate.
"""
import logging
from datetime import datetime, timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionHelpdeskClientKey(models.Model):
_name = 'fusion.helpdesk.client.key'
_description = 'Fusion Helpdesk — Client API Key'
_order = 'create_date desc'
_rec_name = 'client_label'
client_label = fields.Char(
string='Client Label', required=True, index=True,
help='Short tag identifying this client deployment '
'(e.g. ENTECH, MOBILITY). Free text — used for your '
'reference and to find keys quickly when revoking.',
)
notes = fields.Text(
string='Notes',
help='Optional. Stamp deployment URL, contact, install date.',
)
bot_user_id = fields.Many2one(
'res.users', string='Bot User', readonly=True,
ondelete='restrict',
)
apikey_id = fields.Many2one(
'res.users.apikeys', string='API Key Record',
readonly=True, ondelete='set null',
help='Underlying res.users.apikeys row. Cleared if the key '
'is revoked or deleted out-of-band.',
)
apikey_name = fields.Char(
string='Key Name', related='apikey_id.name', store=True,
readonly=True,
)
plaintext_key = fields.Char(
string='Plaintext Key (one-time display)',
readonly=True, copy=False,
help='Shown ONCE right after creation. Copy it now — once you '
'click "Mark Stored" it is wiped from this record forever. '
'The key keeps working; we just stop showing it.',
)
is_revoked = fields.Boolean(
string='Revoked', compute='_compute_is_revoked', store=True,
)
create_date = fields.Datetime(readonly=True)
display_name = fields.Char(
string='Display Name', compute='_compute_display_name', store=True,
)
@api.depends('client_label', 'is_revoked')
def _compute_display_name(self):
for rec in self:
base = rec.client_label or _('(unnamed)')
rec.display_name = ('%s [REVOKED]' % base) if rec.is_revoked else base
_sql_constraints = [
('fhc_client_label_unique',
'unique(client_label)',
'Client label must be unique — one row per client deployment.'),
]
@api.depends('apikey_id')
def _compute_is_revoked(self):
for rec in self:
rec.is_revoked = not rec.apikey_id
# ----------------------------------------------------------------------
@api.model
def _default_expiration_date(self):
"""Odoo 19 requires API keys to carry an expiration. Default to
10 years out so client deployments don't break unexpectedly.
Override per row by writing to apikey_id.expiration_date later."""
years = int(
self.env['ir.config_parameter'].sudo().get_param(
'fusion_helpdesk.key_expiration_years', '10',
) or 10
)
return datetime.utcnow() + timedelta(days=365 * years)
@api.model
def _get_bot_user(self):
ICP = self.env['ir.config_parameter'].sudo()
login = (ICP.get_param('fusion_helpdesk.bot_login') or '').strip()
if not login:
raise UserError(_(
'No bot user configured. Set `fusion_helpdesk.bot_login` '
'in System Parameters first.'
))
user = self.env['res.users'].search([('login', '=', login)], limit=1)
if not user:
raise UserError(_(
'Bot user "%s" not found. Create it first or update '
'`fusion_helpdesk.bot_login`.', login
))
return user
@api.model_create_multi
def create(self, vals_list):
ApiKey = self.env['res.users.apikeys'].with_context(
mail_create_nosubscribe=True,
)
bot = self._get_bot_user()
records = super().create(vals_list)
for rec in records:
# Generate the apikey AS the bot user — the key is bound to
# the user that creates it.
label = 'fusion_helpdesk:%s' % (rec.client_label or rec.id)
key = ApiKey.with_user(bot)._generate(
'rpc', label, self._default_expiration_date(),
)
# Find the apikey row that was just created so we can link it.
apikey_row = self.env['res.users.apikeys'].sudo().search(
[('user_id', '=', bot.id), ('name', '=', label)],
limit=1, order='id desc',
)
rec.write({
'bot_user_id': bot.id,
'apikey_id': apikey_row.id if apikey_row else False,
'plaintext_key': key,
})
_logger.info(
'fusion_helpdesk_central: issued API key for client "%s" '
'(apikey_id=%s, bot_user=%s)',
rec.client_label, apikey_row.id if apikey_row else '?',
bot.login,
)
return records
def action_mark_stored(self):
"""User confirms they have copied the plaintext key. Wipe it
from the DB so a later read can't recover it. The underlying
res.users.apikeys row keeps working — only the plaintext copy
on this row is destroyed."""
self.write({'plaintext_key': False})
return True
def action_revoke(self):
"""Delete the underlying res.users.apikeys row. The client
deployment's next request will get an auth_failed error."""
for rec in self:
if rec.apikey_id:
rec.apikey_id.sudo().unlink()
_logger.warning(
'fusion_helpdesk_central: REVOKED API key for "%s" '
'(was apikey_id=%s)',
rec.client_label, rec.apikey_id.id,
)
rec.write({'plaintext_key': False})
return True
def action_rotate(self):
"""One-shot: revoke the existing key + issue a new one. Plaintext
is shown once on the same row."""
ApiKey = self.env['res.users.apikeys']
bot = self._get_bot_user()
for rec in self:
if rec.apikey_id:
rec.apikey_id.sudo().unlink()
label = 'fusion_helpdesk:%s' % rec.client_label
key = ApiKey.with_user(bot)._generate(
'rpc', label, self._default_expiration_date(),
)
apikey_row = self.env['res.users.apikeys'].sudo().search(
[('user_id', '=', bot.id), ('name', '=', label)],
limit=1, order='id desc',
)
rec.write({
'apikey_id': apikey_row.id if apikey_row else False,
'plaintext_key': key,
})
return True

View File

@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fhc_client_key_admin,fusion.helpdesk.client.key.admin,model_fusion_helpdesk_client_key,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fhc_client_key_admin fusion.helpdesk.client.key.admin model_fusion_helpdesk_client_key base.group_system 1 1 1 1

View File

@@ -0,0 +1,136 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
-->
<odoo>
<record id="view_fhc_client_key_list" model="ir.ui.view">
<field name="name">fusion.helpdesk.client.key.list</field>
<field name="model">fusion.helpdesk.client.key</field>
<field name="arch" type="xml">
<list string="Helpdesk Client API Keys"
decoration-muted="is_revoked">
<field name="client_label"/>
<field name="apikey_name" optional="hide"/>
<field name="bot_user_id"/>
<field name="create_date"/>
<field name="is_revoked" widget="boolean_toggle" readonly="1"/>
<field name="notes" optional="hide"/>
</list>
</field>
</record>
<record id="view_fhc_client_key_form" model="ir.ui.view">
<field name="name">fusion.helpdesk.client.key.form</field>
<field name="model">fusion.helpdesk.client.key</field>
<field name="arch" type="xml">
<form string="Helpdesk Client API Key">
<header>
<button name="action_mark_stored" type="object"
string="Mark Key Stored"
class="btn-primary"
invisible="not plaintext_key"
confirm="Wipe the plaintext key from this record? It can't be recovered after this."/>
<button name="action_rotate" type="object"
string="Rotate Key"
class="btn-secondary"
invisible="is_revoked or plaintext_key"
confirm="Revoke the existing key and issue a fresh one? The client deployment will need its config updated to keep working."/>
<button name="action_revoke" type="object"
string="Revoke"
class="btn-danger"
invisible="is_revoked"
confirm="Revoke this key permanently? The client deployment will stop being able to file tickets."/>
</header>
<sheet>
<widget name="web_ribbon" title="Revoked"
bg_color="bg-danger"
invisible="not is_revoked"/>
<div class="oe_title">
<label for="client_label"/>
<h1><field name="client_label" placeholder="e.g. ENTECH"/></h1>
</div>
<div class="alert alert-warning"
role="alert"
invisible="not plaintext_key">
<h4 class="alert-heading">
<i class="fa fa-key me-1"/> Copy the key now
</h4>
<p>
This is the <strong>only time</strong> the plaintext key
is shown. After you click <em>Mark Key Stored</em> in the
header it will be wiped from this record forever.
The key keeps working — we just stop displaying it.
</p>
<pre class="mt-2 p-2 bg-light text-dark"
style="user-select: all; word-break: break-all;"
><field name="plaintext_key" nolabel="1" readonly="1"/></pre>
</div>
<group>
<group>
<field name="bot_user_id"/>
<field name="apikey_name"/>
<field name="create_date"/>
</group>
<group>
<field name="apikey_id" readonly="1"/>
<field name="is_revoked" readonly="1"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1" placeholder="Deployment URL, contact email, install date…"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_fhc_client_key_search" model="ir.ui.view">
<field name="name">fusion.helpdesk.client.key.search</field>
<field name="model">fusion.helpdesk.client.key</field>
<field name="arch" type="xml">
<search>
<field name="client_label"/>
<field name="notes"/>
<separator/>
<filter string="Active" name="active"
domain="[('is_revoked', '=', False)]"/>
<filter string="Revoked" name="revoked"
domain="[('is_revoked', '=', True)]"/>
<group>
<filter string="Bot User" name="g_bot"
context="{'group_by': 'bot_user_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fhc_client_key" model="ir.actions.act_window">
<field name="name">Helpdesk Client API Keys</field>
<field name="res_model">fusion.helpdesk.client.key</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Issue a new API key
</p>
<p>
Each row maps a client deployment (e.g. ENTECH, MOBILITY)
to a real API key on the shared bot user. The plaintext
key is shown ONCE on creation — copy it then click
"Mark Key Stored". Revoke any key in one click if a
deployment is compromised.
</p>
</field>
</record>
<menuitem id="menu_fhc_client_key"
name="Client API Keys"
parent="helpdesk.helpdesk_menu_config"
action="action_fhc_client_key"
sequence="90"/>
</odoo>