Skip to content

Applet Visibility Control

ADR 0005 — SA Pool + Role Filter
Status: Implemented
Date: 2026-04-29


Context

The ovApp frontend renders a sidebar menu with multiple applets. Currently, menu visibility is controlled by a hardcoded static map in src/lib/auth.tsx keyed on roleName from the JWT token. This means:

  • All users with the same role everywhere see the same applets.
  • There is no way for SA Kenya to show [attendant, rider] while SA Togo shows [attendant, activator, orders].
  • Adding a new applet requires a code deployment.

The goal is to make applet visibility dynamic, SA-configured, and role-filtered — without removing the safety of the existing role layer.


Decision

Implement Option D — Hybrid (SA Pool + Role Filter):

  1. SA level → an SA admin configures which applets are available for their SA.
  2. Role level → the actor's membership role further restricts within that SA pool.
  3. Actor override (future v2) → individual membership records can add or remove applets beyond their role default.

Visibility Resolution Order

Global Applet Registry
        ↓
  SA-enabled pool          ← ov.sa_applet_config  (Odoo model)
        ↓
  Role default filter      ← ROLE_DEFAULT_APPLETS  (static map, server-owned)
        ↓
  Actor overrides          ← ov.membership.applet_overrides  (deferred v2)
        ↓
  + Universal applets      ← ALWAYS_ON  (keypad + any future always-on slugs)
        ↓
  Final list  →  embedded in login response  →  frontend renders

ALWAYS_ON slugs skip the SA pool and role filter entirely — they are merged into the final list unconditionally.


Applet Registry

Canonical slug → display name mapping. These slugs are the agreed identifiers used in both the backend config table and the frontend navigation.ts.

Slug Display Name Description
assets Assets BLE device browser and fleet view
mydevices My Devices Customer-owned device portal
activator Activator New customer activation workflow
attendant Attendant Battery swap attendant workflow
rider Rider Rider service plan portal
customer-management Customer Management Full CRM customer management
customers Customers Customer portfolio and payments
orders Orders Sales orders and fulfilment
products Products Product catalogue management
ticketing Ticketing Support ticket management
keypad Keypad Utility keypad screen
location Location Route and location tracking
ota OTA Firmware over-the-air updates

Backend Data Model

ov.sa_applet_config (new model)

Stores which applets an SA admin has enabled for their SA. This is an opt-in model — if no config record exists for an applet, it is not in that SA's pool.

Field Type Description
sa_id Many2one → ov.serviced_account The SA that owns this config
applet_slug Char One of the canonical slugs above
enabled Boolean (default True) Whether the applet is active
note Text Optional SA admin notes

Unique constraint: (sa_id, applet_slug)


Universal Applets (Always-On)

These applets are always injected into every user's applet list — for every SA, every role, whether or not an SA pool is configured. They bypass both the SA pool check and the role filter.

Slug Reason
keypad Universal utility — every user needs access regardless of SA setup

To add more universal applets, edit the ALWAYS_ON list in abs_connector/controllers/auth_controller.py → _compute_applets_for_sa.


Role Filter Map (server-side)

Defined in Python inside _compute_applets_for_sa. Maps each ov.membership role to the applets that role is permitted to see, but only if the applet is also present in the SA's configured pool. This is a filter, not a grant — an applet not in the SA pool never appears even if the role allows it.

ALWAYS_ON slugs are exempt from this filter — they are appended after the intersection, so they appear regardless of role.

Role Permitted Applets (intersected with SA pool)
admin Full SA pool — no role filter applied
staff activator, attendant, customers, customer-management, orders, products, ticketing, assets
agent attendant, rider, keypad, location

All non-universal applets must be explicitly assigned to the SA pool by an SAA first.


API Endpoint

Frontend Integration

Applets delivered at login — no extra round-trip

Applets are computed server-side and embedded directly inside the login response. The frontend never needs to make a separate GET /api/me/applets call on startup.

Login response shape

Full response returned by POST /api/employee/login:

{
  "success": true,
  "message": "Login successful!",
  "session": {
    "token": "<jwt>",
    "expires_at": "2026-04-30T07:31:12.230684",
    "employee": {
      "id": 87,
      "name": "Evans Musamia",
      "email": "evans_musamia@omnivoltaic.com",
      "company_id": 14,
      "user_type": "abs.employee"
    },
    "partner_id": 11571,
    "service_accounts": [
      {
        "id": 26,
        "name": "OV Global Root SA",
        "account_code": "OVGLOBAL-ROOT",
        "account_class": "OVAC",
        "state": "active",
        "is_root": true,
        "note": null,
        "member_count": 2,
        "child_count": 0,
        "company_id": false,
        "company_name": false,
        "parent": null,
        "partner": {
          "id": 11625,
          "name": "OV Global Root",
          "email": null,
          "phone": null
        },
        "created_at": "2026-04-24 14:12:43.276471",
        "updated_at": "2026-04-24 14:31:44.987279",
        "my_role": "admin",
        "my_scope_policy": false,
        "applets": [
          "activator",
          "assets",
          "attendant",
          "customer-management",
          "customers",
          "keypad",
          "location",
          "mydevices",
          "orders",
          "ota",
          "products",
          "rider",
          "ticketing"
        ]
      },
      {
        "id": 11,
        "name": "Test Company",
        "account_code": null,
        "account_class": "OVAC",
        "state": "active",
        "is_root": true,
        "note": "Root SA — auto-created from res.company \"Test Company\".",
        "member_count": 14,
        "child_count": 3,
        "company_id": 14,
        "company_name": "Test Company",
        "parent": null,
        "partner": {
          "id": 77,
          "name": "Test Company",
          "email": "beryl_huo@omnivoltaic.com",
          "phone": "0755-84563026"
        },
        "created_at": "2026-03-10 08:34:04.876578",
        "updated_at": "2026-04-13 17:42:17.485588",
        "my_role": "agent",
        "my_scope_policy": false,
        "applets": [
          "attendant",
          "rider",
          "keypad",
          "location"
        ]
      }
    ],
    "total": 2,
    "auto_selected": false
  }
}

Key points:

  • applets on each SA entry is the already-computed list — no extra round-trip needed.
  • SA 26 (Global Root, admin role) → returns the full SA pool (all 13 slugs seeded).
  • SA 11 (Test Company, agent role) → no SA pool configured yet, so role defaults kick in: [attendant, rider, keypad, location].
  • auto_selected: true is set when the employee belongs to exactly one SA — the frontend can auto-apply that SA's applets without asking the user to pick.

The applets field on each SA is the already-computed intersection of:

  • that SA's enabled pool (ov.sa_applet_config)
  • the employee's role defaults (ROLE_DEFAULT_APPLETS)

If an SA has no config rows yet, the role defaults are returned directly (graceful degradation).


After login succeeds, store each SA's applets keyed by SA id:

// on login success
session.service_accounts.forEach(sa => {
  localStorage.setItem(`ov_sa_applets_${sa.id}`, JSON.stringify(sa.applets ?? []));
});

When the user selects / switches SA (X-SA-ID = 4):

const applets = JSON.parse(localStorage.getItem('ov_sa_applets_4') ?? '[]');
// pass to useMenuVisibility / canViewMenu

On logout, clear all ov_sa_applets_* keys.


useMenuVisibility hook update (ovApp — deferred)

The existing hook in src/lib/auth.tsx uses a hardcoded menuPermissions map. When the frontend work is prioritised:

  1. Read ov_sa_applets_<active_sa_id> from localStorage.
  2. If present → canViewMenu(id) checks that list.
  3. If absent (cached session / first load before SA is selected) → fall back to existing static role map.

sidebar.tsx already calls canViewMenu(item.id) and needs no structural changes.


Scenario Walkthroughs

Scenario 1 — SA Kenya: field agents only

SA Kenya configures: [attendant, rider, keypad, location]

Actor Role Final Applets
Alice agent attendant, rider, keypad, location
Bob staff attendant, keypad, location (rider not in staff defaults)
Carol admin attendant, rider, keypad, location (admin = full SA pool)

Scenario 2 — SA Togo: full operations team

SA Togo configures: [activator, attendant, customer-management, orders, products, ticketing, assets]

Actor Role Final Applets
Denis agent attendant (only attendant overlaps agent defaults)
Eve staff activator, attendant, customer-management, orders, products, ticketing, assets
Frank admin activator, attendant, customer-management, orders, products, ticketing, assets

Scenario 3 — One actor in two SAs

Grace has an active membership in both SA Kenya (X-SA-ID: 4) and SA Togo (X-SA-ID: 6).

  • GET /api/me/applets with X-SA-ID: 4[attendant, rider, keypad, location]
  • GET /api/me/applets with X-SA-ID: 6[activator, attendant, customer-management, ...]

The frontend re-fetches when Grace switches her active SA.


Scenario 4 — No SA config yet (graceful degradation)

An SA has not yet had any applet records configured by an SAA. Only keypad is returned — regardless of role. All other applets must be explicitly assigned to the SA pool by an SAA before they appear.

{
  "id": 9,
  "name": "SA New",
  "my_role": "staff",
  "my_scope_policy": false,
  "applets": ["keypad"]
}

Design intent: no applet is granted by default. keypad is the universal applet every logged-in user always has — it is injected into every response automatically, even if the SA pool is empty or keypad was never explicitly added to the pool. Everything else is opt-in, configured by the SAA per SA.


Governance Boundary — Who Can Configure Applets

This is a platform-level decision, not an operational one.

Role Can do
SAA (SA Admin — role_code='admin' in Global Root SA) Create, update, delete applet config for any SA
SAM (SA Manager — staff in a specific SA) Read their SA's pool only. Cannot change it
Agent (agent in a specific SA) Cannot read or write pool config. Uses GET /api/me/applets

Rationale: A field SA manager onboards people and manages customers, but they cannot grant themselves access to applets they were not given. Only SAA, sitting in the Global Root SA, has the authority to shape what each SA's team can see.


API Endpoints

GET /api/me/applets — actor reads own computed list

GET /api/me/applets
Authorization: Bearer <jwt>
X-SA-ID: <sa_id>
{ "sa_id": 4, "sa_name": "SA Kenya", "role": "agent", "applets": ["attendant", "rider"] }


GET /api/sa/<sa_id>/applets — view SA pool (SAA or that SA's members)

GET /api/sa/4/applets
Authorization: Bearer <jwt>
{
  "sa_id": 4, "sa_name": "SA Kenya",
  "pool": [
    { "id": 1, "applet_slug": "attendant", "enabled": true, "note": "" },
    { "id": 2, "applet_slug": "rider",     "enabled": true, "note": "" }
  ]
}


POST /api/sa/<sa_id>/applets — assign applet to SA (SAA only)

POST /api/sa/4/applets
Authorization: Bearer <jwt>
Content-Type: application/json

{ "applet_slug": "ticketing", "enabled": true, "note": "added for support team" }
Scenario HTTP Status action in body
New slug added 201 Created "created"
Slug existed but was disabled 200 OK "re-enabled"
Slug already active 409 Conflict (error message)

409 response example:

{
  "success": false,
  "error": "Applet \"ticketing\" is already assigned to SA 4. Use PATCH /api/sa/4/applets/ticketing to update it.",
  "applet_slug": "ticketing",
  "id": 7
}

PATCH /api/sa/<sa_id>/applets/<slug> — enable/disable (SAA only)

PATCH /api/sa/4/applets/ticketing
Authorization: Bearer <jwt>
Content-Type: application/json

{ "enabled": false, "note": "disabled pending training" }

DELETE /api/sa/<sa_id>/applets/<slug> — remove from pool (SAA only)

DELETE /api/sa/4/applets/ticketing
Authorization: Bearer <jwt>

Security Notes

  • JWT + active membership required — a valid token alone is not sufficient.
  • SAA enforcement — write endpoints (POST, PATCH, DELETE) check that the caller has role_code='admin' in the Global Root SA. Any other role receives 403.
  • Visibility only — this feature controls what appears in the sidebar menu. It does not grant or revoke backend API permissions. Each controller continues to enforce JWT + SA membership independently.
  • Route-level access control in ovApp (PUBLIC_ROUTES) is unaffected.

Visibility vs. Security — Important Distinction

Applet visibility is a UI/menu concern only. It controls what the frontend renders in the sidebar. It is not a security boundary.

Layer Question answered Enforced by
Applet visibility "What menu items should this user see?" Frontend (useMenuVisibility) reading applets from login response
Controller auth "Is this user allowed to touch this data?" Every backend controller, independently

What applet visibility does

  • Tells the frontend which menu items to render for a given user in a given SA.
  • Delivered at login so the sidebar knows what to show immediately, with no extra round-trip.

What applet visibility does NOT do

  • It does not protect any backend API route.
  • It does not grant or revoke data access.
  • It does not prevent a determined client from calling an endpoint directly.

What actually protects the data

Every controller independently enforces its own access stack regardless of what the frontend shows:

  1. JWT validation — a valid, non-expired token is required on every request.
  2. SA membership check — the caller must be an active member of the SA in X-SA-ID.
  3. Role check — some endpoints require staff or admin role (403 otherwise).
  4. Scope policyassigned_only vs sa_wide filters which records are returned.

So if a frontend bypasses the menu and calls GET /api/orders directly, the controller still enforces that the user has a valid JWT, is a member of the requested SA, and has the right role. The applet config has no bearing on that outcome.

Applet visibility  →  "what can you see in the menu?"       (frontend UX only)
Controller auth    →  "are you allowed to touch this data?"  (backend enforcement)

The two layers are completely independent. Visibility is a convenience and UX decision; security is never delegated to it.


Suggested Default Pools on First Deployment

SA Suggested Pool
SA Togo activator, attendant, customer-management, orders, products, ticketing, assets
SA Kenya attendant, rider, keypad, location, ticketing
Test SA All applets

Implementation Plan

Phase 1 — Backend (Odoo) ✅ Complete

Step File Status Task
1.1 abs_connector/models/ov_sa_applet_config.py ✅ Done New model — SA applet pool config
1.2 abs_connector/models/__init__.py ✅ Done Import new model
1.3 abs_connector/controllers/governance_api.py ✅ Done _is_saa() helper + GET /api/me/applets + SA pool CRUD endpoints
1.4 abs_connector/controllers/auth_controller.py ✅ Done _compute_applets_for_sa() + applets embedded in login response
1.5 abs_connector/__manifest__.py ✅ Done Version bumped to 18.0.1.1.9
1.6 scripts/seed_sa_applet_config.py ✅ Done Standalone seed script for initial SA pools

Phase 2 — Frontend (ovApp) — Deferred

Step File Task
2.1 src/lib/auth.tsx After login, store ov_sa_applets_<id> per SA in localStorage
2.2 src/lib/auth.tsx Update useMenuVisibility to read from ov_sa_applets_<active_sa_id> with static fallback
2.3 src/components/sidebar/navigation.ts Add missing slugs: activator, orders, products, customer-management
2.4 Logout handler Clear all ov_sa_applets_* keys on logout

Phase 3 — SA Admin UI (future)

SAA-facing screen inside ovApp to configure SA applet pools without requiring a developer or Odoo backend access. Uses POST/PATCH/DELETE /api/sa/<id>/applets endpoints already built in Phase 1.


Open Questions

# Question Owner
1 Should SA admin configure applets from ovApp itself, or only from the Odoo backend? Product
2 Should ota, keypad, and location be protected to specific roles only? Product
3 On SA switch — re-fetch applets or clear cache and redirect? Frontend
4 Should actor-level overrides (v2) use applet_add / applet_remove fields? Architecture