# -*- 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