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:
- SA node (
ov.serviced_account) โ the governance scope - 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
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 ...));
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 Usergroup only - No
sudoprivilege โ cannot escalate to admin - Restricted ACL โ read access to
pos.order, write access only toov.sa_pos_orderfor 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:
- Set
groups_idonir.model.accessto a restricted group (e.g.,ov.group_sa_backendโ not "Internal User") - Use Odoo's
ir.rulewithglobal=Trueto prevent direct writes - Consider database-level
INSERT-only (noUPDATE/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.
Recommended Sequence (with KeyCloak SSO in Progress)¶
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¶
- Association tables are the ACL โ access control is based on immutable bindings, not UI-level filtering
ir.ruleenforcement is mandatory โ every native Odoo model with SA scope MUST have a record rule joining throughov.sa_*- PA connects as restricted service user โ no admin credentials in any client-side or applet-side configuration
- KeyCloak SSO maps to service user, not Admin โ SSO integration must preserve least-privilege principle
- Defense-in-depth โ PostgreSQL RLS will be added as a future hardening layer (Track B Stage 3)
- 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)¶
- โ
Create PA service user (
pos-applet-service): - Odoo UI: Settings โ Users โ Create
- Groups:
Base Useronly (no Admin) -
Verify:
uid != 2,ir.rulenow enforced -
โ Add
ir.ruletopos.order(SA-scoped): - Settings โ Technical โ Record Rules โ Create
- Model:
pos.order - Domain: see Direction A above
-
Test: authenticate as service user, read
pos.order -
โ Update PA config to use service user credentials:
- Update
odoo_config.json(local only, not committed) - Verify connection:
uid = NEW_SERVICE_USER_ID
Short-Term (Week 2-3)¶
-
โ Add
ir.ruleto all transactional models (sale.order,account.move, etc.) -
โ Add
ir.ruletoov.sa_*andov.actor_sa_*models -
โ Integrate KeyCloak SSO (OIDC) for PA authentication
Medium-Term (Future)¶
-
โ PostgreSQL RLS for defense-in-depth
-
โ Audit log for all SA-scoped queries
-
โ 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) andir.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