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:
- An admin or manager in the main app creates a portal token via the
CreateTokenModal UI.
PortalTokenService.createToken() generates a 64-hex-character token using the Web Crypto API (crypto.getRandomValues) and stores it in the portalTokens Firestore collection.
- The admin shares the resulting URL (e.g.,
https://impact.grantmaster.ai/<tokenId>) with the stakeholder.
- When the stakeholder opens the URL, the portal app extracts the
tokenId from the route parameter and calls the validatePortalToken Cloud Function.
- The Cloud Function validates the token (existence, active status, expiry, IP restrictions), creates an audit session, and returns filtered project data.
- 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
| Property | Detail |
|---|
| Token format | 64 hex characters (32 random bytes via crypto.getRandomValues) |
| Storage | Stored as the Firestore document ID in portalTokens/{tokenId} |
| Default expiry | 365 days (configurable: 7, 30, 90, 180, 365, or 730 days) |
| Creator constraints | Must be a Manager or higher role in the owning organization |
Fields on a PortalToken document
| Field | Type | Purpose |
|---|
id | string | The 64-char token, also the document ID |
organizationId | string | Tenant isolation key |
projectId | string | Limits data access to one project |
stakeholderName | string | Display name for the stakeholder |
stakeholderEmail | string? | Optional, used for notification routing |
stakeholderType | 'funder' | 'partner' | 'board' | 'auditor' | Categorizes the external user |
expiresAt | string (ISO 8601) | Hard expiration timestamp |
isActive | boolean | false = revoked |
visibility | PortalVisibility | Granular feature flags (see below) |
accessCount | number | Incremented on each validation |
lastAccessedAt | string? | Timestamp of last access |
allowedIpAddresses | string[]? | Optional IP allowlist |
createdBy / createdByName | string | Audit trail of who created the token |
revokedAt / revokedBy / revokedReason | string? | Populated on revocation |
Validation checks (in order)
The validateAndGetToken() function in portalShared.ts performs:
- Existence — Token document must exist in Firestore. Returns
not-found if missing.
- Active status —
isActive must be true. Returns permission-denied if revoked.
- Expiry —
expiresAt must be in the future. Returns permission-denied if expired.
- 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.
| Flag | Controls |
|---|
showBudgetUtilization | Budget utilization percentage |
showBudgetDetails | Actual budget amounts (total, spent) |
showBudgetLines | Individual budget line items |
showMilestones | Milestone/reporting deadline list and progress |
showTimeline | Project start/end dates and phase |
showTeamMembers | Delivery team names and roles |
showComplianceStatus | Compliance health indicator |
showReports | Generated report list and next-due deadline |
showImpactMetrics | SDGs, beneficiary data, outcome tracking |
allowComments | Two-way comment thread |
allowReportAcknowledgment | Stakeholder can mark reports as received/approved/revision-requested |
allowReportSubmission | Stakeholder 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}
| Operation | Rule |
|---|
| Read | SuperAdmin, or Manager+ in the owning organization |
| Create | Manager+ in the organization; createdBy must match the authenticated user; isActive must be true |
| Update | Manager+ in the organization; organizationId and projectId are immutable |
| Delete | Never (soft delete via isActive = false) |
portalSessions/{sessionId}
| Operation | Rule |
|---|
| Read | SuperAdmin, or Admin in the owning organization |
| Create | Cloud Function only (allow create: if false in rules) |
| Update | Cloud Function only (allow update: if false in rules) |
| Delete | Never (audit trail) |
| Operation | Rule |
|---|
| Read | SuperAdmin, or any authenticated member of the organization |
| Create | Authenticated org members only (stakeholder comments created via Cloud Function) |
| Update | Author of the comment, or org members marking stakeholder comments as read (content must remain unchanged) |
| Delete | Admin 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}
| Operation | Rule |
|---|
| Read | Any authenticated member of the organization |
| Create | Cloud Function only (allow create: if false) |
| Update | Manager+ in the organization (for marking revisions as resolved) |
| Delete | Never |
portalSubmissions/{submissionId}
| Operation | Rule |
|---|
| Read | Any authenticated member of the organization |
| Create | Cloud Function only (allow create: if false) |
| Update | Manager+ in the organization (for reviewing submissions) |
| Delete | Never (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:
| Route | Component | Purpose |
|---|
/:tokenId | PortalLanding | Validates token, redirects to dashboard on success |
/:tokenId/dashboard | ProjectDashboard | Renders the project dashboard |
/ | Static error message | ”Portal Access Required” |
* | Redirect to / | Catch-all |
There is no authenticated route guard in the traditional sense. Instead:
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.
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:
| Function | Limit (per IP per minute) |
|---|
validatePortalToken | 10 |
postPortalComment | 5 |
acknowledgePortalReport | 10 |
updatePortalSession | 60 |
endPortalSession | 60 |
getPortalUploadUrl | 5 |
submitPortalReport | 5 |
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:
- 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.
- The portal uploads the file directly to Firebase Storage using the signed URL.
- 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 type | Trigger |
|---|
PORTAL_COMMENT_POSTED | Stakeholder posts a comment |
PORTAL_REPORT_ACKNOWLEDGED | Stakeholder marks a report as received or approved |
PORTAL_REVISION_REQUESTED | Stakeholder requests revisions on a report (severity: warning) |
PORTAL_REPORT_SUBMITTED | Stakeholder 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.