Skip to main content

Signal — Access Model

Written by Schae Lilley

This page documents the complete access-control model of Signal, Power Digital's internal business-intelligence web app: the five-tier role ladder (Member → Team Lead → Admin → Gandalf → Sauron), the orthogonal Growth grant system, every frontend route guard, how each flag is granted, and a route-by-route table of effective audiences. Everything here is verified directly against the code at staging @ 98df0c4 (verified 2026-07-07), primarily src/App.tsx (routes, guards, sidebar) and src/context/AuthContext.tsx (flag resolution), with server-side enforcement in server/index.ts.

Sign-in and provisioning

Signal sign-in is Google OAuth via Signal's auth service (Supabase). Only three email domains may sign in — a hardcoded allowlist (ALLOWED_DOMAINS in src/context/AuthContext.tsx): powerdigitalmarketinginc.com, external.powerdigitalmarketinginc.com, cardinaldigitalmarketing.com. Any other domain is signed out with "Access restricted to authorized Power Digital and Cardinal email domains only." Deep links survive login (the path is stored in sessionStorage and restored after the OAuth redirect). Local development can bypass auth entirely with VITE_DEV_BYPASS_AUTH=true, which sets every flag true.

On first sign-in the frontend calls POST /api/auth/ensure-provisioned, which creates the user's row in Signal's app_users role table with all flags false — except that anyone who is an Executive Sponsor (Account Director) or Group Director on any active client in Nova is auto-upgraded to Team Lead (is_team_lead = true). Provisioning never downgrades an existing Team Lead. All role flags are cached in the browser's sessionStorage (keys like signal_admin_v1_<email>) so a page refresh does not flash access away; the cache is reconciled against the database in the background, and an ambiguous/failed database read never revokes cached access mid-session.

The five tiers and how each flag is granted

Signal's role ladder is resolved in src/context/AuthContext.tsx from Signal's own app_users Supabase table plus two hardcoded code lists:

Tier

Flag

How granted

Member (base)

isMember (derived)

Automatic: any signed-in user with none of the flags below and no Growth capability. Formally isMember = !isTeamLead && !isAdmin && !isGandalf && !isSauron && !isGrowthAllowed && !canViewRadar && !canManageGrowthAccess.

Team Lead

isTeamLead

app_users.is_team_lead column. Auto-set at provisioning for Nova ADs/GDs; also implied by Admin or Gandalf (effectiveTeamLead = teamLead OR admin OR gandalf when roles are applied from the DB). Managed on the General Access screen (/users).

Admin

isAdmin

app_users.is_admin column, managed on /users by Gandalf-tier users.

Gandalf

isGandalf

app_users.is_gandalf column, managed on /users — PLUS a hardcoded allowlist in code (GANDALF_ALLOWLIST in AuthContext.tsx; as of Jul 2026 exactly one entry: makenna@powerdigitalmarketinginc.com) that always grants Gandalf regardless of database state.

Sauron

isSauron

Hardcoded email list only — SUPER_ADMIN_EMAILS in AuthContext.tsx, mirrored in server/index.ts. As of Jul 2026: john@, jaime.diaz@, laura.bejarano@, lily.loyer@ (powerdigitalmarketinginc.com). Sauron forces isTeamLead, isAdmin, and isGandalf all true regardless of the database, and always receives Growth-manage capability.

Growth flags are separate and NOT part of the ladder (next section). A further independent list, PAYROLL_AUTHORIZED_EMAILS in server/index.ts (a hardcoded list of exactly 15 emails as of Jul 2026), gates the payroll/personnel-expense API endpoints used by Divisional P&L — holding Gandalf is not sufficient for those endpoints.

Growth access: grants, capabilities, and the password gate

Growth access controls Signal's most sensitive area — the Growth section: People (/growth/people, per-person salary/payroll/compensation) and Performance Radar (/growth/performance-radar). It is orthogonal to the five tiers and is resolved by the backend from two Supabase tables (Signal's own records):

  • growth_access_grants — data scope: WHICH divisions (and, for Shared Services, G&A, and Healthcare, which departments) a user's Growth data is filtered to. One row per division or department; a row with division = NULL means all divisions. Revocations are soft-deletes (revoked_at), preserving an audit trail; each row records who granted it and when. Every /api/growth data endpoint filters its response server-side to the caller's scope.

  • growth_access_capabilities — three per-user booleans: can_view_people (Growth → People tab visible; defaults ON for grant-holders with no capabilities row, for backward compatibility), can_view_radar (Performance Radar tab visible; strictly opt-in, default false), and can_manage_access (may open /growth/access and grant/revoke Growth access for others; grants no data visibility by itself).

The frontend fetches GET /api/growth/my-access at sign-in; the response sets four flags in AuthContext: isGrowthAllowed (has any division grant), canViewPeople, canViewRadar, and canManageGrowthAccess (OR'd with Sauron). All four are sessionStorage-cached like the tier flags.

On top of the grants sits a second factor: each Growth viewing tab is wrapped in GrowthTabGate (in src/App.tsx), which prompts for a shared unlock password. The password is validated against a real gated endpoint before being stored, sent as the x-growth-key header on every /api/growth request, and checked server-side against the GROWTH_UNLOCK_KEY environment variable — so a locked tab genuinely cannot load data; this is enforcement, not a hidden screen. The key is held in browser memory only (src/services/growthUnlock.ts): a page reload forces re-entry, and it re-locks after the app has been backgrounded ≥ ~5 minutes. The Growth Access management endpoints require the same key plus requireGrowthManage.

What changed recently (July 2026): before July 2026, Growth access was a hardcoded email allowlist in the code. Migration 20260701_growth_access_grants.sql replaced it with database-backed grants (seeding the 8 previously-allowlisted emails as all-division grants so nobody lost access on deploy); 20260702 added department-level scoping for the three large divisions; 20260703 added the per-tab capability system (can_view_people / can_view_radar / can_manage_access), which split the old single Growth gate into per-tab gating — Performance Radar is now hidden by default and granted per user. The new management screen at /growth/access (guard GrowthManageRoute) delegates administration to Growth managers without requiring Admin or Gandalf, and a new "Access" sidebar section now holds both "General Access" (/users, Gandalf) and "Growth Access" (/growth/access, Growth managers). Gandalf is deliberately NOT auto-granted Growth Access management.

The route guard components

All guards live in src/App.tsx and redirect failing users to /summary (except GrowthTabGate, which renders a password prompt in place):

Guard component

Admits

Notes

TeamLeadRoute

isTeamLead

Admin/Gandalf/Sauron imply Team Lead via the role-application logic, so all higher tiers pass.

AdminRoute

isAdmin OR isGandalf

"Admin or above": Gandalf passes every Admin gate; Sauron auto-sets both flags.

GandalfRoute

isGandalf

Sauron auto-sets isGandalf, so Sauron passes.

SauronRoute

isSauron

Hardcoded super-admin list only. Used solely for /analytics.

MemberAllowedRoute

any NON-Member

Inverse guard: redirects base-tier Members away; every elevated or Growth-capable user falls through. Wraps only otherwise-ungated routes.

PeopleRoute

canViewPeople

Growth → People tab reachability.

RadarRoute

canViewRadar

Growth → Performance Radar tab reachability.

GrowthManageRoute

canManageGrowthAccess

Growth Access management. Includes Sauron implicitly; excludes Gandalf unless separately granted.

GrowthTabGate

anyone already past PeopleRoute/RadarRoute who enters the shared unlock password

Second factor, not a role check. Backend enforces the same key on every /api/growth data request.

Sidebar visibility mirrors the guards: nav links carry flags (teamLeadOnly, adminOnly, gandalfOnly, sauronOnly, peopleOnly, radarOnly, manageGrowthOnly) and are filtered per user; Members' sidebars are stripped to exactly Summary and Employees. Server-side, every sensitive API route re-checks access with middleware (requireAuth, requireAdmin, requireGandalf, requireGrowthAccess, requireGrowthManage, requirePayrollAccess) that re-reads the role/grant tables with 2–5 minute caches — hiding a nav item is never the only protection.

Route table: Route | Guard | Effective audience

Every route mounted in src/App.tsx as of staging @ 98df0c4 (2026-07-07):

Route

Guard

Effective audience

/

none (redirect)

All signed-in users → redirected to /summary (or saved deep link)

/summary

none

All signed-in users, including Members

/employees

none

All signed-in users; Members see only their own row ("Your Record") — view-level scoping, not a route guard

/staffing

MemberAllowedRoute

All non-Members (Team Lead+ and Growth-capable users)

/insights

AdminRoute

Admin, Gandalf, Sauron (link hidden from nav; direct URL works)

/corrections

AdminRoute

Admin, Gandalf, Sauron

/growth/people

PeopleRoute + GrowthTabGate

Users with can_view_people who also enter the Growth unlock password; data further filtered to their division/department scope

/growth/performance-radar

RadarRoute + GrowthTabGate

Users with can_view_radar who also enter the Growth unlock password; same server-side scope filtering

/growth/access

GrowthManageRoute

Delegated Growth managers (can_manage_access) and Sauron; NOT Gandalf by default; management requests also require the unlock password server-side

/audit-log (Assignment History)

GandalfRoute

Gandalf, Sauron

/users (General Access)

GandalfRoute

Gandalf, Sauron

/hiring (Hiring Pipeline)

GandalfRoute

Gandalf, Sauron

/jd-sync (JD Sheet Sync)

GandalfRoute

Gandalf, Sauron

/analytics

SauronRoute

Sauron only (hardcoded super-admin list)

/leads (Manage Leads)

TeamLeadRoute

Team Lead and above

/capacity (Role Capacity)

AdminRoute

Admin, Gandalf, Sauron

/company-capacity

AdminRoute

Admin, Gandalf, Sauron

/kpi-targets

AdminRoute

Admin, Gandalf, Sauron

/divisional-pl (Divisional P&Ls)

GandalfRoute

Gandalf, Sauron; the on-screen Personnel Expenses section requires the PAYROLL_AUTHORIZED_EMAILS allowlist (15 emails); the per-worker personnel CSV export is gated separately by requireGandalf (app_users.is_gandalf) plus the X-Export-Password header matching the PERSONNEL_EXPORT_PASSWORD env var — the payroll allowlist does not apply to the export

/profitability (Client Profitability)

AdminRoute

Admin, Gandalf, Sauron

/pulse-coverage (Employee Pulse)

AdminRoute

Admin, Gandalf, Sauron

/revenue-overlap

TeamLeadRoute

Team Lead and above

/solutions and /solutions/:id (AI Solutions)

GandalfRoute

Gandalf, Sauron

/predictions/churn and /predictions/churn/:clientId

MemberAllowedRoute

All non-Members

/predictions/retention (Division Forecast)

MemberAllowedRoute

All non-Members

/predictions/se and /predictions/se/:clientId (Service Expansion)

MemberAllowedRoute

All non-Members

/etcr (ETCR Beta)

MemberAllowedRoute

All non-Members

/predictions/portfolio and /predictions/portfolio/:clientId (Portfolio Review)

MemberAllowedRoute

All non-Members

/predictions/poc-management and /predictions/poc-management/:clientId

MemberAllowedRoute

All non-Members

/predictions/pulse-summary (Pulse Summary)

AdminRoute

Admin, Gandalf, Sauron

/pulse/submit (Submit Pulse Data)

TeamLeadRoute

Team Lead and above (intended: Account Directors / Group Directors)

/pulse/my-submissions

TeamLeadRoute

Team Lead and above

/agent/insights, /agent/alerts, /agent/advisor

GandalfRoute, behind feature flag

NOT mounted — SHOW_AI_AGENTS = false in src/App.tsx; these routes do not exist in the running app

Two audience subtleties worth stating plainly: (1) a Growth-only user (holding a Growth grant or capability but no Team Lead flag) is not a Member, so MemberAllowedRoute routes — Staffing, ETCR, and all the Client Health prediction views — are technically reachable for them, even though the intended audience is Team Lead and above. (2) The Iris AI chat drawer (Cmd/Ctrl+I) is not route-gated: its enable check is the isAdmin flag specifically (IrisContext.tsx), so Admins and Saurons get it, Members never see the button, and a Gandalf-only user without is_admin does not get it despite passing AdminRoute gates elsewhere.

Recent changes summary

Changes to the access model shipped around June–July 2026, in case a user asks "why does access look different":

  • Growth per-tab gating — the single Growth gate split into per-tab capabilities: People (can_view_people, PeopleRoute) and Performance Radar (can_view_radar, RadarRoute, hidden by default, granted per user), plus a delegated can_manage_access capability. Migrations 20260701–20260703.

  • /growth/access screen — a new management UI replacing the old hardcoded Growth email allowlist; delegated Growth managers administer grants without needing Admin/Gandalf; division- and (for three divisions) department-scoped grants; soft-delete revocations for audit.

  • "Access" sidebar section — new bottom-pinned section holding General Access (/users, Gandalf) and Growth Access (/growth/access, Growth managers), replacing the old lone "Users" link.

  • Nav restructure — the prediction/health views (Portfolio Review, POC Management, Churn Risk, Division Forecast, Service Expansion), Staffing, and ETCR became open to every non-Member (MemberAllowedRoute); they no longer require Team Lead. Client Profitability and Pulse Summary remain Admin+.

  • Member tier hardening — Members' sidebars stripped to Summary + Employees; Global Search and the Iris button hidden for Members.

Did this answer your question?