-
Notifications
You must be signed in to change notification settings - Fork 5
feat(marketing): enforce fga access on marketing bff endpoints #1018
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
55cf5ce
feat(marketing): enforce FGA access on marketing BFF endpoints
dealako a08209d
fix(review): use err field for error objects in warning logs
dealako 90dbc73
fix(review): clarify docs and add audit-logging intent comment
dealako 9f49528
fix(review): improve comment clarity for audit boundary and FGA invar…
dealako 309b675
style(marketing): apply prettier formatting to campaign service
dealako db7e0b9
fix(marketing): address multi-tool review findings
dealako 9b0fe7f
fix(review): address PR #1018 review feedback
dealako b0fec69
style(marketing): fix prettier formatting in access-check service
dealako bb287b8
docs(marketing): fix stale JSDoc after review feedback on PR #1018
dealako 18f4527
Merge branch 'main' into feat/LFXV2-2235
dealako 2232faa
Merge branch 'main' into feat/LFXV2-2235
dealako 70620bf
fix(marketing): address PR #1018 review feedback — type safety and co…
dealako File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
153 changes: 153 additions & 0 deletions
153
apps/lfx-one/src/server/middleware/require-project-access.middleware.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
| }) | ||
| ); | ||
| } | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 withoutfoundationSlug. That is fine whileMARKETING_ACCESS_ENFORCEMENTis 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.