Skip to main content

Documentation Index

Fetch the complete documentation index at: https://grantmaster.dev/llms.txt

Use this file to discover all available pages before exploring further.

Portal Security Model

Last updated: 2026-04-05

Overview

The GrantMaster external portal (portal/) is a standalone React application that gives external stakeholders — funders, partners, board members, and auditors — read access to project progress dashboards without requiring a GrantMaster account or Firebase Auth login. Stakeholders access the portal through unique, shareable URLs containing a cryptographically generated token. The portal is deployed separately from the main app at https://impact.grantmaster.ai.

Key principles

  • No user account required. Stakeholders never create credentials; access is entirely token-based.
  • Least-privilege data exposure. Each token carries a visibility configuration that controls exactly which data categories the stakeholder can see or interact with.
  • Audit-first design. Every portal session, page view, and action (comment, acknowledgment, upload) is recorded for compliance traceability.
  • Organization-scoped isolation. A token is bound to a single organization and a single project. Cross-tenant access is impossible by construction.

Authentication Model

Token-based access (portal app)

The portal app does not use Firebase Authentication. Instead, it relies on opaque URL-based tokens validated server-side by Cloud Functions. Flow:
  1. An admin or manager in the main app creates a portal token via the CreateTokenModal UI.
  2. PortalTokenService.createToken() generates a 64-hex-character token using the Web Crypto API (crypto.getRandomValues) and stores it in the portalTokens Firestore collection.
  3. The admin shares the resulting URL (e.g., https://impact.grantmaster.ai/<tokenId>) with the stakeholder.
  4. When the stakeholder opens the URL, the portal app extracts the tokenId from the route parameter and calls the validatePortalToken Cloud Function.
  5. The Cloud Function validates the token (existence, active status, expiry, IP restrictions), creates an audit session, and returns filtered project data.
  6. The portal stores the returned payload in sessionStorage and renders the dashboard.

Portal tokens in the tRPC/API layer

Portal tokens are also recognized by the main API gateway’s authentication middleware (functions/src/api/middleware/authenticate.ts). Tokens prefixed with portal_ or passed via the ?token= query parameter resolve to an AuthContext with:
  • type: 'portal'
  • Two read-only permissions: VIEW_PROJECTS and VIEW_REPORTS
  • isSuperAdmin: false
  • portalProjectId scoping queries to the bound project
This dual-path design allows future API endpoints to serve portal data through the standard tRPC pipeline while maintaining the same security constraints.

Portal Token Lifecycle

Generation

PropertyDetail
Token format64 hex characters (32 random bytes via crypto.getRandomValues)
StorageStored as the Firestore document ID in portalTokens/{tokenId}
Default expiry365 days (configurable: 7, 30, 90, 180, 365, or 730 days)
Creator constraintsMust be a Manager or higher role in the owning organization

Fields on a PortalToken document

FieldTypePurpose
idstringThe 64-char token, also the document ID
organizationIdstringTenant isolation key
projectIdstringLimits data access to one project
stakeholderNamestringDisplay name for the stakeholder
stakeholderEmailstring?Optional, used for notification routing
stakeholderType'funder' | 'partner' | 'board' | 'auditor'Categorizes the external user
expiresAtstring (ISO 8601)Hard expiration timestamp
isActivebooleanfalse = revoked
visibilityPortalVisibilityGranular feature flags (see below)
accessCountnumberIncremented on each validation
lastAccessedAtstring?Timestamp of last access
allowedIpAddressesstring[]?Optional IP allowlist
createdBy / createdByNamestringAudit trail of who created the token
revokedAt / revokedBy / revokedReasonstring?Populated on revocation

Validation checks (in order)

The validateAndGetToken() function in portalShared.ts performs:
  1. Existence — Token document must exist in Firestore. Returns not-found if missing.
  2. Active statusisActive must be true. Returns permission-denied if revoked.
  3. ExpiryexpiresAt must be in the future. Returns permission-denied if expired.
  4. IP restriction (in validatePortalToken handler) — If allowedIpAddresses is populated, the client IP must be in the list. Returns permission-denied otherwise.

Revocation

Tokens are soft-deleted — the isActive flag is set to false, and revokedAt, revokedBy, and revokedReason are recorded. Hard deletion is prohibited by Firestore rules (allow delete: if false). This preserves the audit trail even after revocation.

Expiry presets

The CreateTokenModal offers the following presets: 7 days, 30 days, 90 days, 6 months, 1 year, 2 years. The recommended default for funder sharing is 1 year.

Visibility Configuration

Each token carries a PortalVisibility object that acts as a feature-flag mask controlling what data is returned by the Cloud Function and rendered in the portal UI.
FlagControls
showBudgetUtilizationBudget utilization percentage
showBudgetDetailsActual budget amounts (total, spent)
showBudgetLinesIndividual budget line items
showMilestonesMilestone/reporting deadline list and progress
showTimelineProject start/end dates and phase
showTeamMembersDelivery team names and roles
showComplianceStatusCompliance health indicator
showReportsGenerated report list and next-due deadline
showImpactMetricsSDGs, beneficiary data, outcome tracking
allowCommentsTwo-way comment thread
allowReportAcknowledgmentStakeholder can mark reports as received/approved/revision-requested
allowReportSubmissionStakeholder can upload documents back through the portal
Visibility filtering is enforced server-side in getFilteredProjectData(). The Cloud Function only queries and returns data for enabled categories. The portal UI also checks visibility flags before rendering sections, but the server-side check is the security boundary.

Firestore Security Rules

All portal-related collections use restrictive rules that enforce tenant isolation and prevent direct writes from the portal app.

portalTokens/{tokenId}

OperationRule
ReadSuperAdmin, or Manager+ in the owning organization
CreateManager+ in the organization; createdBy must match the authenticated user; isActive must be true
UpdateManager+ in the organization; organizationId and projectId are immutable
DeleteNever (soft delete via isActive = false)

portalSessions/{sessionId}

OperationRule
ReadSuperAdmin, or Admin in the owning organization
CreateCloud Function only (allow create: if false in rules)
UpdateCloud Function only (allow update: if false in rules)
DeleteNever (audit trail)

portalComments/{commentId}

OperationRule
ReadSuperAdmin, or any authenticated member of the organization
CreateAuthenticated org members only (stakeholder comments created via Cloud Function)
UpdateAuthor of the comment, or org members marking stakeholder comments as read (content must remain unchanged)
DeleteAdmin of the organization only
Stakeholder-authored comments are created exclusively through the postPortalComment Cloud Function, which validates the token before writing. The Firestore rule for create requires authorType == 'organization' for direct writes, preventing any portal user from writing directly.

portalReportAcknowledgments/{ackId}

OperationRule
ReadAny authenticated member of the organization
CreateCloud Function only (allow create: if false)
UpdateManager+ in the organization (for marking revisions as resolved)
DeleteNever

portalSubmissions/{submissionId}

OperationRule
ReadAny authenticated member of the organization
CreateCloud Function only (allow create: if false)
UpdateManager+ in the organization (for reviewing submissions)
DeleteNever (audit trail)

Design rationale: Cloud Function gatekeeping

All write operations from the portal app are routed through Firebase Cloud Functions (callable functions) rather than direct Firestore writes. The corresponding Firestore rules set allow create: if false for these collections. This design means:
  • Token validation happens in trusted server-side code, not in client-side rule evaluation.
  • Rate limiting, IP checks, and business logic (e.g., checking visibility.allowComments) are enforced before any data is written.
  • Session tracking and notification events are created atomically alongside the primary write.

Data Access Boundaries

What portal users CAN see (when visibility allows)

  • Project name, code, phase, description
  • Budget utilization percentage and optionally amounts and line items
  • Milestone/reporting deadline status and completion rates
  • Timeline (start/end dates)
  • Team member names and roles (no email addresses or internal IDs)
  • Compliance health indicator (derived, not raw data)
  • Impact metrics: SDGs, beneficiaries, outcomes, regions
  • Generated reports list (name, type, date — no download of report files)
  • Trend charts (budget burn, impact delivery, milestone completion)

What portal users CANNOT see

  • Other projects in the organization
  • Other organizations’ data (tenant isolation)
  • Raw financial data (individual expenses, invoices, receipts)
  • Employee personal details beyond name and role
  • Internal notes, audit logs, or compliance details
  • Grant application data or pipeline information
  • User accounts, roles, or permissions
  • System configuration or billing information
  • Other stakeholders’ portal tokens

What portal users CAN do (when visibility allows)

  • Post comments in a discussion thread
  • Acknowledge reports (received, approved, request revision)
  • Upload documents via signed URLs (PDF, DOCX, XLSX, DOC, XLS, JPEG, PNG; max 50 MB)

Cross-project isolation

A portal token is bound to exactly one projectId. The getFilteredProjectData() function reads only the project document matching token.projectId. Related queries (milestones, team members, reports) are filtered by both projectId and organizationId.

Route Protection and Authorization

Portal app routing

The portal app has a minimal route structure:
RouteComponentPurpose
/:tokenIdPortalLandingValidates token, redirects to dashboard on success
/:tokenId/dashboardProjectDashboardRenders the project dashboard
/Static error message”Portal Access Required”
*Redirect to /Catch-all
There is no authenticated route guard in the traditional sense. Instead:
  1. PortalLanding calls validateToken() (Cloud Function). If validation fails, an error screen is shown (invalid, expired, revoked, or network error). If it succeeds, validated data is stored in sessionStorage and the user is redirected to the dashboard.
  2. ProjectDashboard checks sessionStorage for the token’s data. If missing (direct navigation without validation), it redirects back to /:tokenId to force re-validation.

Session management

  • A portalSessions document is created on each successful token validation, recording IP address, user agent, and timestamp.
  • Page views and actions are appended to the session via updatePortalSession.
  • When the portal component unmounts, endSession() is called to mark the session end time.
  • Sessions provide a complete audit trail of what the stakeholder viewed and did.

CORS restrictions

Cloud Functions serving portal requests are restricted to specific origins:
const PORTAL_CORS = [
  'https://impact.grantmaster.ai',
  'https://grantmaster-portal.web.app',
  // Localhost only in emulator mode
  ...(process.env.FUNCTIONS_EMULATOR === 'true' ? ['http://localhost:3001'] : []),
];

Rate Limiting

All portal Cloud Functions enforce per-IP rate limits to prevent abuse:
FunctionLimit (per IP per minute)
validatePortalToken10
postPortalComment5
acknowledgePortalReport10
updatePortalSession60
endPortalSession60
getPortalUploadUrl5
submitPortalReport5
Rate limits use an in-memory store that resets on Cloud Function cold starts. Stale entries are cleaned up every 5 minutes. Exceeding the limit returns a resource-exhausted error.

File Upload Security

Portal document submissions use a two-step signed-URL pattern:
  1. The portal calls getPortalUploadUrl with file metadata. The Cloud Function validates the token, checks allowReportSubmission visibility, validates MIME type and file size, then generates a time-limited (15-minute) signed URL for a specific storage path.
  2. The portal uploads the file directly to Firebase Storage using the signed URL.
  3. After upload, the portal calls submitPortalReport to create the portalSubmissions record linking the uploaded file.
Constraints:
  • Allowed MIME types: PDF, DOCX, XLSX, DOC, XLS, JPEG, PNG
  • Maximum file size: 50 MB
  • Filenames are sanitized (path separators and special characters stripped, truncated to 200 chars)
  • Storage path is namespaced: portal-submissions/{orgId}/{projectId}/{submissionId}/{filename}
  • Signed URLs expire after 15 minutes

Notification Integration

Portal actions generate systemEvents documents that trigger Novu notification workflows:
Event typeTrigger
PORTAL_COMMENT_POSTEDStakeholder posts a comment
PORTAL_REPORT_ACKNOWLEDGEDStakeholder marks a report as received or approved
PORTAL_REVISION_REQUESTEDStakeholder requests revisions on a report (severity: warning)
PORTAL_REPORT_SUBMITTEDStakeholder uploads a document
These events are created within the same Cloud Function call as the primary action, ensuring the organization is promptly notified of stakeholder activity.

Security Considerations and Best Practices

Token hygiene

  • Rotate tokens periodically. Use the expiration presets to ensure tokens do not remain valid indefinitely. The recommended maximum is 1 year for ongoing funder relationships.
  • Revoke promptly. When a stakeholder relationship ends or a project closes, revoke all associated tokens immediately.
  • Use IP restrictions for high-sensitivity projects where the stakeholder’s network is known.
  • Minimize visibility. Grant only the data categories the stakeholder needs. Use the “Funder Preset” as a starting point and remove unnecessary flags.

Transport security

  • The portal is served over HTTPS only.
  • Cloud Function CORS is restricted to known portal origins.
  • Signed upload URLs use HTTPS and expire after 15 minutes.

Data leakage prevention

  • Token IDs appear in URLs. Advise stakeholders to treat portal links as confidential.
  • The portal stores validated data in sessionStorage (not localStorage), so it is cleared when the browser tab closes.
  • No project data is cached on disk or persisted client-side beyond the browser session.

Monitoring

  • Review portalSessions for unusual access patterns (high frequency, unexpected IPs, unusual user agents).
  • Monitor portalTokens.accessCount to identify tokens that may have been shared beyond the intended stakeholder.
  • Track unread stakeholder comments and pending submissions via the Grantors Portal tab in the main app.

Known limitations

  • Rate limiting is in-memory per Cloud Function instance. Under high concurrency with many instances, the effective rate limit is per-instance, not global. For most portal workloads this is acceptable.
  • Token IDs are opaque hex strings but are not hashed at rest in Firestore. Anyone with direct Firestore Admin access can read token values. This is mitigated by Firestore security rules preventing client-side reads by non-org-members.
  • The portal app does not implement CSRF protection. This is acceptable because all mutations go through Firebase Cloud Functions (callable), which use their own CORS and authentication checks rather than cookie-based auth.