Internal FSAdmin Activity Visibility
This rule governs which reports and engagements a non-admin viewer sees when they belong to a client account where FSAdmin users have authored work (typically via impersonation). It ensures analytical surfaces (dashboard, assessments) present a client-faithful view of activity rather than a mix of client work and Fair Supply staff work.
The Rule
If the viewer does not have the
FSAdminAuth0 role, hide all activity authored by internal (Fair Supply) users — including its contribution to derived metrics.
Symmetric across reports and engagements. Applied at the application (use case) layer, post-fetch, before any aggregation.
Why this exists
FSAdmin users can impersonate a client account (Redis-backed session selection, no BELONGS_TO relationship written). When impersonating, they can author Spotlight/Analyst reports, supply-chain assessments, and engagements that get persisted as Neo4j nodes attributed to the FSAdmin's User record.
Without this rule, the real client (a member with BELONGS_TO to the account) would see:
- Rows in the activity table they did not create and cannot trace back to anyone in their organisation.
- Metrics (
Risk Assessments,Supplier Engagements,Mitigation Actions,Supply Chain Risk) inflated by Fair Supply's internal work. - A discrepancy between counts and what is visible in the table.
Where the rule applies
| Surface | Use case | Filter applied to |
|---|---|---|
| Dashboard (Activity tab) | GetAccountActivity | reports, engagements, orgRiskMap, all activity metrics, all overview metrics |
| Assessments page | GetAssessments | reports only (assessments domain has no engagements) |
Where the rule does not apply
- Active Suppliers (
overviewMetrics.activeOrganisations) — derived fromOrganisationRepository.findActiveOrganisationCount, transaction-based, not author-attributable. - Active Spend (
overviewMetrics.activeSpend) — pure transaction aggregate. - FSAdmin viewers — pass-through; admins see all activity in any account they have selected (including impersonated ones), so impersonation work remains visible to the impersonating admin.
How "internal user" is identified
Phase 1 (current) — email-domain heuristic
isInternalUserEmail(email) in packages/core/src/domain/utils/internal-user.ts returns true for any address ending in @fairsupply.com.au or @fairsupply.com.
This is a pragmatic stop-gap that ships immediately and requires no schema changes. Trade-offs:
- False positives: a non-staff user with a Fair Supply email (rare, edge cases like partner accounts) would have their work hidden from non-admin viewers.
- False negatives: an FSAdmin with a non-FS email (e.g., a contractor with role granted but external mailbox) would not be filtered.
Phase 2 (planned) — persisted createdViaImpersonation flag
Tracked in FairSupply/platform#1122.
Replace the heuristic with a boolean property on Report and Engagement nodes, set at write time when the author's Auth0 user is operating in an account they do not have BELONGS_TO to (i.e., impersonation context). Push the filter down to the GraphQL where clause so the database does the work.
Why this is better:
- Semantically correct — captures the actual concern ("this came from impersonation") rather than a proxy (email domain).
- Stable — role changes and email changes don't drift the signal.
- Auditable — gives a direct query for "all impersonated work in this account".
- Performant — DB-side filter avoids over-fetch.
Backfill plan: use Phase 1 heuristic to backfill the flag on existing records during the migration.
The trust boundary
Use cases that apply this rule accept isAdmin: boolean as a required input. The caller (presentation layer) is responsible for deriving this from hasAdminRole() in apps/web/src/lib/auth.ts.
Passing isAdmin: true without verifying via hasAdminRole() is a security bug — equivalent to bypassing the filter. Use cases trust this input; they do not re-verify against Auth0 (that would require a domain-layer dependency on the Auth0 SDK, which is a Clean Architecture violation).
This mirrors the contract documented on ImpersonateAccountAsAdmin in packages/core/src/application/use-cases/account/impersonate-account-as-admin.ts.
Mixed-source orgs
An organisation that appears in both a client-authored engagement and an FSAdmin-authored report retains its risk contribution via the client engagement. orgRiskMap uses a "first writer wins" pattern (if (map.has(id)) continue) while iterating these sources:
orgRiskMap: engagement orgs → report orgs → account-scoped organisations
The client engagement seeds the org before the FSAdmin report is processed, so the report's risk data does not leak through to the non-admin viewer's metrics.
If an org appears only in FSAdmin-authored activity for a non-admin viewer, the org contributes nothing to supply-chain risk. The viewer sees a smaller, client-faithful supply-chain picture.
Adding new surfaces
When introducing a new use case that aggregates reports or engagements for analytical display:
- Add
isAdmin: booleanas a required input field. - Document the security contract on the field (see
GetAssessmentsInput.isAdminfor the canonical pattern). - Immediately after fetching reports/engagements, create
visibleReports/visibleEngagementsusing the heuristic:const visibleReports = input.isAdmin ? reports : reports.filter((r) => !isInternalUserEmail(r.createdBy[0]?.email)); - Use the filtered variants in every downstream consumer — rows, metrics, derived maps.
- Wire
isAdminthrough from the presentation layer usinghasAdminRole(). - Add a test covering the non-admin and admin paths.
Related
- Accounts Domain — covers Auth0 roles, sessions, and the impersonation mechanism.
GetAccountActivityinpackages/core/src/application/use-cases/account/get-account-activity.ts— reference implementation for the symmetric report + engagement filter.GetAssessmentsinpackages/core/src/application/use-cases/assessment/get-assessments.ts— reports-only mirror.