diff --git a/apps/lfx-one/src/app/shared/services/campaign.service.ts b/apps/lfx-one/src/app/shared/services/campaign.service.ts index 0770b8c95..8a610ecab 100644 --- a/apps/lfx-one/src/app/shared/services/campaign.service.ts +++ b/apps/lfx-one/src/app/shared/services/campaign.service.ts @@ -28,29 +28,42 @@ import { } from '@lfx-one/shared/interfaces'; import { exhaustMap, last, map, Observable, of, take, takeWhile, timer } from 'rxjs'; +import { ProjectContextService } from './project-context.service'; import { SseService } from './sse.service'; @Injectable({ providedIn: 'root' }) export class CampaignService { private readonly http = inject(HttpClient); private readonly sse = inject(SseService); + private readonly projectContextService = inject(ProjectContextService); + + /** + * Returns the `foundationSlug` of the currently selected foundation. + * Sent on every campaign request so the server can resolve the foundation + * project and enforce the campaign_viewer / campaign_manager FGA relation. + * See LFXV2-2235. + */ + private get foundationSlug(): string | undefined { + return this.projectContextService.selectedFoundation()?.slug ?? undefined; + } public generateBrief(request: CampaignBriefRequest): Observable> { - return this.sse.connect('/api/campaigns/brief/generate', { - method: 'POST', - body: request, - }); + const slug = this.foundationSlug; + const url = slug ? `/api/campaigns/brief/generate?foundationSlug=${encodeURIComponent(slug)}` : '/api/campaigns/brief/generate'; + return this.sse.connect(url, { method: 'POST', body: request }); } public refineBrief(request: CampaignBriefRefineRequest): Observable> { - return this.sse.connect('/api/campaigns/brief/refine', { - method: 'POST', - body: request, - }); + const slug = this.foundationSlug; + const url = slug ? `/api/campaigns/brief/refine?foundationSlug=${encodeURIComponent(slug)}` : '/api/campaigns/brief/refine'; + return this.sse.connect(url, { method: 'POST', body: request }); } public createCampaign(request: CampaignCreateRequest): Observable<{ jobId: string; result?: CampaignCreateResponse; error?: string }> { - return this.http.post<{ jobId: string; result?: CampaignCreateResponse; error?: string }>('/api/campaigns/create', request); + const slug = this.foundationSlug; + return this.http.post<{ jobId: string; result?: CampaignCreateResponse; error?: string }>('/api/campaigns/create', request, { + ...(slug && { params: { foundationSlug: slug } }), + }); } public getCreateResult(jobId: string): Observable { @@ -70,58 +83,101 @@ export class CampaignService { } public getMonitorData(days: number = 30): Observable { - return this.http.get('/api/campaigns/monitor', { params: { days } }); + const slug = this.foundationSlug; + return this.http.get('/api/campaigns/monitor', { + params: { days, ...(slug && { foundationSlug: slug }) }, + }); } public getLinkedInAccounts(): Observable { - return this.http.get('/api/campaigns/linkedin/accounts'); + const slug = this.foundationSlug; + return this.http.get('/api/campaigns/linkedin/accounts', { + params: { ...(slug && { foundationSlug: slug }) }, + }); } public getLinkedInMonitorData(accountKey: string, days: number = 30): Observable { - return this.http.get('/api/campaigns/linkedin/monitor', { params: { days, accountKey } }); + const slug = this.foundationSlug; + return this.http.get('/api/campaigns/linkedin/monitor', { + params: { days, accountKey, ...(slug && { foundationSlug: slug }) }, + }); } public getRedditAccounts(): Observable { - return this.http.get('/api/campaigns/reddit/accounts'); + const slug = this.foundationSlug; + return this.http.get('/api/campaigns/reddit/accounts', { + params: { ...(slug && { foundationSlug: slug }) }, + }); } public getRedditMonitorData(accountKey: string, days: number = 30): Observable { - return this.http.get('/api/campaigns/reddit/monitor', { params: { days, accountKey } }); + const slug = this.foundationSlug; + return this.http.get('/api/campaigns/reddit/monitor', { + params: { days, accountKey, ...(slug && { foundationSlug: slug }) }, + }); } public getMetaAccounts(): Observable { - return this.http.get('/api/campaigns/meta/accounts'); + const slug = this.foundationSlug; + return this.http.get('/api/campaigns/meta/accounts', { + params: { ...(slug && { foundationSlug: slug }) }, + }); } public getMetaMonitorData(accountKey: string, days: number = 30): Observable { - return this.http.get('/api/campaigns/meta/monitor', { params: { days, accountKey } }); + const slug = this.foundationSlug; + return this.http.get('/api/campaigns/meta/monitor', { + params: { days, accountKey, ...(slug && { foundationSlug: slug }) }, + }); } public getKeywords(days: number = 30): Observable { - return this.http.get('/api/campaigns/keywords', { params: { days } }); + const slug = this.foundationSlug; + return this.http.get('/api/campaigns/keywords', { + params: { days, ...(slug && { foundationSlug: slug }) }, + }); } public getAudience(days: number = 30): Observable { - return this.http.get('/api/campaigns/audience', { params: { days } }); + const slug = this.foundationSlug; + return this.http.get('/api/campaigns/audience', { + params: { days, ...(slug && { foundationSlug: slug }) }, + }); } public lookupHubSpotUtm(eventName: string): Observable { - return this.http.get('/api/campaigns/hubspot/utm', { params: { event_name: eventName } }); + const slug = this.foundationSlug; + return this.http.get('/api/campaigns/hubspot/utm', { + params: { event_name: eventName, ...(slug && { foundationSlug: slug }) }, + }); } public createHubSpotUtm(eventName: string): Observable { - return this.http.post('/api/campaigns/hubspot/utm/create', {}, { params: { event_name: eventName } }); + const slug = this.foundationSlug; + return this.http.post( + '/api/campaigns/hubspot/utm/create', + {}, + { params: { event_name: eventName, ...(slug && { foundationSlug: slug }) } } + ); } public executeKeywordActions(request: BulkKeywordActionRequest): Observable { - return this.http.post('/api/campaigns/keywords/actions', request); + const slug = this.foundationSlug; + return this.http.post('/api/campaigns/keywords/actions', request, { + ...(slug && { params: { foundationSlug: slug } }), + }); } private pollJobStatus(jobId: string): Observable { const maxPolls = Math.ceil(300_000 / CAMPAIGN_JOB_POLL_INTERVAL_MS); + const slug = this.foundationSlug; return timer(0, CAMPAIGN_JOB_POLL_INTERVAL_MS).pipe( take(maxPolls), - exhaustMap(() => this.http.get(`/api/campaigns/jobs/${encodeURIComponent(jobId)}`)), + exhaustMap(() => + this.http.get(`/api/campaigns/jobs/${encodeURIComponent(jobId)}`, { + params: { ...(slug && { foundationSlug: slug }) }, + }) + ), takeWhile((status) => status.status === 'running', true) ); } diff --git a/apps/lfx-one/src/server/controllers/project.controller.ts b/apps/lfx-one/src/server/controllers/project.controller.ts index 1d0a8a606..531af9ac3 100644 --- a/apps/lfx-one/src/server/controllers/project.controller.ts +++ b/apps/lfx-one/src/server/controllers/project.controller.ts @@ -111,17 +111,18 @@ export class ProjectController { } const includeMeetingCoordinator = req.query['meeting_coordinator'] === 'true'; + const includeMarketingAccess = req.query['marketing_access'] === 'true'; // Check if slug is a uuid if (isUuid(slug)) { // If the slug is a uuid, get the project by id - const project = await this.projectService.getProjectById(req, slug, true, includeMeetingCoordinator); + const project = await this.projectService.getProjectById(req, slug, true, includeMeetingCoordinator, includeMarketingAccess); res.json(project); return; } // If the slug is not a uuid, get the project by slug - const project = await this.projectService.getProjectBySlug(req, slug, includeMeetingCoordinator); + const project = await this.projectService.getProjectBySlug(req, slug, includeMeetingCoordinator, includeMarketingAccess); // Log the success logger.success(req, 'get_project_by_slug', startTime, { diff --git a/apps/lfx-one/src/server/middleware/require-project-access.middleware.ts b/apps/lfx-one/src/server/middleware/require-project-access.middleware.ts new file mode 100644 index 000000000..d1bdf01cb --- /dev/null +++ b/apps/lfx-one/src/server/middleware/require-project-access.middleware.ts @@ -0,0 +1,153 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { AccessCheckAccessType } from '@lfx-one/shared/interfaces'; +import { NextFunction, Request, RequestHandler, Response } from 'express'; + +import { AuthorizationError } from '../errors'; +import { logger } from '../services/logger.service'; +import { AccessCheckService } from '../services/access-check.service'; +import { ProjectService } from '../services/project.service'; + +/** + * SLUG_PATTERN matches valid foundation/project slugs (lowercase alphanumeric and hyphens). + * Must match the pattern enforced in analytics.controller.ts. + */ +const SLUG_PATTERN = /^[a-z0-9-]+$/; + +/** + * Symbol used to cache slug→UID lookups on the request object. + * Prevents redundant NATS calls when multiple requireProjectAccess middleware + * instances are stacked on the same route (e.g. reader + writer checks). + */ +const PROJECT_UID_CACHE = Symbol('projectUidCache'); + +/** + * Factory that returns an Express middleware enforcing an FGA relation on the + * foundation identified by `foundationSlug` in the request query string. + * + * Gate logic (all fail-closed): + * 1. Read `foundationSlug` from `req.query`; validate format. + * 2. Resolve slug → project UID via NATS (result cached on `req` to avoid + * redundant NATS calls when multiple middleware instances are stacked on + * the same route — not shared across concurrent requests). + * 3. Check the FGA relation via `/access-check` on LFX_V2_SERVICE. + * 4. Call `next()` on success; `next(AuthorizationError)` on any failure. + * + * Rollout flag: the enforcement is **disabled by default** until tuples are + * confirmed across all foundations. Set the environment variable + * `MARKETING_ACCESS_ENFORCEMENT=true` to activate. + * + * @param relation The FGA relation to enforce (e.g. 'marketing_dashboard_viewer'). + */ +export function requireProjectAccess(relation: AccessCheckAccessType): RequestHandler { + const accessCheckService = new AccessCheckService(); + const projectService = new ProjectService(); + + return async (req: Request, _res: Response, next: NextFunction): Promise => { + // Rollout gate — deploy dark; enable once tuples are confirmed. + if (process.env['MARKETING_ACCESS_ENFORCEMENT'] !== 'true') { + next(); + return; + } + + const operation = `require_project_access_${relation}`; + + try { + // Authorization denials are security-relevant audit events that warrant a + // dedicated WARN log entry before apiErrorHandler processes the AuthorizationError. + // This middleware is a security boundary, not a business-logic controller — the + // no-pre-next-error-logging rule applies to controllers where apiErrorHandler is + // the sole audit surface; here the denial itself is the auditable event. + const foundationSlug = req.query['foundationSlug']; + + if (!foundationSlug || typeof foundationSlug !== 'string' || !SLUG_PATTERN.test(foundationSlug)) { + logger.warning(req, operation, 'Missing or invalid foundationSlug — denying access', { + path: req.path, + relation, + foundation_slug: typeof foundationSlug === 'string' ? foundationSlug : '[invalid type]', + }); + next( + new AuthorizationError('Insufficient permissions for this resource', { + operation, + service: 'authorization', + path: req.path, + code: 'MARKETING_ACCESS_REQUIRED', + }) + ); + return; + } + + // Resolve slug → UID; cache on req to avoid N+1 NATS calls. + const cache = (req as Request & { [PROJECT_UID_CACHE]?: Record })[PROJECT_UID_CACHE] ?? {}; + (req as Request & { [PROJECT_UID_CACHE]?: Record })[PROJECT_UID_CACHE] = cache; + + let projectUid = cache[foundationSlug]; + + if (!projectUid) { + const natsResult = await projectService.getProjectIdBySlug(req, foundationSlug); + + if (!natsResult.exists || !natsResult.uid) { + logger.warning(req, operation, 'Foundation slug could not be resolved — denying access', { + path: req.path, + relation, + foundation_slug: foundationSlug, + }); + next( + new AuthorizationError('Insufficient permissions for this resource', { + operation, + service: 'authorization', + path: req.path, + code: 'MARKETING_ACCESS_REQUIRED', + }) + ); + return; + } + + projectUid = natsResult.uid; + cache[foundationSlug] = projectUid; + } + + const hasAccess = await accessCheckService.checkSingleAccess(req, { + resource: 'project', + id: projectUid, + access: relation, + }); + + if (!hasAccess) { + logger.warning(req, operation, 'FGA check did not grant marketing access (denied or upstream failure)', { + path: req.path, + relation, + foundation_slug: foundationSlug, + project_uid: projectUid, + }); + next( + new AuthorizationError('Insufficient permissions for this resource', { + operation, + service: 'authorization', + path: req.path, + code: 'MARKETING_ACCESS_REQUIRED', + }) + ); + return; + } + + next(); + } catch (error) { + // Fail-closed: any unexpected error denies access rather than permitting it. + logger.warning(req, operation, 'Marketing access check threw unexpectedly — denying access', { + path: req.path, + relation, + err: error, + }); + next( + new AuthorizationError('Marketing access check failed', { + operation, + service: 'authorization', + path: req.path, + code: 'MARKETING_ACCESS_REQUIRED', + }) + ); + } + }; +} diff --git a/apps/lfx-one/src/server/routes/analytics.route.ts b/apps/lfx-one/src/server/routes/analytics.route.ts index c64a16b12..8561d3950 100644 --- a/apps/lfx-one/src/server/routes/analytics.route.ts +++ b/apps/lfx-one/src/server/routes/analytics.route.ts @@ -4,6 +4,7 @@ import { Router } from 'express'; import { AnalyticsController } from '../controllers/analytics.controller'; +import { requireProjectAccess } from '../middleware/require-project-access.middleware'; const router = Router(); @@ -137,21 +138,25 @@ router.get('/org-involvement-event-attendance-monthly', (req, res, next) => anal router.get('/org-involvement-certified-employees-monthly', (req, res, next) => analyticsController.orgCertifiedEmployeesMonthly(req, res, next)); router.get('/org-involvement-training-enrollments', (req, res, next) => analyticsController.orgTrainingEnrollments(req, res, next)); +// Marketing Impact endpoints — require marketing_dashboard_viewer FGA relation. +// See LFXV2-2235. Enforcement is flag-gated (MARKETING_ACCESS_ENFORCEMENT=true). +const requireMarketingDashboardViewer = requireProjectAccess('marketing_dashboard_viewer'); + // Web activities summary endpoint (marketing dashboard) -router.get('/web-activities-summary', (req, res, next) => analyticsController.getWebActivitiesSummary(req, res, next)); +router.get('/web-activities-summary', requireMarketingDashboardViewer, (req, res, next) => analyticsController.getWebActivitiesSummary(req, res, next)); // Email CTR endpoint (marketing dashboard) -router.get('/email-ctr', (req, res, next) => analyticsController.getEmailCtr(req, res, next)); +router.get('/email-ctr', requireMarketingDashboardViewer, (req, res, next) => analyticsController.getEmailCtr(req, res, next)); // Social reach endpoint (marketing dashboard) -router.get('/social-reach', (req, res, next) => analyticsController.getSocialReach(req, res, next)); +router.get('/social-reach', requireMarketingDashboardViewer, (req, res, next) => analyticsController.getSocialReach(req, res, next)); // Keyword performance endpoint (marketing dashboard) -router.get('/keyword-performance', (req, res, next) => analyticsController.getKeywordPerformance(req, res, next)); +router.get('/keyword-performance', requireMarketingDashboardViewer, (req, res, next) => analyticsController.getKeywordPerformance(req, res, next)); // Social media endpoints (marketing dashboard) -router.get('/social-media', (req, res, next) => analyticsController.getSocialMedia(req, res, next)); -router.get('/social-media/monthly', (req, res, next) => analyticsController.getSocialMediaMonthly(req, res, next)); +router.get('/social-media', requireMarketingDashboardViewer, (req, res, next) => analyticsController.getSocialMedia(req, res, next)); +router.get('/social-media/monthly', requireMarketingDashboardViewer, (req, res, next) => analyticsController.getSocialMediaMonthly(req, res, next)); // North Star metrics endpoints (executive director dashboard) router.get('/member-retention', (req, res, next) => analyticsController.getMemberRetention(req, res, next)); @@ -187,8 +192,8 @@ router.get('/board-meeting-participation-summary', (req, res, next) => analytics router.get('/event-growth', (req, res, next) => analyticsController.getEventGrowth(req, res, next)); router.get('/brand-reach', (req, res, next) => analyticsController.getBrandReach(req, res, next)); router.get('/brand-health', (req, res, next) => analyticsController.getBrandHealth(req, res, next)); -router.get('/revenue-impact', (req, res, next) => analyticsController.getRevenueImpact(req, res, next)); -router.get('/marketing-attribution', (req, res, next) => analyticsController.getMarketingAttribution(req, res, next)); +router.get('/revenue-impact', requireMarketingDashboardViewer, (req, res, next) => analyticsController.getRevenueImpact(req, res, next)); +router.get('/marketing-attribution', requireMarketingDashboardViewer, (req, res, next) => analyticsController.getMarketingAttribution(req, res, next)); // Multi-foundation summary endpoint (multi-foundation dashboard) router.get('/multi-foundation-summary', (req, res, next) => analyticsController.getMultiFoundationSummary(req, res, next)); diff --git a/apps/lfx-one/src/server/routes/campaigns.route.ts b/apps/lfx-one/src/server/routes/campaigns.route.ts index 5ca00ac9a..338aad1f3 100644 --- a/apps/lfx-one/src/server/routes/campaigns.route.ts +++ b/apps/lfx-one/src/server/routes/campaigns.route.ts @@ -4,26 +4,42 @@ import { Router } from 'express'; import { CampaignController } from '../controllers/campaign.controller'; +import { requireProjectAccess } from '../middleware/require-project-access.middleware'; const router = Router(); const campaignController = new CampaignController(); -router.post('/brief/generate', (req, res, next) => campaignController.generateBrief(req, res, next)); -router.post('/brief/refine', (req, res, next) => campaignController.refineBrief(req, res, next)); -router.post('/create', (req, res, next) => campaignController.createCampaign(req, res, next)); -router.get('/jobs/:jobId', (req, res, next) => campaignController.getJobStatus(req, res, next)); -router.get('/hubspot/utm', (req, res, next) => campaignController.lookupHubSpotUtm(req, res, next)); -router.post('/hubspot/utm/create', (req, res, next) => campaignController.createHubSpotUtm(req, res, next)); -router.get('/monitor', (req, res, next) => campaignController.getMonitorData(req, res, next)); -router.get('/linkedin/accounts', (req, res) => campaignController.getLinkedInAccounts(req, res)); -router.get('/linkedin/monitor', (req, res, next) => campaignController.getLinkedInMonitor(req, res, next)); -router.get('/reddit/accounts', (req, res) => campaignController.getRedditAccounts(req, res)); -router.get('/reddit/monitor', (req, res, next) => campaignController.getRedditMonitor(req, res, next)); -router.get('/meta/accounts', (req, res) => campaignController.getMetaAccounts(req, res)); -router.get('/meta/monitor', (req, res, next) => campaignController.getMetaMonitor(req, res, next)); -router.get('/keywords', (req, res, next) => campaignController.getKeywords(req, res, next)); -router.get('/audience', (req, res, next) => campaignController.getAudience(req, res, next)); -router.post('/keywords/actions', (req, res, next) => campaignController.executeKeywordActions(req, res, next)); -router.patch('/:campaignId/status', (req, res, next) => campaignController.updateCampaignStatus(req, res, next)); +// Campaign access enforcement — gated by FGA relation on the selected foundation. +// See LFXV2-2235. Enforcement is flag-gated (MARKETING_ACCESS_ENFORCEMENT=true). +// All routes require foundationSlug (sent by campaign.service.ts via LFXV2-2235). +// +// FGA model 14.1.0 (LFXV2-1760): both campaign_viewer and campaign_manager +// resolve to `executive_director or marketing_ops`, so every campaign_manager +// user also holds campaign_viewer. The viewer/manager split is semantic +// (future-proofing) and does not create a case where a manager can create but +// not poll. If the FGA model changes, revisit this split. +const requireCampaignViewer = requireProjectAccess('campaign_viewer'); +const requireCampaignManager = requireProjectAccess('campaign_manager'); + +// Write operations — require campaign_manager +router.post('/brief/generate', requireCampaignManager, (req, res, next) => campaignController.generateBrief(req, res, next)); +router.post('/brief/refine', requireCampaignManager, (req, res, next) => campaignController.refineBrief(req, res, next)); +router.post('/create', requireCampaignManager, (req, res, next) => campaignController.createCampaign(req, res, next)); +router.post('/hubspot/utm/create', requireCampaignManager, (req, res, next) => campaignController.createHubSpotUtm(req, res, next)); +router.post('/keywords/actions', requireCampaignManager, (req, res, next) => campaignController.executeKeywordActions(req, res, next)); +router.patch('/:campaignId/status', requireCampaignManager, (req, res, next) => campaignController.updateCampaignStatus(req, res, next)); + +// Read operations — require campaign_viewer +router.get('/jobs/:jobId', requireCampaignViewer, (req, res, next) => campaignController.getJobStatus(req, res, next)); +router.get('/hubspot/utm', requireCampaignViewer, (req, res, next) => campaignController.lookupHubSpotUtm(req, res, next)); +router.get('/monitor', requireCampaignViewer, (req, res, next) => campaignController.getMonitorData(req, res, next)); +router.get('/linkedin/accounts', requireCampaignViewer, (req, res) => campaignController.getLinkedInAccounts(req, res)); +router.get('/linkedin/monitor', requireCampaignViewer, (req, res, next) => campaignController.getLinkedInMonitor(req, res, next)); +router.get('/reddit/accounts', requireCampaignViewer, (req, res) => campaignController.getRedditAccounts(req, res)); +router.get('/reddit/monitor', requireCampaignViewer, (req, res, next) => campaignController.getRedditMonitor(req, res, next)); +router.get('/meta/accounts', requireCampaignViewer, (req, res) => campaignController.getMetaAccounts(req, res)); +router.get('/meta/monitor', requireCampaignViewer, (req, res, next) => campaignController.getMetaMonitor(req, res, next)); +router.get('/keywords', requireCampaignViewer, (req, res, next) => campaignController.getKeywords(req, res, next)); +router.get('/audience', requireCampaignViewer, (req, res, next) => campaignController.getAudience(req, res, next)); export default router; diff --git a/apps/lfx-one/src/server/services/access-check.service.ts b/apps/lfx-one/src/server/services/access-check.service.ts index 5272aa78e..38ea77749 100644 --- a/apps/lfx-one/src/server/services/access-check.service.ts +++ b/apps/lfx-one/src/server/services/access-check.service.ts @@ -118,6 +118,92 @@ export class AccessCheckService { return results.get(resource.id) || false; } + /** + * Check multiple access types on a single resource in one API call. + * + * Unlike {@link checkAccess} — which maps its results by `resource.id` only, + * causing multiple access types for the same ID to collide (last-write-wins) — + * this method operates on a single ID and returns a `Record` keyed by + * `AccessCheckAccessType`, so every relation is preserved independently. + * + * Results are matched positionally to `accessTypes` (same order as the upstream + * `/access-check` response), then stored as `record[accessType] = hasAccess`. + * Throws on upstream error or result-count mismatch — callers must catch and + * decide how to fail (e.g. omit probe fields, return `undefined`, or deny). + * + * @param req Express request object with auth context + * @param resourceType Resource type (e.g. 'project') + * @param id Resource unique identifier + * @param accessTypes Array of access types to check + * @returns Record mapping each access type to its boolean result + */ + public async checkMultipleAccess( + req: Request, + resourceType: AccessCheckResourceType, + id: string, + accessTypes: T[] + ): Promise> { + const operationName = `check_multiple_access_${resourceType}`; + const startTime = logger.startOperation(req, operationName, { + resource_type: resourceType, + resource_id: id, + access_types: accessTypes, + }); + + const fallback = Object.fromEntries(accessTypes.map((a) => [a, false])) as Record; + + if (accessTypes.length === 0) { + return fallback; + } + + try { + const apiRequests = accessTypes.map((access) => `${resourceType}:${id}#${access}`); + const requestPayload: AccessCheckApiRequest = { requests: apiRequests }; + + const response = await this.microserviceProxy.proxyRequest( + req, + 'LFX_V2_SERVICE', + '/access-check', + 'POST', + undefined, + requestPayload + ); + + if (response.results.length !== accessTypes.length) { + throw new Error(`access-check result count mismatch: expected ${accessTypes.length}, received ${response.results.length}`); + } + + const result = { ...fallback }; + + for (let i = 0; i < accessTypes.length; i++) { + const access = accessTypes[i]; + const resultString = response.results[i]; + let hasAccess = false; + + if (resultString && typeof resultString === 'string') { + const parts = resultString.split('\t'); + if (parts.length >= 2) { + hasAccess = parts[1]?.toLowerCase() === 'true'; + } + } + + result[access] = hasAccess; + } + + logger.success(req, operationName, startTime, { + resource_id: id, + granted: accessTypes.filter((a) => result[a]), + }); + + return result; + } catch (error) { + logger.error(req, operationName, startTime, error, { + resource_id: id, + }); + throw error; + } + } + /** * Add writer access field to multiple resources automatically * @param req Express request object with auth context diff --git a/apps/lfx-one/src/server/services/project.service.ts b/apps/lfx-one/src/server/services/project.service.ts index 97c0aff30..6217337fd 100644 --- a/apps/lfx-one/src/server/services/project.service.ts +++ b/apps/lfx-one/src/server/services/project.service.ts @@ -290,7 +290,13 @@ export class ProjectService { /** * Fetches a single project by ID */ - public async getProjectById(req: Request, uid: string, access: boolean = true, includeMeetingCoordinator: boolean = false): Promise { + public async getProjectById( + req: Request, + uid: string, + access: boolean = true, + includeMeetingCoordinator: boolean = false, + includeMarketingAccess: boolean = false + ): Promise { const project = await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/projects/${uid}`, 'GET'); if (!project) { @@ -307,28 +313,57 @@ export class ProjectService { // meeting_coordinator, so the extra round trip can't change the outcome. // Return the field as undefined (omitted) rather than false — false would be a // false-negative assertion since the role was never actually checked for writers. - if (writerProject.writer) { + if (writerProject.writer && !includeMeetingCoordinator && !includeMarketingAccess) { return writerProject; } + + let result: Project = writerProject; + // Only run the meeting_coordinator FGA check when the caller explicitly requests it. // This field is consumed by exactly one branch of writer.guard.ts; running it on every // GET /api/projects/:slug call would add a second sequential access-check round-trip // for all non-writer callers (guards, components, etc.) that never read the field. - if (!includeMeetingCoordinator) { - return writerProject; + if (includeMeetingCoordinator && !writerProject.writer) { + const isMeetingCoordinator = await this.accessCheckService + .checkSingleAccess(req, { resource: 'project', id: project.uid, access: 'meeting_coordinator' }) + .catch((error) => { + logger.warning(req, 'get_project_by_id', 'meeting coordinator check failed, skipping field', { + project_uid: project.uid, + err: error, + }); + // Return undefined rather than false — false implies the check ran clean and found no + // role; undefined preserves the "unknown" semantics documented on Project.meetingCoordinator. + return undefined; + }); + result = { ...result, meetingCoordinator: isMeetingCoordinator }; } - const isMeetingCoordinator = await this.accessCheckService - .checkSingleAccess(req, { resource: 'project', id: project.uid, access: 'meeting_coordinator' }) - .catch((error) => { - logger.warning(req, 'get_project_by_id', 'meeting coordinator check failed, skipping field', { - project_uid: project.uid, - error: error instanceof Error ? error.message : String(error), + + // Marketing access probe — checked independently of writer status because the + // marketing_* FGA relations are separate from the project writer relation. + // Only populated when explicitly requested (opt-in) to avoid extra round-trips + // on every project fetch. Consumed by LFXV2-2236 Angular marketing guards. + if (includeMarketingAccess) { + const marketingResults = await this.accessCheckService + .checkMultipleAccess(req, 'project', project.uid, ['marketing_dashboard_viewer', 'campaign_viewer', 'campaign_manager']) + .catch((error) => { + logger.warning(req, 'get_project_by_id', 'marketing access check failed, skipping fields', { + project_uid: project.uid, + err: error, + }); + return undefined; }); - // Return undefined rather than false — false implies the check ran clean and found no - // role; undefined preserves the "unknown" semantics documented on Project.meetingCoordinator. - return undefined; - }); - return { ...writerProject, meetingCoordinator: isMeetingCoordinator }; + + if (marketingResults) { + result = { + ...result, + marketingDashboardViewer: marketingResults['marketing_dashboard_viewer'], + campaignViewer: marketingResults['campaign_viewer'], + campaignManager: marketingResults['campaign_manager'], + }; + } + } + + return result; } return project; @@ -404,7 +439,12 @@ export class ProjectService { * Fetches a single project by slug using NATS for slug resolution * First resolves slug to ID via NATS, then fetches project data */ - public async getProjectBySlug(req: Request, projectSlug: string, includeMeetingCoordinator: boolean = false): Promise { + public async getProjectBySlug( + req: Request, + projectSlug: string, + includeMeetingCoordinator: boolean = false, + includeMarketingAccess: boolean = false + ): Promise { const natsResult = await this.getProjectIdBySlug(req, projectSlug); if (!natsResult.exists || !natsResult.uid) { @@ -416,7 +456,7 @@ export class ProjectService { } // Now fetch the project using the resolved ID - return this.getProjectById(req, natsResult.uid, true, includeMeetingCoordinator); + return this.getProjectById(req, natsResult.uid, true, includeMeetingCoordinator, includeMarketingAccess); } public async getProjectSettings(req: Request, uid: string): Promise { diff --git a/docs/user/dashboards/index.md b/docs/user/dashboards/index.md index 404dd18b2..d751175d0 100644 --- a/docs/user/dashboards/index.md +++ b/docs/user/dashboards/index.md @@ -9,7 +9,8 @@ last_updated: 2026-06-22 intercom_collection: Dashboards --- -The Dashboard is the central starting point of LFX Self Serve. It adapts to show you relevant information based on who you are (your persona) and what you are viewing (your lens). +The Dashboard is the central starting point of LFX Self Serve. It adapts to show you relevant information based on who you are (your persona) and what you are +viewing (your lens). ## Lens system diff --git a/packages/shared/src/interfaces/access-check.interface.ts b/packages/shared/src/interfaces/access-check.interface.ts index 596bfd323..3fb526540 100644 --- a/packages/shared/src/interfaces/access-check.interface.ts +++ b/packages/shared/src/interfaces/access-check.interface.ts @@ -42,4 +42,11 @@ export type AccessCheckResourceType = | 'groupsio_service' | 'groupsio_mailing_list' | 'groupsio_member'; -export type AccessCheckAccessType = 'writer' | 'viewer' | 'organizer' | 'meeting_coordinator'; +export type AccessCheckAccessType = + | 'writer' + | 'viewer' + | 'organizer' + | 'meeting_coordinator' + | 'marketing_dashboard_viewer' + | 'campaign_viewer' + | 'campaign_manager'; diff --git a/packages/shared/src/interfaces/project.interface.ts b/packages/shared/src/interfaces/project.interface.ts index 7eee2d7d4..063b37e4e 100644 --- a/packages/shared/src/interfaces/project.interface.ts +++ b/packages/shared/src/interfaces/project.interface.ts @@ -21,6 +21,21 @@ export interface Project { * transiently. Do NOT treat as a definitive role denial. */ meetingCoordinator?: boolean; + /** + * Response-only marketing access probe fields — present only when the caller + * requested marketing access checks (`?marketing_access=true`). + * + * Each follows the same semantics as `meetingCoordinator`: + * - `true` — FGA check ran and user holds the relation. + * - `false` — FGA check ran; user does not hold the relation. + * - `undefined` — check was not requested or failed transiently. + * + * These are consumed by the Angular marketing guards (LFXV2-2236). + * FGA-backed — no ED-persona shortcut. + */ + marketingDashboardViewer?: boolean; + campaignViewer?: boolean; + campaignManager?: boolean; public: boolean; parent_uid: string; stage: ProjectStage | string;