Skip to content

ADR 0006: SA Access Security โ€” Foundation, Gap Analysis, and Staged Hardening

Status

Proposed

Date

2026-06-13


Context

dirac-odoo implements SA (Service Account) as an access governance layer that controls who can use which Odoo transactional models โ€” without consuming Odoo seats. The architecture uses two layers:

  1. SA node (ov.serviced_account) โ€” the governance scope
  2. Association edges (ov.sa_* + ov.actor_sa_*) โ€” bindings between SA nodes and Odoo transactional records

Two security questions were raised during design review:

Q1: Assuming the association tables are immutable, will this be sufficient to prevent someone who hacks the PA (POS Applet) from gaining access to records outside of the table rows?

Q2: Are the association tables safe from changing (tampering by authorized internal users)?

Live Odoo inspection (uid=2, Admin) revealed that the answers are both negative โ€” not because the SA model is wrong, but because enforcement is missing.

Authentication vs. Authorization โ€” The Critical Distinction

This ADR addresses both tracks:

Track Concern Odoo Mechanism SA-Specific
Track A: Authentication Who is accessing the system? Odoo user login, ir.rule bypass for Admin PA service user, KeyCloak SSO integration
Track B: Authorization What can the authenticated user access? ir.rule (record rules) on every ORM query ov.sa_* association table enforcement

Key insight: The SA association tables define authorization scope (what an actor should access). But Odoo will only enforce this scope if: 1. The authenticated user is not Admin (Track A โœ…) 2. ir.rule exists on the target model (Track B โœ…)

Both tracks must be implemented. Track B can be parallelized; Track A has sequential dependencies on SSO integration.


Gap Analysis

Live Odoo inspection (2026-06-13, uid=2 Admin session) revealed five security gaps. Each gap is mapped to an industry-standard security pattern for team reference.

G1: No ir.rule on Transaction Models โ€” ๐Ÿ”ด HIGH

Finding: pos.order has only company-scoped ir.rule:

[('company_id', 'in', company_ids)]  # Standard Odoo multi-company rule
No SA-scoped rule exists. Direct RPC calls can read all pos.order records across all SAs.

Industry Pattern: SQL Row-Level Security (RLS). In PostgreSQL, this would be:

CREATE POLICY sa_pos_order_policy ON pos_order
  USING (id IN (SELECT pos_order_id FROM ov_sa_pos_order WHERE ...));
Odoo ir.rule is the ORM-level equivalent. Without it, authorization is bypassed.

Root Cause: ADR 0003 defined the association schema but did not mandate ir.rule enforcement as part of the pattern.


G2: Admin Bypass โ€” ๐Ÿ”ด HIGH

Finding: PA connects as Admin (team_crm@omnivoltaic.com, uid=2). Odoo's SUPERUSER_ID behavior: uid=1 (and often uid=2) bypasses ALL ir.rule checks. Even if G1 is fixed (add ir.rule to pos.order), it will NOT be enforced for the PA.

Industry Pattern: UNIX setuid bit / privilege separation. Running the PA as root (Admin) means any compromise gives full system access. The fix is to run as a dedicated service account with least privilege.

Root Cause: PA was prototyped with Admin credentials for convenience. This is not safe for production.


G3: No ir.rule on Association Tables โ€” ๐ŸŸก MEDIUM

Finding: ov.sa_pos_order, ov.actor_sa_pos_order, and ov.serviced_account have no ir.rule. Any internal user (anyone with an Odoo seat) can read all SAs' association records via direct RPC.

Industry Pattern: Access Control List (ACL) enforcement. The association tables ARE the ACL. An ACL is only effective if the reference monitor (Odoo ORM ir.rule) cannot be bypassed. Reading another SA's ACL entries is itself a security violation.

Root Cause: Association models were created without ir.rule because they were assumed to be "backend-only." But Odoo internal users can query any model via XML-RPC.


G4: Full CRUD on Association Models for Internal Users โ€” ๐ŸŸก MEDIUM

Finding: ir.model.access for all ov.sa_* and ov.actor_sa_* models grants full CRUD to Internal Users:

User Type Read Write Create Unlink
Internal User (has Odoo seat) โœ… โœ… โœ… โœ…
Portal User (no Odoo seat) โœ… โŒ โŒ โŒ

Any internal user can create/delete/modify association records, effectively granting themselves access to any SA's data.

Industry Pattern: Mandatory Access Control (MAC) vs. Discretionary Access Control (DAC). The current model is pure DAC โ€” the owner (any internal user) can change permissions. MAC would prevent even the owner from changing security labels. Odoo ir.rule with global=True approximates MAC.

Root Cause: Odoo's default ACL generation grants full CRUD to Internal Users. This must be restricted for governance models.


G5: No PA Service User โ€” ๐Ÿ”ด HIGH

Finding: PA uses Admin credentials stored in odoo_config.json. If this file is compromised (even with .gitignore), the attacker has full Odoo access.

Industry Pattern: Static Password vs. Service Identity. See Staged Implementation below for detailed comparison.

Root Cause: Prototype used Admin for convenience. Production requires dedicated service identity.


Industry Pattern References

The SA security model maps to established patterns in operating systems, databases, and distributed systems. Understanding these mappings helps the team (and AI agents) infer correct implementation from broader resources.

Pattern 1: UNIX DAC + MAC Hybrid

UNIX Concept Odoo / SA Equivalent
File permissions (DAC) Odoo ir.model.access (ACL per model)
SELinux security labels (MAC) ov.sa_* association records (immutable bindings)
setuid / privilege separation Dedicated PA service user (non-Admin)
sudo bypass Odoo Admin bypass of ir.rule

Inference for team: If you understand UNIX file permissions + SELinux, you understand SA security. The association table is the security label. ir.rule is the enforcement hook. Admin is root โ€” don't use it for application logic.


Pattern 2: SQL Row-Level Security (RLS)

RLS Concept Odoo / SA Equivalent
CREATE POLICY ir.rule record
current_user env.user (Odoo current user)
Policy function Python callable in ir.rule domain
FORCE ROW LEVEL SECURITY ir.rule with global=True (cannot be bypassed by sudo)

PostgreSQL RLS Example (defense-in-depth for future):

-- Enforce SA scope at DB level (even if ORM is bypassed)
ALTER TABLE pos_order ENABLE ROW LEVEL SECURITY;

CREATE POLICY sa_pos_order_policy ON pos_order
  USING (id IN (
    SELECT pos_order_id FROM ov_sa_pos_order
    WHERE account_id IN (
      SELECT account_id FROM ov_actor_sa_pos_order
      WHERE actor_id = current_setting('app.current_actor_partner_id')::integer
      AND state = 'active'
    )
    AND state = 'active'
  ));

Inference for team: Odoo ir.rule is ORM-level RLS. For production multi-tenant SaaS, also implement PostgreSQL RLS as defense-in-depth.


Pattern 3: HashiCorp Vault โ€” Dynamic Secrets

Vault Concept Odoo / SA Equivalent (Future)
Static secrets Current: ODOO_PASSWORD in odoo_config.json
Dynamic secrets Future: Short-lived Odoo session tokens via KeyCloak
AppRole Future: PA service user with rotated credentials
PKI secrets engine Future: mTLS between PA and Odoo

Inference for team: Static passwords in config files are the #1 cause of credential leaks. Staged implementation (Track A) eliminates this.


Pattern 4: PKI / mTLS โ€” Service Identity

PKI Concept Odoo / SA Equivalent (Future)
Certificate (public key) Odoo user certificate (future)
Private key (never leaves host) PA private key in secure storage
mTLS handshake Future: Odoo OIDC with mutual TLS
Short-lived certificates KeyCloak token rotation

Inference for team: Service identity (cryptographic) is stronger than service account (password-based). KeyCloak SSO is the bridge.


Pattern 5: OWASP ASVS V8 โ€” Multi-Tenant SaaS Security

OWASP Application Security Verification Standard (ASVS) V8 covers "Data Isolation and Multi-Tenancy." Relevant requirements:

ASVS V8 Requirement SA Status Remediation
V8.1.1: Tenant data isolated at DB layer โŒ Not implemented PostgreSQL RLS (Track B, Stage 3)
V8.1.2: Tenant context validated on every request โŒ Not implemented ir.rule (Track B, Stage 1)
V8.2.1: Authorization checks cannot be bypassed โŒ Admin bypass Dedicated service user (Track A, Stage 1)
V8.3.1: Audit log for cross-tenant access โŒ Not implemented mail.message or custom audit model

Pattern 6: NIST Zero Trust Architecture (SP 800-207)

NIST ZTA assumes breach and enforces "never trust, always verify." SA security maps to ZTA as:

ZTA Component Odoo / SA Equivalent
Policy Decision Point (PDP) ov.sa_* association tables (what access is allowed)
Policy Enforcement Point (PEP) Odoo ir.rule (enforces access on every query)
Continuous diagnostic Future: audit log of all SA-scoped queries
Least privilege scope_policy field (assigned_only / sa_wide)

Design Directions

Direction A: Enforce SA Scope via ir.rule (Track B โ€” Mandatory)

Add ir.rule to every native Odoo model that has an SA association. The rule must join through ov.sa_* and ov.actor_sa_* to enforce per-actor, per-SA scope.

Prescriptive Implementation:

# ir.rule on pos.order โ€” SA-scoped read/write
# Domain: user can only access pos.order records assigned to their SA(s)
[
  ('id', 'in',
    env['ov.sa.pos.order'].search([
      ('account_id', 'in',
        env['ov.actor.sa.pos.order'].search([
          ('actor_partner_id', '=', env.user.partner_id.id),
          ('membership_state', '=', 'active')
        ]).mapped('account_id').ids
      ),
      ('state', '=', 'active')
    ]).mapped('pos_order_id').ids
  )
]

Important: The above rule uses env.user.partner_id โ€” this only works if the authenticated user is NOT Admin (Track A must be done first).


Direction B: Dedicated PA Service User (Track A โ€” Mandatory)

Create a dedicated Odoo user for PA connections:

  • No admin rights โ€” belongs to Base User group only
  • No sudo privilege โ€” cannot escalate to admin
  • Restricted ACL โ€” read access to pos.order, write access only to ov.sa_pos_order for their own SA
  • Dedicated credential โ€” rotatable independently of Admin

KeyCloak SSO Integration: Once SSO is live, the PA service user authenticates via OIDC, not password. See Staged Implementation below.


Direction C: Immutable Association Tables (Track B โ€” Desired)

The association tables should be writable only by backend processes, not by any Odoo user interface:

  1. Set groups_id on ir.model.access to a restricted group (e.g., ov.group_sa_backend โ€” not "Internal User")
  2. Use Odoo's ir.rule with global=True to prevent direct writes
  3. Consider database-level INSERT-only (no UPDATE/DELETE) for audit trail (PostgreSQL trigger or RLS)

Direction D: PostgreSQL Row-Level Security (Track B โ€” Future)

For industry-strength enforcement, add PostgreSQL RLS as a defense-in-depth layer. This ensures even direct SQL access (bypassing Odoo ORM) cannot bypass SA scope.

Prerequisite: Odoo must pass current_setting('app.current_actor_partner_id') to PostgreSQL for every session. This requires a custom Odoo module that sets the session variable on login.


Staged Implementation

Track A: Authentication Hardening

Stage Work Dev Effort Migration Friction
Stage 1 Create dedicated PA service user (non-Admin), configure restricted ACL 3-5 days Low (config change)
Stage 2 Integrate KeyCloak SSO โ€” PA authenticates via OIDC, gets session for service user 5-8 days Medium (new auth flow)
Stage 3 Short-lived tokens via KeyCloak, credential rotation, mTLS (optional) 15-25 days Low (if CredentialProvider abstraction exists)

Stage 1 Detail: 1. Create Odoo user pos-applet-service (no admin groups) 2. Configure ACL: read pos.order, write ov.sa_pos_order (own SA only) 3. Update PA config to use pos-applet-service credentials 4. Verify ir.rule now enforced (run test: try to read cross-SA pos.order)

KeyCloak SSO Implication: Stage 1 is a prerequisite for SSO integration. SSO should map to pos-applet-service, not Admin.


Track B: Authorization Hardening (ir.rule Enforcement)

Stage Work Dev Effort Depends On
Stage 1 Add ir.rule to pos.order, sale.order, and all transactional models 5-8 days Track A Stage 1 (non-Admin user)
Stage 2 Add ir.rule to all ov.sa_* and ov.actor_sa_* models 3-5 days Stage 1
Stage 3 PostgreSQL RLS for defense-in-depth 10-15 days Stage 2, custom Odoo module

Stage 1 Detail: 1. Write ir.rule domain for pos.order (SA-scoped) 2. Write ir.rule domain for sale.order (SA-scoped) 3. Test: authenticate as non-Admin user, verify cross-SA access blocked 4. Test: direct RPC call read() on pos.order โ€” verify only SA-scoped records returned

Parallelizable: Track B Stage 1 can run in parallel with Track A Stage 1.


Week 1 (Parallel):
โ”œโ”€โ”€ Track A Stage 1: Create PA service user (3-5 days)
โ””โ”€โ”€ Track B Stage 1: Add ir.rule to pos.order, sale.order (5-8 days)

Week 2:
โ”œโ”€โ”€ Track A Stage 2: Integrate KeyCloak SSO (OIDC) (5-8 days)
โ””โ”€โ”€ Track B Stage 2: Add ir.rule to ov.sa_* models (3-5 days)

Week 3 (if needed):
โ””โ”€โ”€ Track A Stage 3: Short-lived tokens, credential rotation (15-25 days)

Critical Path: Track A Stage 1 must complete before ir.rule can be verified (because Admin bypasses ir.rule). But Track B development can happen in parallel.


KeyCloak SSO Integration โ€” Implications for SA Security

KeyCloak SSO is being implemented for company-wide resources (MS Teams, Odoo, compute resources). This changes Track A but does NOT eliminate the need for Track B.

What KeyCloak Solves

Layer KeyCloak Covers KeyCloak Does NOT Cover
Authentication โœ… Centralized identity, MFA, SSO across systems โ€”
Service Identity โš ๏ธ Possible via OIDC client credentials Requires proper Odoo integration
Authorization within Odoo โŒ No โ€” KeyCloak sends claims, Odoo must enforce โœ… ir.rule (Track B)
Association-table binding โŒ No โ€” KeyCloak doesn't know ov.sa_* โœ… SA-specific logic
Audit trail for SA access โŒ KeyCloak audits login, not Odoo record access โœ… Odoo-level audit

Key Risk: Admin Bypass + KeyCloak

If PA authenticates via KeyCloak but still gets Admin session (uid=2), then ir.rule enforcement is still bypassed.

Ensure: KeyCloak OIDC integration maps to the dedicated PA service user (non-Admin), not to Admin.

KeyCloak OIDC Config:
- OIDC client: `pos-applet`
- Subject: maps to Odoo user `pos-applet-service` (uid=NEW, non-Admin)
- Odoo OIDC plugin: maps `sub` claim โ†’ `pos-applet-service`

Revised Track A with KeyCloak

Current State: PA uses Admin password (static, long-lived, ir.rule bypass)
โ†“
Stage 1: Create dedicated PA service user (non-Admin)
โ†“ (parallel with SSO integration)
Stage 2: Integrate KeyCloak SSO โ€” PA authenticates via OIDC
โ†“ Result: PA uses dedicated service user + ir.rule enforced
โ†“
Stage 3 (optional): KeyCloak client credentials for PA (machine-to-machine)

Dev effort reduction: With KeyCloak SSO in progress, Stage 2 effort drops from 5-8 days to 3-5 days (Odoo OIDC plugin may already be available via auth_oidc community module).


Consequences

Security Gaps (Current State)

Gap Severity Industry Pattern Remediation Track
G1: No ir.rule on pos.order ๐Ÿ”ด HIGH SQL RLS Track B Stage 1
G2: Admin bypass ๐Ÿ”ด HIGH UNIX privilege separation Track A Stage 1
G3: No ir.rule on association tables ๐ŸŸก MEDIUM ACL enforcement Track B Stage 2
G4: Full CRUD on association models ๐ŸŸก MEDIUM MAC (Mandatory Access Control) Track B Stage 2
G5: No PA service user ๐Ÿ”ด HIGH Static password vs. service identity Track A Stage 1

Design Commitments

  1. Association tables are the ACL โ€” access control is based on immutable bindings, not UI-level filtering
  2. ir.rule enforcement is mandatory โ€” every native Odoo model with SA scope MUST have a record rule joining through ov.sa_*
  3. PA connects as restricted service user โ€” no admin credentials in any client-side or applet-side configuration
  4. KeyCloak SSO maps to service user, not Admin โ€” SSO integration must preserve least-privilege principle
  5. Defense-in-depth โ€” PostgreSQL RLS will be added as a future hardening layer (Track B Stage 3)
  6. Audit log for SA access โ€” all SA-scoped queries must be logged (future, not in this ADR)

What Changes, What Stays the Same

Changes: - PA no longer connects as Admin - ir.rule enforced on all SA-scoped models - Association tables restricted to backend processes

Stays the Same: - SA association model (ADR 0003) โ€” schema unchanged - SA context switching logic โ€” unchanged - POS Applet UI โ€” unchanged (authentication is backend change)


Next Steps (Prescriptive)

Immediate (Week 1)

  1. โœ… Create PA service user (pos-applet-service):
  2. Odoo UI: Settings โ†’ Users โ†’ Create
  3. Groups: Base User only (no Admin)
  4. Verify: uid != 2, ir.rule now enforced

  5. โœ… Add ir.rule to pos.order (SA-scoped):

  6. Settings โ†’ Technical โ†’ Record Rules โ†’ Create
  7. Model: pos.order
  8. Domain: see Direction A above
  9. Test: authenticate as service user, read pos.order

  10. โœ… Update PA config to use service user credentials:

  11. Update odoo_config.json (local only, not committed)
  12. Verify connection: uid = NEW_SERVICE_USER_ID

Short-Term (Week 2-3)

  1. โœ… Add ir.rule to all transactional models (sale.order, account.move, etc.)

  2. โœ… Add ir.rule to ov.sa_* and ov.actor_sa_* models

  3. โœ… Integrate KeyCloak SSO (OIDC) for PA authentication

Medium-Term (Future)

  1. โœ… PostgreSQL RLS for defense-in-depth

  2. โœ… Audit log for all SA-scoped queries

  3. โœ… Short-lived tokens via KeyCloak credential rotation


References

  • ADR 0003: V3 Association-Model Migration Pattern โ€” query pattern and referential integrity
  • ADR 0005: V3 POS Applet Design โ€” SA governance for POS transactions
  • Odoo Documentation: ir.rule (Record Rules) and ir.model.access
  • PostgreSQL Documentation: Row-Level Security (RLS)
  • OWASP ASVS V8: Data Isolation and Multi-Tenancy
  • NIST SP 800-207: Zero Trust Architecture
  • HashiCorp Vault: Dynamic Secrets engine (reference for future Stage 3)
  • UNIX Man Pages: setuid(2), capabilities(7) (reference for privilege separation)
  • KeyCloak Documentation: OIDC clients, client credentials, token rotation