Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 78 additions & 22 deletions apps/lfx-one/src/app/shared/services/campaign.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SSEEvent<CampaignSSEEventType>> {
return this.sse.connect<CampaignSSEEventType>('/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';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Audit (non-blocking, pre-flag-flip): When selectedFoundation() is null, campaign requests proceed without foundationSlug. That is fine while MARKETING_ACCESS_ENFORCEMENT is off, but once enforcement is enabled every call here will 403. Before flipping the flag, consider failing fast client-side (or blocking calls until foundation context is set) so users get a clear UX rather than opaque 403s. Pair with LFXV2-2236 guards.

return this.sse.connect<CampaignSSEEventType>(url, { method: 'POST', body: request });
}

public refineBrief(request: CampaignBriefRefineRequest): Observable<SSEEvent<CampaignSSEEventType>> {
return this.sse.connect<CampaignSSEEventType>('/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<CampaignSSEEventType>(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<CampaignCreateResponse | null> {
Expand All @@ -70,58 +83,101 @@ export class CampaignService {
}

public getMonitorData(days: number = 30): Observable<CampaignMonitorResponse> {
return this.http.get<CampaignMonitorResponse>('/api/campaigns/monitor', { params: { days } });
const slug = this.foundationSlug;
return this.http.get<CampaignMonitorResponse>('/api/campaigns/monitor', {
params: { days, ...(slug && { foundationSlug: slug }) },
});
Comment thread
dealako marked this conversation as resolved.
}

public getLinkedInAccounts(): Observable<LinkedInAccount[]> {
return this.http.get<LinkedInAccount[]>('/api/campaigns/linkedin/accounts');
const slug = this.foundationSlug;
return this.http.get<LinkedInAccount[]>('/api/campaigns/linkedin/accounts', {
params: { ...(slug && { foundationSlug: slug }) },
});
}

public getLinkedInMonitorData(accountKey: string, days: number = 30): Observable<LinkedInMonitorResponse> {
return this.http.get<LinkedInMonitorResponse>('/api/campaigns/linkedin/monitor', { params: { days, accountKey } });
const slug = this.foundationSlug;
return this.http.get<LinkedInMonitorResponse>('/api/campaigns/linkedin/monitor', {
params: { days, accountKey, ...(slug && { foundationSlug: slug }) },
});
}

public getRedditAccounts(): Observable<RedditAccountOption[]> {
return this.http.get<RedditAccountOption[]>('/api/campaigns/reddit/accounts');
const slug = this.foundationSlug;
return this.http.get<RedditAccountOption[]>('/api/campaigns/reddit/accounts', {
params: { ...(slug && { foundationSlug: slug }) },
});
}

public getRedditMonitorData(accountKey: string, days: number = 30): Observable<RedditMonitorResponse> {
return this.http.get<RedditMonitorResponse>('/api/campaigns/reddit/monitor', { params: { days, accountKey } });
const slug = this.foundationSlug;
return this.http.get<RedditMonitorResponse>('/api/campaigns/reddit/monitor', {
params: { days, accountKey, ...(slug && { foundationSlug: slug }) },
});
}

public getMetaAccounts(): Observable<MetaAccountOption[]> {
return this.http.get<MetaAccountOption[]>('/api/campaigns/meta/accounts');
const slug = this.foundationSlug;
return this.http.get<MetaAccountOption[]>('/api/campaigns/meta/accounts', {
params: { ...(slug && { foundationSlug: slug }) },
});
}

public getMetaMonitorData(accountKey: string, days: number = 30): Observable<MetaMonitorResponse> {
return this.http.get<MetaMonitorResponse>('/api/campaigns/meta/monitor', { params: { days, accountKey } });
const slug = this.foundationSlug;
return this.http.get<MetaMonitorResponse>('/api/campaigns/meta/monitor', {
params: { days, accountKey, ...(slug && { foundationSlug: slug }) },
});
}

public getKeywords(days: number = 30): Observable<KeywordMetricsResponse> {
return this.http.get<KeywordMetricsResponse>('/api/campaigns/keywords', { params: { days } });
const slug = this.foundationSlug;
return this.http.get<KeywordMetricsResponse>('/api/campaigns/keywords', {
params: { days, ...(slug && { foundationSlug: slug }) },
});
}

public getAudience(days: number = 30): Observable<AudienceDemographics> {
return this.http.get<AudienceDemographics>('/api/campaigns/audience', { params: { days } });
const slug = this.foundationSlug;
return this.http.get<AudienceDemographics>('/api/campaigns/audience', {
params: { days, ...(slug && { foundationSlug: slug }) },
});
}

public lookupHubSpotUtm(eventName: string): Observable<HubSpotUtmLookupResult> {
return this.http.get<HubSpotUtmLookupResult>('/api/campaigns/hubspot/utm', { params: { event_name: eventName } });
const slug = this.foundationSlug;
return this.http.get<HubSpotUtmLookupResult>('/api/campaigns/hubspot/utm', {
params: { event_name: eventName, ...(slug && { foundationSlug: slug }) },
});
}
Comment thread
dealako marked this conversation as resolved.

public createHubSpotUtm(eventName: string): Observable<HubSpotUtmCreateResult> {
return this.http.post<HubSpotUtmCreateResult>('/api/campaigns/hubspot/utm/create', {}, { params: { event_name: eventName } });
const slug = this.foundationSlug;
return this.http.post<HubSpotUtmCreateResult>(
'/api/campaigns/hubspot/utm/create',
{},
{ params: { event_name: eventName, ...(slug && { foundationSlug: slug }) } }
);
}

public executeKeywordActions(request: BulkKeywordActionRequest): Observable<BulkKeywordActionResponse> {
return this.http.post<BulkKeywordActionResponse>('/api/campaigns/keywords/actions', request);
const slug = this.foundationSlug;
return this.http.post<BulkKeywordActionResponse>('/api/campaigns/keywords/actions', request, {
...(slug && { params: { foundationSlug: slug } }),
});
}

private pollJobStatus(jobId: string): Observable<CampaignJobStatus> {
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<CampaignJobStatus>(`/api/campaigns/jobs/${encodeURIComponent(jobId)}`)),
exhaustMap(() =>
this.http.get<CampaignJobStatus>(`/api/campaigns/jobs/${encodeURIComponent(jobId)}`, {
params: { ...(slug && { foundationSlug: slug }) },
})
),
takeWhile((status) => status.status === 'running', true)
);
}
Expand Down
5 changes: 3 additions & 2 deletions apps/lfx-one/src/server/controllers/project.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> => {
// 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<string, string> })[PROJECT_UID_CACHE] ?? {};
(req as Request & { [PROJECT_UID_CACHE]?: Record<string, string> })[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',
})
);
}
};
}
21 changes: 13 additions & 8 deletions apps/lfx-one/src/server/routes/analytics.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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));
Expand Down
Loading
Loading