Skip to content

feat(http-utils): add facsWrapper for hybrid FACS authorization#1717

Open
ravverma wants to merge 1 commit into
mainfrom
feat/http-utils-facs-wrapper
Open

feat(http-utils): add facsWrapper for hybrid FACS authorization#1717
ravverma wants to merge 1 commit into
mainfrom
feat/http-utils-facs-wrapper

Conversation

@ravverma

Copy link
Copy Markdown
Contributor

What

Adds the FACS authorization wrapper (facsWrapper) to spacecat-shared-http-utils, plus its supporting modules. This is the piece intentionally deferred from the minimal-scope shared PRs (#1711 / #1712) — it builds on the AuthInfo FACS helpers and constants already on main.

Changes (net-new, additive only)

  • src/auth/facs-wrapper.js — per-route FACS enforcement for external customer users; gated by a LaunchDarkly flag. Internal identities and Adobe internal orgs bypass.
  • src/auth/facs-resource-resolver.js — resolves the ReBAC resource for a request; defers to the controller when no resource is resolvable.
  • src/auth/facs-state-layer.jsnormalizeImsOrgId (canonicalises bare ident → <ident>@<authSrc>) and findFacsResourceBinding.
  • src/auth/route-utils.js — route-pattern matching helpers.
  • src/index.js / src/index.d.ts — barrel exports + facsWrapper type.

No changes to auth-info.js, constants.js, or handlers/ims.js — those shipped in #1712 and main's versions are preserved.

Testing

  • npm test -w packages/spacecat-shared-http-utils474 passing
  • Coverage: 100% lines / 99.12% branches (gate: 100% / 97%); all FACS files at 100%.
  • Lint clean.

🤖 Generated with Claude Code

Introduces the FACS authorization wrapper for the helix-shared-wrap
`.with()` chain, plus the supporting resource resolver, state-layer
helpers and route-pattern utilities. Enforces FACS permissions for
external customer users per route (gated by a LaunchDarkly flag);
internal identities and Adobe internal orgs bypass.

Net-new, additive only:
- src/auth/facs-wrapper.js
- src/auth/facs-resource-resolver.js
- src/auth/facs-state-layer.js (normalizeImsOrgId, findFacsResourceBinding)
- src/auth/route-utils.js
- barrel exports in index.js + index.d.ts type

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ravverma ravverma requested a review from MysticatBot June 24, 2026 12:11
tag: 'facs',
bypass: 'internal-identity',
method,
suffix,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (blocking): The LD flag evaluation hardcodes `${orgId}@AdobeOrg` via template literal. If getTenantIds() returns a pre-suffixed value (e.g. ACME@AdobeOrg), this produces ACME@AdobeOrg@AdobeOrg - the flag evaluates against a non-existent org, returns false, and FACS enforcement is silently bypassed.

Fix: normalizeImsOrgId(orgId) is already imported and used in step 7. Use it here too:

isFacsEnabled = await ldClient.isFlagEnabledForIMSOrg(flagKey, normalizeImsOrgId(orgId));

* @returns A wrapped handler.
*/
export function facsWrapper(
fn: Function,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (blocking): Record<string, string> does not match the actual shape. The real config is deeply nested (PRODUCTS_ROUTES, PRODUCTS_FACS_RESOURCE_PARAM_ALIASES, INTERNAL_ROUTES). TS consumers get no compile-time guidance - violates the repo rule that .d.ts files accurately describe public APIs.

@MysticatBot

Copy link
Copy Markdown

Verdict: Request changes | Complexity: HIGH (large diff; auth/IMS risk flag)

Adds facsWrapper for hybrid FACS authorization: JWT capability claims + state-layer grants (union model), per-product LaunchDarkly gating, and layered bypass rules.


Must fix

1. LaunchDarkly org-ID double-suffix risk

packages/spacecat-shared-http-utils/src/auth/facs-wrapper.js:225

The LD flag evaluation hardcodes `${orgId}@AdobeOrg` via template literal, while step 7 uses the idempotent normalizeImsOrgId(orgId) (which checks for @ before appending). If getTenantIds() returns a value already containing @AdobeOrg, this line produces ORG@AdobeOrg@AdobeOrg. The flag evaluation targets the wrong context key, LD returns "flag off" for the malformed key, and FACS enforcement is silently bypassed for that org.

Fix:

isFacsEnabled = await ldClient.isFlagEnabledForIMSOrg(flagKey, normalizeImsOrgId(orgId));

normalizeImsOrgId is already imported and used in step 7. Using it here aligns both paths and handles pre-suffixed inputs safely.


2. TypeScript declaration does not reflect actual routeFacsCapabilities shape

packages/spacecat-shared-http-utils/src/index.d.ts

The type Record<string, string> is flat and misleading. The actual runtime shape is a deeply nested structure. TypeScript consumers get no compilation error when passing the wrong shape; the error surfaces only at runtime. This violates the repo convention that .d.ts files accurately describe public APIs.

Fix:

interface FacsRouteCapabilities {
  INTERNAL_ROUTES?: string[];
  PRODUCTS_ROUTES: Record<string, Record<string, string>>;
  PRODUCTS_FACS_RESOURCE_PARAM_ALIASES?: Record<string, Record<string, string[]>>;
}

export function facsWrapper(
  fn: (request: Request, context: object) => Promise<Response>,
  opts: { routeFacsCapabilities: FacsRouteCapabilities },
): (request: Request, context: object) => Promise<Response>;

Should fix

3. Falsy resource IDs silently defer to controller

packages/spacecat-shared-http-utils/src/auth/facs-resource-resolver.js:131,143,155

The resolver uses if (value) to check whether a resource ID is present. JavaScript falsy values "", 0, false all fail this check. A request body with { brandId: 0 } or { brandId: "" } causes the resolver to return null, skipping state-layer enforcement entirely and deferring to the controller. If the controller assumes the wrapper already enforced FACS, this is an authorization bypass.

Fix: Replace if (value) with if (value != null) (allows 0/false through), or if (value !== null && value !== undefined && value !== '') if empty-string IDs are architecturally invalid.


4. Internal-org bypass has no org-ID normalization

packages/spacecat-shared-http-utils/src/auth/facs-wrapper.js:~166

parseFacsExceptionInternalOrgs compares raw orgId from getTenantIds()[0] against the env var set. If an operator copies the canonical form from the DB (e.g. 8C6043F15F43B6390A49401A@AdobeOrg) into FACS_EXCEPTION_INTERNAL_ORGS, the .has(orgId) check silently fails because orgId is the bare form. The org then hits the full FACS path instead of being bypassed.

Fix: Normalize both sides:

const normalizedOrgId = normalizeImsOrgId(orgId);
// Also normalize entries in parseFacsExceptionInternalOrgs:
return new Set(raw.split(',').map((s) => normalizeImsOrgId(s.trim())).filter(Boolean));

5. Sequential state-layer DB reads could be parallelized

packages/spacecat-shared-http-utils/src/auth/facs-wrapper.js:258-270

The user-scoped and org-scoped findFacsResourceBinding calls are sequential. Since they are independent queries, wrapping them in Promise.all would halve the p50 state-layer latency for the common case where both are needed.


Minor

  • parseFacsExceptionInternalOrgs re-parses the env var and allocates a new Set on every request. Consider moving parsing to wrapper construction time.
  • resolveRouteCapability uses truthiness (if (routeMap[exactKey])) while findMatchedRouteKey uses hasOwnProperty for the same map. The inconsistency is currently unreachable due to construction-time validation but would become a latent bug if the functions are reused outside facsWrapper.
  • normalizeImsOrgId and findFacsResourceBinding are exported from index.js but lack .d.ts declarations. TypeScript consumers get implicit any.

Note: CI checks are all passing (474 tests, 100% lines / 99.12% branches).

@MysticatBot MysticatBot added ai-reviewed Reviewed by AI complexity:high High complexity PR labels Jun 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-reviewed Reviewed by AI complexity:high High complexity PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants