Architecture Proposal · 2026-04-19

One login.
Every internal app.

Centralized authentication and authorization for every internal application — built on Microsoft Entra ID, Google Workspace, and an organization-owned entitlement layer.

1
Login for all apps
2
Identity providers
15
IT Policy controls
<5m
Off-boarding latency
Read in 3 minutes
Dive deeper ↓

Executive summary

Problem

One person, many accounts.

Four internal apps. Four logins. Four user tables. Off-boarding requires chasing four admin UIs. Role changes never propagate. No one can answer "who accessed what last week" in under an hour.

Solution

One auth layer. Every app.

A Hub in front of every app that handles login, role resolution, legal acceptance, and token issue. Apps enforce their own RBAC but inherit identity, approval, and audit from the Hub.

Outcomes
  • SSO to every internal app
  • Off-boarding latency < 5 min
  • Cross-app audit in one query
  • Roles update when org chart publishes
Decisions locked
① One user, one provider
② New apps · Entra-only default
③ Google capped at Internal tier
④ Shared T&C + AUP
Rollout · 6 weeks
P1
Build
P2
Safety Hub
P3
AssetX
P4
Biogas
P5
VAPT · GL
Existing apps keep current auth until each is migrated individually.
01 · Overview

The problem we're solving

Four internal apps today. Four separate user tables. Four separate off-boarding checklists. When a staff member leaves or changes role, IT has to chase down access in every app individually — and sometimes misses one. IT Policy §21 requires centralized control. We don't have it yet.

Current state

Each app owns its own identity

Separate user tables in AssetX, Safety Hub, Biogas Management, Biogas Operator
Off-boarding requires IT to touch every app individually — easy to miss one
No cross-app audit: "which apps did Ahmad access last month?" takes hours
T&C + Acceptable Use Policy acceptance tracked per-app or not at all
Role changes don't propagate — operator promoted to Regional HSE in Safety Hub still read-only in AssetX
Target state

Hub owns identity. Apps enforce roles.

One canonical user per email, regardless of which app signs them up
One off-boarding switch kills access across every app in <5 minutes
Single audit log for "who accessed what, when, from where"
Shared T&C + Acceptable Use Policy, version-controlled, accepted once per user
IT dashboard sees every user × every app × every role in one screen
Policy decisions · locked-in 2026-04-19

The four calls we've already made

1

One user = one identity provider

A user cannot link both Entra and Google to the same Hub account. Cleaner off-boarding, no provider-switching attack surface, no identity-merge edge cases.

DECISION: NO dual-linking
2

New apps default to Entra-only

Every new app registration ships "staff-only" (Entra-only). Granting Gmail access requires an explicit toggle + Head of ICT approval per app. Staff-by-default, permissive-by-exception.

DECISION: Entra-only default
3

Google users blocked from Confidential data

IT Policy §15 Confidential tier (PII, plant coordinates, PPA tariffs, audit logs) is Entra-only at the enforcement layer. Google-auth users may read Internal data but are blocked from Confidential actions even if their app role allows it.

DECISION: IdP-level data-tier gate
4

One shared legal agreement

Master T&C + Acceptable Use Policy lives in the Hub, versioned. Accepted once at first login, re-prompted on version bump. Every app checks users.legalAcceptedVersion >= current before issuing an entitlement.

DECISION: Shared T&C + AUP
Off-boarding · the reason this exists

One switch, every app locked

When a user leaves, IT hits one button in the Hub admin console. Access is revoked everywhere in under five minutes — no per-app chase, no missed tails.

flowchart LR
  A([HR signals departure]) --> B[Hub · /admin/users/:id/disable]
  B --> C[users.active = false]
  B --> D[Bump token_version]
  B --> E[Revoke all app_grants]
  D --> F{Next token refresh
on any app} F -- mismatch --> G([All sessions killed]) E --> H[JWKS rotation signal] H --> G A -. provider .-> P[Entra / Google
sign-out] P --> G style A fill:#fee2e2,stroke:#dc2626 style B fill:#1b61c9,stroke:#1b61c9,color:#fff style G fill:#006400,stroke:#006400,color:#fff
02 · Current state

Auth flow per app · today

Four apps, four different implementations, four different user tables. Every app was built in isolation — which worked at the time but is now blocking consolidation, audit, and off-boarding.

S

Safety Hub

safetyhub.my

Next.js 16
Auth libraryNextAuth v5
ProvidersEntra + Google
SessionJWT cookie · 5m cache
User storeMySQL · Prisma
RBAC8 locked roles · 35 permissions
Access requestsSelf-signup → HSE approval
Login
flowchart LR
  U([User]) --> N[NextAuth]
  N --> G[Google]
  N --> E[Entra]
  G --> DB[(Users DB)]
  E --> DB
  DB --> S[Session cookie]
  S --> A([Safety Hub])
Account approval
flowchart LR
  U([Sign in]) --> C{Email in DB?}
  C -- no --> R["/register form"]
  R --> Q[access_requests queue]
  Q --> H[HSE reviews]
  H --> OK[User row created]
  OK --> L([Can log in])
  C -- yes --> L
A

AssetX

assetx.my · api.assetx.my

Next.js 15 · Hono
Auth libraryCustom OIDC client
ProvidersEntra ID only
SessionHttpOnly cookie · 15m idle
User storeMySQL · Prisma (solardb)
RBACApp roles · per-plant scoping
API accessApiConsumer + API keys
Login
flowchart LR
  U([User]) --> O[OIDC client]
  O --> E[Entra only]
  E --> DB[(solardb users)]
  DB --> C[HttpOnly cookie
15m idle] C --> A([AssetX]) T([3rd party]) --> K[API key] K --> A
Account approval
flowchart LR
  M([Manager email
or ticket]) --> IT[IT Admin] IT --> N["/admin/users/new"] N --> R[Role + plants assigned] R --> E[User notified by email] E --> L([Entra sign-in OK])
B

Biogas Management

app.biogas.internal

Next.js 14 (frontend only)
Auth libraryConsumes Biogas Operator API
ProvidersUsername + password
SessionJWT (access + refresh)
User storeMySQL · dbbiogas
Lockoutdjango-axes · 5/1hr
Gates180d expiry · PDPA · Policy
Login
flowchart LR
  U([User]) --> L[Login form]
  L --> AX[django-axes
5 tries / 1h] AX --> P{Password
expired?} P -- yes --> RS[Force reset
180d rule] P -- no --> G[PDPA + Policy gate] G --> J[DRF JWT] J --> A([Biogas Mgmt])
Account approval
flowchart LR
  M([Manager request]) --> DA[Django admin
creates user] DA --> TP[Temp password
emailed to user] TP --> U([User logs in]) U --> CH[Forced password change] CH --> R([Ready])
O

Biogas Operator

operator.biogas.internal

Next.js + Django · fullstack
Auth libraryOwn Django + DRF JWT backend
ProvidersUsername + password
SessionLong-lived JWT refresh
User storedbbiogas · serves Biogas Mgmt too
ScopeRole = operator, plant-bound
Field useOffline-first reads
Login
flowchart LR
  U([Operator on phone]) --> L[Login form]
  L --> D[Own Django backend
plus serves Biogas Mgmt] D --> J[Long-lived JWT] J --> LS[(localStorage)] LS --> A([Operator PWA])
Account approval
flowchart LR
  PH([Plant Head request]) --> BA[Biogas Mgmt admin]
  BA --> U[Create user · role operator]
  U --> B[Assign plant binding]
  B --> TP[Temp password handed
or emailed] TP --> OP([Operator installs PWA])

Today · four islands

Each app runs its own login, its own user table, its own role store. Two share a backend; the other two stand entirely alone.

flowchart TB
  subgraph Users
    U1[Staff with Cenergi email]
    U2[Staff without Cenergi email]
    U3[Field operator]
  end
  U1 -. Entra .-> SH
  U2 -. Google .-> SH
  U1 -. Entra .-> AX
  U1 -. Password .-> BM
  U3 -. Password .-> BO
  SH[(Safety Hub
NextAuth · own users)] AX[(AssetX
OIDC · own users)] BM[(Biogas Mgmt
frontend only)] BO[(Biogas Operator
Django · owns users table)] BM -->|uses API| BO SH -. no SSO .- AX AX -. no SSO .- BM style SH fill:#fef3c7,stroke:#d97706 style AX fill:#dcfce7,stroke:#006400 style BM fill:#fee2e2,stroke:#dc2626 style BO fill:#fee2e2,stroke:#dc2626

Side-by-side · what's the same, what isn't

Aspect Safety Hub AssetX Biogas Mgmt Biogas Operator
StackNext.js 16Next.js 15 + HonoNext.js 14 FE · no backend of its ownNext.js FE + Django backend
Identity sourceGoogle + EntraEntra onlyPassword onlyPassword only
MFAVia EntraVia EntraNoneNone
Session idle 15mPartialYesRefresh windowLong-lived
Own user tableYesYesYesYes (shared)
API tokensNoneApiConsumerNoneNone
Legal gateNoNoPDPA + PolicyInherited
Off-boardingManualManualManualManual
AH
The real problem

Meet Ahmad — one person, three accounts today

Field operator at Plant Medini. Logs into Biogas Operator daily (mobile), Biogas Management weekly (to close work orders), and Safety Hub whenever he sees a hazard. Same human. Three separate identities.

Today — 3 identities, 3 off-boarding tasks
flowchart LR
  A([Ahmad Hafiz])
  A -->|password login| BO[(Biogas Operator
user row)] A -->|password login| BM[(Biogas Mgmt
via Biogas Operator backend)] A -->|Entra OAuth| SH[(Safety Hub
separate user row)] style A fill:#fee2e2,stroke:#dc2626 style BO fill:#fef3c7,stroke:#d97706 style BM fill:#fef3c7,stroke:#d97706 style SH fill:#fee2e2,stroke:#dc2626
  • Two different login flows: Entra for Safety Hub, password for Biogas stack
  • Promotion to Plant Head = role update in 3 places
  • When he leaves: 3 separate tickets, one often missed
  • "Did Ahmad log in last week?" = three DB queries
After Auth Hub — 1 identity, 1 off-boarding switch
flowchart LR
  A([Ahmad Hafiz]) --> H[(Auth Hub
one user record
email = PK)] H -->|grant: operator| BO([Biogas Operator]) H -->|grant: plant_viewer| BM([Biogas Mgmt]) H -->|grant: REGIONAL_HSE| SH([Safety Hub]) style A fill:#dcfce7,stroke:#006400 style H fill:#1b61c9,stroke:#1b61c9,color:#fff style BO fill:#dcfce7,stroke:#006400 style BM fill:#dcfce7,stroke:#006400 style SH fill:#dcfce7,stroke:#006400
  • One login via Entra MFA, three apps open to him
  • Promotion = one role change in Hub, propagates instantly
  • Departure = one switch, all three apps lock him out in <5 min
  • "Did Ahmad log in last week?" = single audit query
This is the real ROI. The Hub isn't just tidy architecture — it collapses duplicate humans across the fleet. Every user who touches more than one app today has this problem, and the fleet will only grow.

Per-app · before and after

Each app's current login path, next to how the same path looks once the Hub fronts it. Red = today's problem; green = Hub-backed replacement.

S
Safety Hub
safetyhub.my
Today
flowchart LR
  U([User]) --> SI[Sign in page]
  SI --> NA[NextAuth v5]
  NA --> G[Google OAuth]
  NA --> E[Entra OAuth]
  G --> DB[(Own users DB)]
  E --> DB
  DB --> J[NextAuth JWT cookie]
  J --> AP([Safety Hub])
  style U fill:#fee2e2,stroke:#dc2626
  style DB fill:#fee2e2,stroke:#dc2626

Own user table, own roles, own access-request flow. No legal gate, no cross-app audit.

With Auth Hub
flowchart LR
  U([User]) --> SI[Sign in]
  SI --> H[Auth Hub]
  H --> E[Entra]
  H --> G[Google
allowlisted] E --> H2[Resolve · grant · legal] G --> H2 H2 --> JWT[Hub JWT · 15m] JWT --> AP([Safety Hub]) style U fill:#dcfce7,stroke:#006400 style H fill:#1b61c9,stroke:#1b61c9,color:#fff style JWT fill:#dcfce7,stroke:#006400

Single Hub login. Existing Gmail users grandfathered at migration. Legal gate + cross-app audit gained for free.

A
AssetX
assetx.my · api.assetx.my
Today
flowchart LR
  U([User]) --> SI[Sign in]
  SI --> O[Custom OIDC client]
  O --> E[Entra only]
  E --> DB[(solardb users)]
  DB --> C[HttpOnly cookie · 15m idle]
  C --> AP([AssetX])
  TP([3rd party]) --> K[API key · ApiConsumer]
  K --> AP
  style U fill:#fef3c7,stroke:#d97706
  style DB fill:#fef3c7,stroke:#d97706

Already Entra-only + 15m idle. Has API consumer model. Just still owns its own user table.

With Auth Hub
flowchart LR
  U([User]) --> H[Auth Hub]
  H --> E[Entra]
  E --> J[Hub JWT · 15m]
  J --> AP([AssetX])
  TP([3rd party]) --> HK[Hub consumer token]
  HK --> AP
  style U fill:#dcfce7,stroke:#006400
  style H fill:#1b61c9,stroke:#1b61c9,color:#fff
  style HK fill:#1b61c9,stroke:#1b61c9,color:#fff
  style J fill:#dcfce7,stroke:#006400

User session + API keys both centralized. Off-boarding + token rotation inherited from Hub.

B
Biogas Management
app.biogas.internal
Today
flowchart LR
  U([User]) --> SI[Login form
user + password] SI --> BO[Biogas Operator backend
Django auth] BO --> AX[django-axes
5/1h lockout] AX --> EX{Password
expired?} EX -- yes --> RP[Force reset
180d rule] EX -- no --> PD[PDPA + Policy gate] PD --> JW[DRF JWT pair] JW --> AP([Biogas Mgmt]) style U fill:#fee2e2,stroke:#dc2626 style SI fill:#fee2e2,stroke:#dc2626 style RP fill:#fef3c7,stroke:#d97706

Passwords + JWT. No MFA, no SSO. Biggest compliance risk of the four.

With Auth Hub
flowchart LR
  U([User]) --> H[Auth Hub]
  H --> E[Entra · MFA]
  E --> LG[Legal gate
PDPA + T&C unified] LG --> J[Hub JWT] J --> DM[Django middleware
verifies via JWKS] DM --> AP([Biogas Mgmt]) style U fill:#dcfce7,stroke:#006400 style H fill:#1b61c9,stroke:#1b61c9,color:#fff style E fill:#dcfce7,stroke:#006400 style LG fill:#dcfce7,stroke:#006400

Passwords gone. MFA inherited from Entra. PDPA + Policy acceptance migrated into Hub's legal gate. Django gains a JWT verification middleware only.

O
Biogas Operator
operator.biogas.internal · mobile PWA
Today
flowchart LR
  U([Operator
on phone]) --> SI[Login form] SI --> DJ[Own Django backend
shared with Biogas Mgmt] DJ --> JW[Long-lived JWT
offline capture] JW --> LS[(localStorage)] LS --> AP([Operator PWA]) style U fill:#fee2e2,stroke:#dc2626 style JW fill:#fee2e2,stroke:#dc2626 style LS fill:#fef3c7,stroke:#d97706

Long-lived tokens on mobile for offline use. No MFA, password resets nearly impossible from field.

With Auth Hub
flowchart LR
  U([Operator]) --> H[Auth Hub
mobile OAuth] H --> E[Entra · device-bound] E --> J[Hub JWT · 8h operator scope] J --> RT[Refresh token
secure device store] RT --> AP([Operator PWA]) AP -. offline .-> SY[Queue + sync] style U fill:#dcfce7,stroke:#006400 style H fill:#1b61c9,stroke:#1b61c9,color:#fff style J fill:#dcfce7,stroke:#006400

Mobile OAuth with PKCE. Longer operator-tier JWT (8h) balances field reality vs security. Offline capture queue preserved; Hub re-auth happens on reconnect.

Account lifecycle · each app today

Beyond login: how accounts are created, approved, roles changed, and deactivated. Today every app has its own playbook, its own admin UI, and its own failure modes.

S

Safety Hub · self-serve request + HSE approval

flowchart LR
  A([User tries to sign in]) --> B{Email
in DB?} B -- no --> C["/register form"] C --> D[access_requests row] D --> E["HSE sees queue at
/hse/access-requests"] E --> F{Approve?} F -- no --> X([Rejected]) F -- yes --> G[users row inserted
role assigned] G --> H[Back to sign in] B -- yes --> H H --> I([Logged in]) I -. role change .-> J["/hse/user-management"] J -. tokenVersion bump .-> K[Forced re-login] I -. off-boarding .-> L[Admin flips active=false] L -. tokenVersion bump .-> M([Locked out]) style A fill:#fef3c7,stroke:#d97706 style D fill:#fef3c7,stroke:#d97706 style X fill:#fee2e2,stroke:#dc2626 style M fill:#fee2e2,stroke:#dc2626 style I fill:#dcfce7,stroke:#006400
Request
User self-files via /register after first sign-in attempt
Approval
HSE Team or Admin · no formal ticket, UI-only
Off-boarding
Manual deactivate; relies on IT remembering
A

AssetX · admin-provisioned, no self-serve

flowchart LR
  A([Manager requests access
via email or ticket]) A --> B[IT Admin at
/admin/users/new] B --> C[Fill email + role + plants] C --> D[User row created
plant assignments inserted] D --> E[User notified by email] E --> F([First Entra sign-in]) F --> G([Logged in]) G -. role change .-> H[Admin edits at
/admin/users/:id] H -. session rotates .-> I[Next request re-checks role] G -. off-boarding .-> J[Admin flips active=false] J --> K([Locked out on next refresh]) style A fill:#fef3c7,stroke:#d97706 style B fill:#fef3c7,stroke:#d97706 style G fill:#dcfce7,stroke:#006400 style K fill:#fee2e2,stroke:#dc2626
Request
Out-of-band (email, ticket, WhatsApp)
Approval
IT Admin decides at provisioning time · no explicit approver field
Off-boarding
Admin toggle · manual, depends on IT knowing about the departure
B

Biogas Management · Django admin, temp password

flowchart LR
  A([Manager requests access])
  A --> B[Biogas Operator Django admin
creates shared user] B --> C[Auto-generated temp password
emailed to user] C --> D[User logs in] D --> E[Forced password change] E --> F[PDPA consent] F --> G[Accept IT Policy] G --> H([Logged in]) H -. 180d passes .-> I[Password expired redirect] I --> E H -. role change .-> J[Admin edits in Django admin] H -. 5 bad tries .-> K[django-axes lock · 1h] H -. off-boarding .-> L[Admin flips is_active=false] L --> M([Locked out]) style A fill:#fee2e2,stroke:#dc2626 style C fill:#fee2e2,stroke:#dc2626 style H fill:#dcfce7,stroke:#006400 style K fill:#fef3c7,stroke:#d97706 style M fill:#fee2e2,stroke:#dc2626
Request
Out-of-band · Django admin UI-only
Approval
Implicit · whoever has admin access decides
Off-boarding
Manual is_active toggle; password still exists in DB
O

Biogas Operator · owns backend + plant scope · serves Biogas Mgmt

flowchart LR
  A([Plant Head requests operator])
  A --> B[Biogas Operator admin panel
creates user] B --> C[Assigns role=operator
+ plant binding] C --> D[Temp password emailed
or handed to operator] D --> E([Operator installs mobile PWA]) E --> F[Login on device] F --> G[Long-lived JWT
stored on phone] G --> H([Daily field use offline]) H -. role change .-> I[Admin edits · rare] H -. phone lost or stolen .-> J[Token still valid until
JWT expires or refresh fails] H -. off-boarding .-> K[Admin flips is_active=false] K -. next online sync .-> L([Locked out]) style A fill:#fee2e2,stroke:#dc2626 style D fill:#fee2e2,stroke:#dc2626 style G fill:#fee2e2,stroke:#dc2626 style J fill:#fee2e2,stroke:#dc2626 style H fill:#dcfce7,stroke:#006400 style L fill:#fee2e2,stroke:#dc2626
Request
Plant Head → Biogas Mgmt admin
Approval
Plant Head implicitly via plant binding
Off-boarding
Only takes effect when phone syncs — can be days after departure
After Hub

One lifecycle. All apps.

Same flow whether a user is on Safety Hub, AssetX, Biogas Management, or Biogas Operator. HR drives provisioning. IT approves elevation. One switch off-boards everywhere.

%%{init: {"themeVariables": {"fontSize":"18px"}, "flowchart": {"nodeSpacing": 55, "rankSpacing": 70, "padding": 18}}}%%
flowchart TB
  subgraph P1 ["1 · Onboard"]
    HR([HR ticket]) --> CU[Hub creates user
Cenergi email or Google] CU --> LG[Accept T&C + AUP] LG --> GR[Default grants
read-only per job] end subgraph P2 ["2 · Get access"] GR --> Q{Need more
access?} Q -- no --> RDY([Ready]) Q -- yes --> EL[Manager files
elevation] EL --> TR{Confidential
tier?} TR -- no --> AA[Auto-approve] TR -- yes --> IC[Head of ICT
approves] AA --> RDY IC --> RDY end subgraph P3 ["3 · Daily use"] RDY --> LOG([SSO login
to any app]) end subgraph P4 ["4 · Role change"] LOG -. role update .-> RC[Hub admin
edits role] RC -. tokenVersion bump .-> PR[All apps updated
within 60 s] end subgraph P5 ["5 · Off-boarding"] LOG -. departure .-> KS[One switch
disable user] KS -. bump version .-> OB([All apps locked
under 5 min]) end style HR fill:#dbe7fe,stroke:#1b61c9 style CU fill:#1b61c9,stroke:#1b61c9,color:#fff style LG fill:#1b61c9,stroke:#1b61c9,color:#fff style RDY fill:#dcfce7,stroke:#006400 style LOG fill:#dcfce7,stroke:#006400 style OB fill:#dcfce7,stroke:#006400 style KS fill:#fee2e2,stroke:#dc2626 style IC fill:#fef3c7,stroke:#d97706
Request
HR ticket or self-serve via Hub request form; auto-linked to AD employee record
Approval
Default read-only grants auto-approved · Confidential tier → Head of ICT · Secret → GCEO
Role change
One edit, tokenVersion bump, propagates to every app within 60 seconds
Off-boarding
One switch · all active sessions killed · Entra/Google sign-out triggered · audit recorded

Gaps the Hub closes

  • Biogas apps still use passwords — no SSO, no MFA, policy-violation if audited tomorrow
  • Three separate user tables (Biogas Operator serves two apps) means the same person has multiple sets of roles drifting apart
  • Off-boarding is manual across four apps — at least one gets missed every departure
  • No cross-app audit trail — "who accessed what" requires querying four databases
  • Only AssetX has API tokens for third parties; others hand out service-account passwords
  • Only Biogas enforces legal gates; Safety Hub + AssetX have no legal-gate surface at login
03 · Architecture

How the pieces fit

Three layers: identity providers (who you are), the Hub (what you can access), and the apps (what you can do inside them). Each layer owns a distinct concern and never reaches across.

Identity Provider
Microsoft Entra ID
Internal staff · MFA enforced · enterprise.onmicrosoft.com
Identity Provider
Google Workspace
Staff without Cenergi email · per-app allowlist · 2SV attestation
OIDC · ID token
Authorization Layer
Auth Hub API
auth.internal · Hono + MySQL · owns entitlements, not identity
users
identities
apps
app_grants
roles_catalog
legal_docs
audit_log
nda_records
GET /me/entitlements · Hub JWT
A
AssetX
solar ops
S
Safety Hub
HSE
B
Biogas Mgmt
plants
O
Biogas Operator
field ops

Login sequence · step-by-step

1
User clicks "Sign in" on any internal app
App redirects to auth.internal/login?app=safety-hub&redirect=...
2
Hub shows provider picker (respecting per-app allowlist)
If app is Entra-only → single button. If both allowed → two buttons. User chooses.
3
Provider authenticates (MFA if Entra, 2SV attestation if Google)
Hub receives ID token, validates signature + audience + expiry
4
Hub resolves user by email → checks grant for this app
No grant → 403 "Access not granted. Request access from IT." · Grant exists but provider mismatch → 403 "This app requires Entra."
5
Legal gate: T&C + AUP version check
If user's acceptedVersion < current → show acceptance screen. Re-show on bump.
6
Hub mints app-scoped JWT and redirects back
JWT contains: { sub, email, appId, role, permissions, dataClass, provider, exp: 15m, jti }
7
App validates JWT, creates session cookie, enforces RBAC locally
App never contacts Hub again during session (except refresh). JWT rotation every 15min, revocation via jti blocklist.

Same flow, drawn

Message sequence between user, app, Hub, and identity provider for a cold login.

sequenceDiagram
  autonumber
  participant U as User
  participant App as App
  participant Hub as Auth Hub
  participant IdP as Entra / Google
  U->>App: Open app, click Sign in
  App->>Hub: Redirect /login?app=safety-hub
  Hub->>U: Show provider picker
  U->>Hub: Choose provider
  Hub->>IdP: OIDC redirect + PKCE
  IdP->>U: MFA challenge
  U->>IdP: Complete MFA
  IdP-->>Hub: ID token (email, sub, provider)
  Note over Hub: Resolve user · check grant · legal gate
  Hub->>U: Accept T&C + AUP (if outdated)
  U->>Hub: Accept
  Hub-->>App: Redirect with Hub JWT (15m)
  App->>App: Verify JWT via JWKS · set session cookie
  App-->>U: Authenticated app page
        
04 · Identity rules

Multi-provider, single identity

✉️

Email is the canonical key

User is identified by verified email, not by provider sub. This makes migration between providers possible (rare, but needed when a staff member starts sharing a workspace with their Cenergi email after onboarding).

🔒

One active provider per user

If you sign up with Entra, you cannot later add Google. Provider migration requires IT action with audit trail and old identity deactivation.

🏷️

Provider determines data tier cap

Entra users: no cap (role decides). Google users: hard-capped at "Internal" tier. Confidential actions return 403 regardless of role.

Entitlement decision tree

Every login runs through these gates. First denial exits. Success issues a scoped JWT.

flowchart TD
  A([ID token received]) --> B{User exists?}
  B -- no --> X1[403 · not registered]
  B -- yes --> C{User active?}
  C -- no --> X2[403 · account disabled]
  C -- yes --> D{Provider matches
active_provider?} D -- no --> X3[403 · provider locked] D -- yes --> E{App allows
this provider?} E -- no --> X4[403 · app requires X] E -- yes --> F{Grant for app
active and unexpired?} F -- no --> X5[403 · no access] F -- yes --> G{Legal version
current?} G -- no --> H[Redirect: accept T&C + AUP] H --> I[User accepts] I --> J G -- yes --> J{Google user
requesting Confidential?} J -- yes --> X6[403 · data tier cap] J -- no --> K([Issue JWT · 15m · scoped]) style A fill:#1b61c9,stroke:#1b61c9,color:#fff style K fill:#006400,stroke:#006400,color:#fff style X1 fill:#fee2e2,stroke:#dc2626 style X2 fill:#fee2e2,stroke:#dc2626 style X3 fill:#fee2e2,stroke:#dc2626 style X4 fill:#fee2e2,stroke:#dc2626 style X5 fill:#fee2e2,stroke:#dc2626 style X6 fill:#fee2e2,stroke:#dc2626 style H fill:#fef3c7,stroke:#d97706
// Hub pseudocode for issuing an entitlement
async function issueEntitlement(idToken, appId) {
  const claim = verify(idToken);
  const user  = await db.users.find({ email: claim.email });
  if (!user || !user.active) throw new 403('User disabled');

  // Provider mismatch (1 user = 1 provider)
  if (user.activeProvider !== claim.provider)
    throw new 403(`Account locked to ${user.activeProvider}`);

  // Per-app IdP allowlist
  const app = await db.apps.find({ id: appId });
  if (!app.allowedProviders.includes(claim.provider))
    throw new 403(`${app.name} requires ${app.allowedProviders.join(' or ')}`);

  // Legal gate
  if (user.legalAcceptedVersion < app.requiredLegalVersion)
    return { redirect: '/legal/accept' };

  // Grant lookup
  const grant = await db.app_grants.find({ userId: user.id, appId, active: true });
  if (!grant) throw new 403('No access to this app');

  // Data tier cap for Google users (IT Policy §15)
  const dataClassCap = claim.provider === 'google' ? 'internal' : 'confidential';

  return signJWT({
    sub: user.id,
    email: user.email,
    appId,
    role: grant.role,
    permissions: grant.permissions,
    dataClassCap,
    provider: claim.provider,
    exp: now() + 15 * 60,
    jti: uuid(),
  });
}
05 · Data model

Core tables

MySQL 8, InnoDB tablespace encryption enabled (IT Policy §15.4). Audit tables have INSERT-only permission for the app user — UPDATE and DELETE are rejected at the DB layer.

users Confidential

Canonical user record, keyed by email.

id · uuid · PK
email · varchar · UNIQUE
display_name · varchar
active_provider · enum('entra','google')
provider · enum('entra','google') · already stored above
legal_accepted_version · int
legal_accepted_at · datetime
— (NDA field removed — all users are staff)
active · boolean · default true
token_version · int · bump to revoke all sessions
created_at, updated_at
identities Internal

Provider-specific identity. Single-active per user.

id · uuid · PK
user_id · FK users
provider · enum('entra','google')
provider_sub · varchar · provider's unique ID
active · boolean
first_seen_at, last_seen_at
UNIQUE(user_id) WHERE active = true
apps Internal

Registered internal applications.

id · varchar · PK (e.g. 'safety-hub')
name · varchar
callback_url · varchar
allowed_providers · json · ['entra'] or ['entra','google']
required_legal_version · int
data_classification · enum('internal','confidential','secret')
signing_key · bytea · encrypted
owner_user_id · FK users
active · boolean
app_grants Confidential

Per-user-per-app access grant.

id · uuid · PK
user_id · FK users
app_id · FK apps
role · varchar · app-specific role code
permissions · json · override flags
active · boolean
granted_by · FK users
granted_at · datetime
expires_at · datetime · nullable (required on Google-provider grants)
revoked_at, revoked_by, revoke_reason
UNIQUE(user_id, app_id)
legal_docs Public

Versioned T&C + Acceptable Use Policy text.

id · uuid · PK
type · enum('tnc','nda','privacy')
version · int
content_md · text
effective_from · datetime
published_by · FK users
UNIQUE(type, version)
audit_log INSERT-ONLY · Secret

Immutable record of every auth event.

id · bigint · PK
event_type · enum · login, grant, revoke, legal_accept, …
user_id · FK users · nullable
actor_id · FK users · who performed the action
app_id · FK apps · nullable
provider · enum · nullable
ip · varchar
user_agent · text
result · enum('success','denied','error')
details · json
ts · datetime · default now()
06 · API surface

Endpoints

Thirteen endpoints split into three groups: public auth flow, app-to-hub service calls, and admin console. Everything under /api/v1, OpenAPI spec auth-gated per IT Policy §21.2.

Auth flow (public)

GET /login?app=&redirect= Render provider picker for given app
GET /auth/:provider/redirect Redirect to Entra or Google with state+PKCE
GET /auth/:provider/callback Exchange code, issue Hub JWT, redirect to app
POST /legal/accept Record T&C + AUP acceptance, bump user version
POST /logout Bump token_version, propagate to all apps

App-to-Hub (service tokens)

GET /me/entitlements?app= Return current user's role + permissions for app
POST /tokens/refresh Rotate 15m JWT (checks token_version + grant active)
POST /tokens/validate App-to-hub JWT validity check (optional, stateless alt via JWKS)
GET /.well-known/jwks.json Public keys for apps to verify Hub JWTs offline

Admin console (IT + Head of ICT)

GET /admin/users List users with filters (provider, active, app-access)
POST /admin/users/:id/grants Grant or revoke app access (triggers approval flow for elevated or Confidential-tier grants)
POST /admin/users/:id/disable Off-boarding kill switch — revokes all grants + bumps token_version
GET /admin/audit Audit log query with date range + event-type filter
07 · UI mockups

What people actually see

Three surfaces: the login/provider picker (end-user), the legal gate (first login), and the admin console (IT). All built with the same design language as Safety Hub and AssetX for brand continuity.

① Provider picker

A

Sign in to Safety Hub

via Auth Hub

By continuing you agree to the organization's Terms and Privacy Policy.

② Legal acceptance (first login or on version bump)

Review and accept

Master T&C v3 · AUP v2 · effective 2026-04-19

Required

1. Acceptable Use

You agree to use internal applications only for authorized business purposes and to comply with the IT Policy (Release 2.0).

2. Confidentiality

You acknowledge that plant data, alarms, reports, and personnel records are classified as Confidential and may not be shared outside the organization without written approval.

3. Session Security

You agree to maintain MFA on your authentication provider and report any suspected compromise within 24 hours per §19.2.

③ Admin console · user detail

Auth Hub · Admin
darus.ishak@example.com · Head of ICT
AH
Ahmad Hafiz
ahmad.hafiz@example.com · Entra Active
Apps granted
4
Provider
Microsoft Entra
Last login
14 min ago
Legal version
v3 · current
App grants
App Role Granted by Status
S
Safety Hub
REGIONAL_HSE darus.ishak@… Active Revoke
A
AssetX
om_manager darus.ishak@… Active Revoke
B
Biogas Mgmt
plant_viewer darus.ishak@… Expires 30d Revoke
O
Biogas Operator
operator admin@… Active Revoke

④ Admin console · app detail (users + API tokens in one view)

Click any app from the Apps list to land on its detail page. Everything that app touches — provider allowlist, users granted, roles in use, API tokens issued, recent audit — lives here on one screen.

Auth Hub · Admin · Apps · Safety Hub
darus.ishak@example.com · Head of ICT
S
Safety Hub
safetyhub.my · Active Confidential
Overview
Users (47)
Roles (8)
API Tokens (5)
Audit
Providers allowed
Entra Google (grandfather)
Users granted
47
Legal version required
v3 · current
Active tokens
5 · 1 expiring
API Tokens for Safety Hub
Name Kind Abilities Rate limit Last used Expires
Safety Hub service
chub_svc_4f2a···a91c
service hub:* 10k/min 12 s ago · 47k today 82 days
PowerBI · HSE dashboards
chub_api_7c19···b2e4
consumer reports:read · plants:read Tier 2 · 600/min 5 min ago · 320/min 287 days
External audit platform
chub_api_a81e···c402
consumer reports:write Tier 1 · 60/min 6 h ago · 14/min 4 days
HR sync · roles import
awaiting issue
consumer users:read ⚑ Tier 2 (requested) Awaiting ICT
Recent audit · Safety Hub
View all →
  • grant.created ahmad.hafiz@example.com · role REGIONAL_HSE · by darus.ishak 3 min ago
  • token.requested HR sync · users:read · awaiting Head of ICT 28 min ago
  • login.denied sara.ramli@gmail.com · reason: provider-locked-to-entra 1 h ago
  • token.rotated Safety Hub service · auto-rotation 90d 2 d ago
08 · API management

Token-based API access

Every app and every external integration gets its own token with scoped permissions. Tokens are managed from the Hub — rotate, revoke, and audit in one place. Fully aligned with IT Policy §21.2 external-system access controls.

S

Service tokens

App ↔ Hub · machine-to-machine

Issued per registered app. Used by apps to call Hub endpoints (validate JWT, fetch entitlements, log events). One token per app, rotated every 90 days automatically.

Formatchub_svc_<32b>
Lifetime90 days · auto-rotate
Scopeapp-restricted
Storageapp env · never logged
C

Consumer tokens

External system → App API · integrations

Issued per integration — e.g., PowerBI pulling Safety Hub reports, ERP posting into Biogas Management. Read-only default, time-limited, DSA-gated.

Formatchub_api_<32b>
Lifetimemax 365 days · expires_at NOT NULL
Scopeapp + ability set
Rate limitper-consumer quota

Permission model · scoped abilities

Every token declares abilities using a resource:action format. Permissions are strictly additive — a token cannot exceed what its owner was granted, and Confidential-tier resources require Head of ICT sign-off at issue time (IT Policy §21.2.3.1.3).

Ability Example use Data tier Default
reports:readPowerBI pulls hazard report summariesInternal✓ enabled
reports:writeExternal audit platform posts inspection resultsInternalopt-in
reports:exportBulk CSV export incl. reporter PIIConfidentialICT approval
plants:readAsset registry sync, fleet dashboardInternal✓ enabled
plants:coordsPlant latitude/longitude (security-sensitive)ConfidentialICT approval
users:readHR integration read employee listConfidentialICT approval
users:writeHR system provisions new joinersConfidentialICT approval
metrics:readSCADA pulls plant KPIsInternal✓ enabled
audit:readSIEM ingests Hub audit streamSecretGCEO approval
*:adminPlatform-level admin (reserved — no consumer issues)SecretNever

Token lifecycle

Request

Consumer lodges a request in the Hub admin console with purpose, abilities needed, and expected volume.

DSA gate

If external system, data-sharing agreement captured. Internal integrations skip.

Approve

Default abilities auto-approved. Confidential tier routes to Head of ICT. Secret tier routes to GCEO.

Issue

Token shown once, Argon2id hash stored. Downloadable as .env snippet.

Rotate / revoke

90d service rotation, 365d consumer max. Revoke is instant, propagated to app caches within 60s.

flowchart LR
  R([Request filed]) --> N{External?}
  N -- yes --> ND[DSA signature]
  N -- no --> T{Ability tier?}
  ND --> T
  T -- Internal --> AA[Auto-approve]
  T -- Confidential --> IC[Head of ICT]
  T -- Secret --> GC[GCEO]
  AA --> IS[Issue token · Argon2id hash]
  IC --> IS
  GC --> IS
  IS --> US([In use · metered])
  US -->|90d service| RO[Auto-rotate]
  US -->|365d max| EX[Expire]
  US -->|abuse detected| SU[Auto-suspend]
  US -->|IT action| RV[Revoke]
  RO --> US
  EX --> AR([Archive + audit])
  SU --> AR
  RV --> AR
  style R fill:#dbe7fe,stroke:#1b61c9
  style IS fill:#1b61c9,stroke:#1b61c9,color:#fff
  style US fill:#dcfce7,stroke:#006400
  style AR fill:#f8fafc,stroke:#8a9099
  style SU fill:#fee2e2,stroke:#dc2626
  style RV fill:#fee2e2,stroke:#dc2626
      

Rate limiting

Service tokens10,000 req/min
Consumer · tier 1 (default)60 req/min
Consumer · tier 2 (approved)600 req/min
Consumer · tier 3 (bulk)6,000 req/min
Burst window10 s

429 response includes Retry-After. Sustained abuse auto-suspends the token after 3 strikes.

Request header contract

GET /api/v1/reports HTTP/1.1
Host: api.safetyhub.my
Authorization: Bearer chub_api_4f...a9
X-Request-Id: 3f8e2a-...
User-Agent: powerbi-connector/1.2

< HTTP/1.1 200 OK
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 587
X-RateLimit-Reset: 1713523200
X-Token-Expires-In: 2419200

Schema · api_tokens

api_tokens Confidential · token_hash never returned
id · uuid · PK
name · varchar · human label
kind · enum('service','consumer')
app_id · FK apps
owner_user_id · FK users
token_prefix · varchar(8) · display-safe
token_hash · varchar · Argon2id
abilities · json · ['reports:read',…]
rate_limit_tier · int · 1/2/3
expires_at · datetime · required for consumer
last_used_at · datetime · nullable
last_used_ip · varchar
last_used_ua · text
use_count · bigint
nda_ref · FK nda_records · nullable
approved_by · FK users · for elevated abilities
revoked_at, revoked_by, revoke_reason
created_at, created_by

Admin console · tokens tab

Auth Hub · Admin · API Tokens
darus.ishak@example.com · Head of ICT

API Tokens

28 active · 3 pending approval · 12 revoked · 2 expiring this week

All (28) Service (4) Consumer (24) Pending (3) Expiring <7d (2)
Name Kind · App Abilities Last used Expires Status
Safety Hub service
chub_svc_4f2a···a91c
service · Safety Hub hub:* 12 s ago · 47k today 82 days Active
PowerBI · HSE dashboards
chub_api_7c19···b2e4
consumer · Safety Hub reports:read · plants:read 5 min ago · 320/min 287 days Active
SCADA · Biogas Medini
chub_api_9d44···f05a
consumer · Biogas Mgmt metrics:read · plants:read 2 s ago · 12/min 5 days Expiring
HR Sync · Workday
awaiting issue
consumer · Hub core users:read · users:write ⚑ Awaiting ICT
ERP · AssetX parts sync
chub_api_1e83···8c77
consumer · AssetX parts:read · workorders:read 14 h ago · 4/min 94 days Active
09 · Compliance

IT Policy mapping

Every Hub feature traces back to an IT Policy clause. This matrix is what gets handed to audit.

Policy § Requirement Hub implementation
§20.6.1MFA for privileged & remote accessInherited from Entra tenant; Google path gates via 2SV attestation
§20.3.1.415-minute session idle timeoutCentral policy enforced via 15m Hub JWT + app-side idle watchdog
§21.1.1Third-party NDA before accessApplies to API consumers (external systems). api_tokens.dsa_signed_at gates consumer token issue. Not applicable to human users — all staff.
§21.2.3.1.1Time-limited third-party accessapi_tokens.expires_at required on consumer tokens; max 365d
§21.2.3.1.2Read-only default for third-partyDefault abilities for consumer tokens = read-only; elevation routes to Head of ICT
§21.2.3.1.3Head of ICT approval for elevated accessGrant elevation workflow with named approver + audit
§15.1, §15.2.1Data classification (4-tier)apps.data_classification + Google-provider cap at Internal
§15.4Secret data encrypted at restLUKS full-disk on host + MySQL InnoDB TDE
§14.2Audit log separationaudit_log table: INSERT-only grant for app user, no UPDATE/DELETE
§16.1.2.8Backup retention ≥ 2 yearsR2 offsite lifecycle rule 730 days
§18.3.5, §18.4.5Annual VAPT + pre-production pen testScheduled before go-live + yearly recertification
§6.1.1, §8.4.3.13Virus scanning on file uploadsN/A — Hub accepts no file uploads (avatar links only)
§19.2Formal incident responseRunbook + severity tier mapping; alerts on auth-fail spikes
§20.4Password complexity + 180d expiryN/A — no passwords in Hub, OAuth only
§8.1.1.2Firewall log review monthlyDocumented in Hub runbook; Cloudflare logs archived to R2
10 · Rollout

Phased migration

Six weeks, four apps, zero downtime. Existing apps keep their current auth until each is migrated individually.

WEEK 1-2

Phase 1 · Hub foundation

  • Provision droplet, MySQL, LUKS + InnoDB TDE, R2 for backups
  • Deploy Hono API with 13 endpoints, JWKS signing keys
  • Register 4 apps (inactive), seed legal_docs v1 + roles_catalog
  • Deploy admin console behind Entra-only Hub login
WEEK 3

Phase 2 · Safety Hub migration (pilot)

  • Safety Hub users + roles exported → bulk-insert into Hub
  • Grandfather all existing Gmail users (explicit google allowlist)
  • Safety Hub NextAuth config swap: single "Continue via Auth Hub" button
  • Dark-launch with 10% traffic; full cutover after 1 week observation
WEEK 4

Phase 3 · AssetX migration

  • AssetX is already Entra-only — straight cutover, no Gmail grandfathering
  • Existing API key + ApiConsumer model retained; Hub only replaces user session flow
  • Session idle timeout centralized (was per-app, now Hub-enforced)
WEEK 5

Phase 4 · Biogas Management + Operator

  • Django backend adds Hub-JWT verification middleware
  • Next.js frontends swap NextAuth → Hub OAuth client
  • Legacy user tables become read-only reference
WEEK 6

Phase 5 · VAPT + go-live

  • External pen test (IT Policy §18.4.5) with remediation window
  • Head of ICT sign-off against compliance matrix
  • Incident response runbook + alert thresholds activated
  • Legacy app-local auth endpoints decommissioned 30 days post-cutover
11 · Tech stack

Boringly reliable

Backend

  • RuntimeBun + Hono
  • DBMySQL 8 + Prisma
  • EncryptionLUKS + InnoDB TDE
  • JWTES256 + JWKS
  • OIDCopenid-client

Frontend

  • FrameworkNext.js 16
  • StylingTailwind v4
  • Admin UIshadcn/ui
  • StateTanStack Query

Infra

  • HostDO the organization
  • ProxyCaddy + Cloudflare
  • DeployDocker Compose
  • BackupsR2 · 730d
  • MonitoringGrafana + Loki
Dynamic roles

Roles that follow the org chart

Every app already has its own role vocabulary, and that's fine — each app knows its permissions best. What centralizes is the mapping from org position to app role. Publish a new org chart, grants update everywhere automatically.

LAYER 1

Positions (from HR)

The org chart. Job title · department · plant · reports-to. Synced nightly from HR system. Authoritative — Hub never edits, only reads.

LAYER 2

Role templates

One row per (position × app). Defines which app-specific role that position gets. Admin-managed, rarely changes. Inherits from parent position when unset.

LAYER 3

Effective grants · computed

Never stored as rows. Computed on each token issue: user → position → templates → app roles. Cache 5 min. When position changes, effective grants change in the next cycle.

Resolution flow

%%{init: {"themeVariables": {"fontSize":"16px"}, "flowchart": {"nodeSpacing": 50, "rankSpacing": 65}}}%%
flowchart TB
  HR([HR system
nightly sync]) --> POS[(positions
title + dept + plant + reports_to)] POS --> USR[(users.position_id)] ADM([Hub admin]) --> TPL[(role_templates
position × app → app-role)] USR --> COMP{Compute grants
at JWT issue} TPL --> COMP COMP --> JWT([Hub JWT with
scoped role per app]) OVR[(admin overrides
per user · audited)] -. merge on top .-> COMP style HR fill:#dbe7fe,stroke:#1b61c9 style ADM fill:#dbe7fe,stroke:#1b61c9 style COMP fill:#1b61c9,stroke:#1b61c9,color:#fff style JWT fill:#dcfce7,stroke:#006400 style OVR fill:#fef3c7,stroke:#d97706

Concrete example · Ahmad gets promoted

Today, Ahmad is Operator at Plant Medini. HR publishes a new org chart — he's now Plant Head. Watch what happens without anyone touching grants.

Monday · before publish
ahmad.hafiz — position
operator_medini
Templates for this position:
Safety HubFIELD_USER
Biogas Operatoroperator
Biogas Mgmtplant_viewer
AssetXno grant
Tuesday · after HR publish
ahmad.hafiz — position
plant_head_medini
Templates for new position:
Safety HubPLANT_HEAD
Biogas Operatorsupervisor
Biogas Mgmtplant_head
AssetXom_viewer
Next JWT refresh: new role live across 4 apps within 5 minutes. Zero admin clicks.

Schema additions

positions HR-sourced
id · varchar · PK (hr_position_code)
title · varchar
department · varchar
plant_id · varchar · nullable
parent_position_id · FK positions
active · boolean
sync_source · enum('hr','manual')
last_synced_at · datetime
role_templates Admin-managed
id · uuid · PK
position_id · FK positions
app_id · FK apps
app_role · varchar · app-specific
conditions · json · plant-scope etc
inherit_from · FK role_templates
updated_by, updated_at
UNIQUE(position_id, app_id)
app_grant_overrides Exceptional
id · uuid · PK
user_id · FK users
app_id · FK apps
app_role · varchar
operation · enum('add','remove','replace')
reason · text · required
approved_by · FK users
expires_at · datetime · nullable
audit_ref · FK audit_log

What happens when HR publishes an update

%%{init: {"themeVariables": {"fontSize":"16px"}, "flowchart": {"nodeSpacing": 55, "rankSpacing": 60}}}%%
flowchart TB
  A([HR publishes new org chart]) --> B[Hub · positions sync]
  B --> C{Diff vs current}
  C -- new position --> D[Inherit template from
parent position] C -- moved user --> E[Update user.position_id] C -- deactivated --> F[Flag position · users need reassignment] D --> G([Templates ready]) E --> H[Bump token_version
for affected users] F --> I[Notify admins] H --> J([Next token refresh
uses new position]) style A fill:#dbe7fe,stroke:#1b61c9 style B fill:#1b61c9,stroke:#1b61c9,color:#fff style G fill:#dcfce7,stroke:#006400 style J fill:#dcfce7,stroke:#006400 style F fill:#fef3c7,stroke:#d97706 style I fill:#fef3c7,stroke:#d97706

Why not one unified role list?

Tempting to collapse all 4 apps' roles into one global table. We don't recommend it because:

  • Safety Hub's REGIONAL_HSE and AssetX's om_manager are not the same thing — they gate different features. Flattening loses semantic precision.
  • App teams need to evolve their roles without waiting for Hub changes. Keeping role definitions local preserves release velocity.
  • Audit trail is cleaner — when Safety Hub's RBAC changes, only Safety Hub admins are involved.
  • Templates give you the best of both: centralized mapping, decentralized semantics.
Glossary

Plain-English for every acronym

Every time this document uses an acronym or tech term, here's what it actually means. Grouped by theme. Safe to skim.

Authentication & identity

Auth Hub
The central service proposed in this document. Handles login and decides which apps each user can access.
IdP · Identity Provider
The system that actually verifies who you are. We use two: Microsoft Entra ID and Google Workspace.
Microsoft Entra ID
Microsoft's identity service (used to be called Azure AD). Most Cenergi staff log in through this.
Google Workspace
Google's identity service. Used here as the backup login path for staff who don't have a Cenergi email address.
SSO · Single Sign-On
Log in once, get into every app — no separate login for each.
OAuth / OIDC
The industry-standard protocols for letting an app know "this user is logged in" without handing it the user's password. OpenID Connect (OIDC) is the modern flavor.
PKCE
Pronounced "pixy". A small one-time secret added to the OAuth exchange so it can't be hijacked in the middle.
MFA · Multi-Factor Authentication
Logging in with something you know (password) plus something you have (phone app or code).
2SV · 2-Step Verification
Same idea as MFA. Google's branding for it.
JWT · JSON Web Token
Pronounced "jot". A compact signed ticket the Hub issues after login, carrying who the user is and what they can do. Expires after 15 minutes.
JWKS · JSON Web Key Set
The Hub's public keys, published openly. Apps use them to check that a JWT really came from the Hub — without having to call back to the Hub every time.
RBAC · Role-Based Access Control
Permissions attached to roles (e.g. REGIONAL_HSE), not to individual users. Change the role, everyone in that role updates.

Security

SIEM · Security Information and Event Management
A platform that collects logs from every system in one place and alerts on suspicious patterns. Common ones: Splunk, Microsoft Sentinel, Elastic SIEM. Big orgs use these as their security nerve center.
VAPT · Vulnerability Assessment and Penetration Testing
A scheduled exercise where security professionals actively try to break into the system to find weaknesses. IT Policy requires this annually.
Audit log
An untampered record of who did what, when, and from where. Our design makes it INSERT-only at the database level, so even an attacker with admin access can't edit history.
LUKS · Linux Unified Key Setup
Full-disk encryption on Linux servers. If a drive is stolen or photographed, the contents are unreadable.
TDE · Transparent Data Encryption
Encrypts the database files at rest. Even someone with file-level access sees only encrypted bytes without the key.
Argon2id
A modern password-hashing algorithm. We use it to store API token hashes — the original token is never saved, only a one-way hash.
Token rotation
Automatically swapping a long-lived credential for a fresh one on a schedule. Shrinks the damage window if a token leaks.

Compliance & legal

IT Policy · ICT-001
The organization's internal IT policy document. Every implementation choice traces back to a clause here.
T&C · Terms and Conditions
The legal agreement every user accepts when they first log in. Versioned — if it changes, users re-accept on next login.
AUP · Acceptable Use Policy
The rules on how staff may (and may not) use internal systems. Accepted alongside T&C.
DSA · Data Sharing Agreement
Contract required before an external system can access our data via API. Replaces the old NDA concept for machine-to-machine flows.
PDPA · Personal Data Protection Act
Malaysia's data protection law. Requires consent collection before processing personal data.
Data classification
Four tiers — Public, Internal, Confidential, Secret — defined by IT Policy §15. Determines encryption, access controls, and retention.
Head of ICT / GCEO
Approval authorities: Head of ICT signs off on Confidential-tier access; GCEO signs off on Secret-tier.

Integrations & infrastructure

API · Application Programming Interface
The machine-readable "front door" of an app. Other systems call it to read or write data.
API token
A long-lived credential one system uses to call another. Unlike a user login, there's no browser, no MFA — just a secret string in an HTTP header.
PowerBI
Microsoft's dashboard and analytics tool. A common consumer of our data — pulls reports nightly via API.
SCADA
Supervisory Control and Data Acquisition. The industrial systems running biogas plants; they feed live metrics into our apps.
ERP · Enterprise Resource Planning
Finance, inventory, and procurement systems. Integrates via API for part lookups, work-order cost sync, etc.
HR sync
Automated feed of new hires and leavers from HR software. In the Hub design, this auto-creates/disables Hub users.
PWA · Progressive Web App
A web app that installs on a phone like a native app, works offline, and can send push notifications. Biogas Operator and AssetX mobile use this.
Cloudflare R2
Cloud object storage — where we keep backups and file uploads. Cheap and no egress fees.
Cloudflare Pages
Static website hosting. This design document lives on Pages at 1122.rujilabs.com.
Caddy
A web server that sits in front of apps, handling HTTPS certificates automatically.
Hono
Lightweight backend framework. We'd use it for the Hub API because it's fast, small, and runs anywhere.
Django / DRF
Python web framework. Biogas Management and Operator are built on it. DRF (Django REST Framework) adds JSON API features.
NextAuth
The auth library Safety Hub currently uses. Handles login, sessions, and OAuth for Next.js apps.

Organisational roles used in this doc

HSE · Health, Safety, Environment
The department that owns Safety Hub; reviews and closes hazard reports.
Regional HSE
A safety officer responsible for multiple plants in a region. Can triage and close reports.
Plant Head
Lead at a single plant. Acknowledges incoming hazards, requests access for their operators.
Field operator
On-site staff doing daily plant operations. Primary user of Biogas Operator mobile app.
Head of ICT
Head of the IT department. Approves elevated access and signs off on compliance exceptions.
GCEO · Group CEO
The most senior approver. Signs off on Secret-tier access — rare, reserved for cases like SIEM ingesting audit logs.