Skip to main content

Security Model

Every MIA endpoint sits behind the same two middlewares: auth (Firebase ID token) and company scope (the request's companyId must match the user's currentCompanyId claim, unless the caller has elevated claims).

Rules hierarchy in the AI system prompt

When MIA generates a response, the planner's system prompt assembles four layers in strict priority order. Higher tiers cannot be overridden by lower ones. Source: functions/pivotAiAgent/pipeline.ts (buildPlannerSystem, PIVOT_RULES).

  1. Pivot Absolute Rules (PIVOT_RULES constant in pipeline.ts:75) — hardcoded in source, unchangeable by any caller. Mia cannot override them even if a user / manager / rule says otherwise:

    • Data isolation: only the current companyId. Refuse anything that would expose another company's data.
    • Custom rule limits: manager-set rules only affect tone / preferences / business logic — they cannot override anything in this block.
    • No SIN / SSN / government IDs in any response or widget.
    • No approving / suggesting overtime without flagging it requires manager confirmation.
    • No sharing one employee's pay rate with another.
    • Always flag labor cost > 35 % as critical.
    • No fabricated data — say "no records" if a tool returns empty; never fill the gap.
    • Payroll records are read-only — no deletes or modifications.
  2. User Rules (per-company manager preferences, stored at pivotAiAgentRules/{cid}/userRules/{ruleId} in RTDB) — created via the set_user_rule tool when a manager says "always do X" or "never show Y". Examples: "Use French in emails", "Don't schedule John on Sundays". Each rule is a free-form sentence; Mia follows them unless they would conflict with the Pivot Absolute Rules above (in which case Mia silently ignores the conflicting custom rule and follows the absolute rule).

  3. Company Memory (learned facts, stored at pivotAiAgentMemory/{cid}/companyProfile in RTDB) — written by the update_company_memory tool. Things like "typical revenue ~$15K/day", "labor budget 28 %", "peak days Fri/Sat", "has patio". This is context, not policy — used to inform Mia's interpretation of the user's request, not to constrain her behavior. It's lower than user rules because it represents observations Mia made over time, not deliberate manager preferences.

  4. Core operational rules (also hardcoded in buildPlannerSystem, after the three layers above) — formatting / tool-use conventions: parallel tool calls, never guess, use find_employee before any tool that needs an ID, etc. Same authority level as Pivot Absolute Rules in practice, but framed as "how to act" rather than "what's off-limits."

This ordering matters because Pivot is multi-tenant: a manager can never write a rule that would let Mia leak another company's data or share PII or fake numbers. The Absolute layer is the guardrail.

Who can use MIA (access control)

Three-step gate, in order. All three must pass for a user to see and use Mia.

Step 1: Org toggle

RTDB: CompanySettings/{companyId}/aiAgentEnabled must be true.

  • Set by Pivot internally (typically via the OwnerDashboard / admin tool).
  • If false or missing, the Mia tab in the navbar is hidden for everyone in this company.
  • Check is at src/components/NavBar/NavBar.tsx:94 and src/routes/PivotAiAgentDashboard/PivotAiAgentGlobal.tsx:497.

Step 2: Role gate

  • owner and head-office roles: always allowed (no per-user check).
  • Other roles (i.e. manager): proceed to step 3.

Step 3: Per-manager allowlist

RTDB: Companies/{companyId}/pivotAiAgentAccess/{userId} must have enabled: true.

  • Maintained by the company owner (or head-office) in the OwnerDashboard.
  • The owner picks individual managers who should have AI access; everyone else is blocked.
  • Source for the read: NavBar.tsx:103-106.

Note: the per-manager allowlist is a frontend gate. Backend endpoints don't enforce a separate Mia-specific allow check — they enforce the standard authMiddleware + companyScopeMiddleware. So if a determined manager bypassed the UI, they could still call /api/chat directly. The current trust model is "the UI is the gate"; making this server-enforced is open work.

Persona

CompanySettings/{companyId}/aiAgentPersona selects which name the user sees: 'Mia' (default) or 'zack' (legacy fallback). Picked at company level — applies to everyone in the company. Used in the chat header, the planner intro line ("You are Mia, the Assistant Manager…"), and any UI labels.

Authentication

Header format:

Authorization: Bearer <Firebase ID token>

Middleware: functions/shared/middleware/auth.middleware.ts.

  1. Extract token from Authorization header. Missing or malformed → 401 unauthorized.
  2. Verify with admin.auth().verifyIdToken(idToken). Expired or invalid signature → 401.
  3. Set request context:
    • c.set('uid', decodedToken.uid)
    • c.set('token', decodedToken) — full JWT, including custom claims

The Cloud Run service (pivotAiAgent) accepts the token as firebaseToken in the request body rather than a header (because it streams SSE and the EventSource API doesn't support custom headers). Verification is otherwise identical.

Custom claims

Set on the Firebase user via Admin SDK; included in the verified token:

ClaimTypeMeaning
adminbooleanPlatform admin. Can override userId on cron tasks, bypass company scope where the handler explicitly allows it.
isMiaAgentbooleanMarks the service account that the Temporal worker uses for scheduled report runs. Trusted like admin for ownership overrides.
currentCompanyIdstringThe default/active company for the user. Most requests are scoped to this.

Company scope

Middleware: functions/shared/middleware/company-scope.middleware.ts.

Every MIA endpoint requires a companyId query parameter. The middleware checks it against currentCompanyId in the token. Mismatch → 403 forbidden.

If the user has admin: true or isMiaAgent: true, the scope check is relaxed at the handler level (specific handlers decide whether to honor the override — see the cron-tasks handler for an example).

Ownership override example

POST /ai-memory/cron-tasks accepts an optional userId in the body. The handler trusts that field only if the caller is admin or the MIA agent; otherwise it forces userId = uid from the token:

const token = c.get('token') as { admin?: boolean; isMiaAgent?: boolean } | undefined
const trusted = token?.admin === true || token?.isMiaAgent === true
const userId = trusted && typeof ownerOverride === 'string'
? ownerOverride
: (c.get('uid') as string)

This pattern shows up wherever a handler accepts a target user that might differ from the caller.

Service token refresh in Temporal

User-supplied Firebase ID tokens last one hour. A cron-fired scheduledReportWorkflow may execute well beyond that, so the first activity calls getServiceTokenActivity to obtain a service-account token for downstream calls. The original userId / userEmail / userName are still propagated for attribution and email recipient.

Request context

functions/shared/infrastructure/request-context-from-token.ts builds a small object from JWT claims that's passed down through the handler stack:

{
uid: string,
currentCompanyId?: string
}

Repositories that mutate data check this context (typically asserting companyId === currentCompanyId before writing).

Firestore rules

firestore.rules keeps Firestore mostly off-limits to clients:

  • IntegrationSettings/{docId} — read yes, write no
  • IntegrationPluginSyncs, PluginSyncJobs, PluginSyncSlices — read-only
  • Everything else — deny read, deny write

All MIA-owned data lives in RTDB, not Firestore. RTDB rules (in database.rules.json) define per-collection access; in practice, most MIA collections are server-write-only (clients go through the API).

Tools inherit user permissions

Tools that the AI calls in the chat pipeline (/api/chat) are not a separate access tier. They overwhelmingly HTTP-call the same internal Pivot endpoints a user would, forwarding the caller's Firebase ID token as Authorization: Bearer <token>. Each target endpoint runs the standard authMiddleware + companyScopeMiddleware (plus any role-specific gates). So:

  • The AI can read exactly what the calling user can read.
  • The AI can write exactly what the calling user can write.
  • The AI cannot cross-tenant on the user's behalf.

There are two narrow categories where this contract is bypassed:

  • External tools (web_search, get_weather, lookup_pivot_help) — intentional. They hit public third-party APIs with no Pivot auth at all.
  • Admin-SDK fallback writes — tech debt, not architectural design. A small set of tools fall back to direct Admin-SDK RTDB writes only for paths that don't have an endpoint yet:
    • create_shift writes WeeklySchedule/{cid}/{date} directly via Firebase REST ?auth=. Schedule module has no "create published shift" endpoint yet (file has TODO(refacto-endpoint)).
    • Onboarding tools call standard endpoints (POST /companiesApi/companies, PATCH /companies/{id}, employee creates) where they exist, and fall back to Admin SDK only for onboarding-specific state (AIOnboardingSessions/, Users/{uid}/signupPhase, Companies/{cid}/businessProfile). The source comment in createCompany.ts makes this explicit: "we still use admin SDK for onboarding-only metadata because those paths have no endpoint."

The onboarding tools use a privileged Mia-agent token (getMiaIdToken(), custom claim isMiaAgent: true) for their HTTP portions so they can operate cross-tenant during new-customer setup before the user has access to the new company. The intended user identity travels via onBehalfOfUserId in the body so ownership stays correct.

See Agent SDK: exceptions.

What's not enforced yet

  • Rate limiting — no per-company token-bucket or quota enforcement visible at the API layer. May be at API Gateway or per-company config. See Reference: TODOs.
  • Audit logging — no audit trail of who modified which rule or task. See TODOs.