Skip to content

feat : integrate mac facs capabilities#2433

Draft
ravverma wants to merge 9 commits into
mainfrom
feat/mac-facs-capabilities
Draft

feat : integrate mac facs capabilities#2433
ravverma wants to merge 9 commits into
mainfrom
feat/mac-facs-capabilities

Conversation

@ravverma
Copy link
Copy Markdown
Contributor

@ravverma ravverma commented May 18, 2026

Summary

Implements the api-service side of Phase 1 of the MAC State Layer design (platform/decisions/mac-state-layer.md) — externalising LLMO access decisions to FACS while keeping all existing auth paths working unchanged.

Three things ship here:

  1. src/routes/facs-capabilities.js — new file. The route → permission map that facsWrapper enforces against the JWT's facs_permissions claim.

  2. src/index.js — wires facsWrapper from @adobe/spacecat-shared-http-utils as the innermost wrapper in the chain (runs after readOnlyAdminWrapper).

  3. test/routes/facs-capabilities.test.js — pins the shape contract and the coverage invariant against src/routes/index.js.

Capability model

The map departs from the original flat route → action proposal in favour of a per-product, full-permission-string shape. Each product (LLMO, ASO, ACO) authors its MAC policy independently with its own role and action vocabulary, so a runtime <product>/<action> composition would force every product into LLMO's vocabulary or duplicate maps anyway. Top-level shape:

{
  INTERNAL_ROUTES: ['METHOD /path', ...],            // bypass FACS entirely
  PRODUCTS_ROUTES: {
    LLMO: { 'METHOD /path': 'llmo/can_*', ... },     // values are FULL permissions
    ASO:  { },                                       // TBD pending MAC policy
    ACO:  { },                                       // TBD pending MAC policy
  },
}
  • INTERNAL_ROUTES (55 routes) — admin-only / S2S-only / restricted / pure infrastructure surfaces. facsWrapper does NOT act on this list; it's here for the coverage invariant test. Internal endpoints are already covered by the identity bypass in the wrapper.
  • PRODUCTS_ROUTES[<PRODUCT>] — the customer-facing route → permission map. Values are fully-qualified strings used verbatim by facsWrapper (no runtime composition).

LLMO permission set (agreed with the MAC team)

Permission Action surface Count
llmo/can_view Read-only across all LLMO surfaces (incl. body-based query POSTs) 289
llmo/can_configure Edit / add / delete prompts, topics, categories, aliases, competitors, intent, strategy 76
llmo/can_onboard Brand creation, URLs, integrations (analytics, CMS, CDN) 10
llmo/can_deploy Edge / source optimization writes, auto-fix application 8
llmo/can_manage_user Add / delete users, assign capabilities (Phase 2 — empty in this PR) 0
Total 383

POST classification cross-checked against src/routes/required-capabilities.js (the S2S source of truth). :read POSTs map to can_view, :write POSTs to can_configure (with onboard/deploy exceptions).

Coverage invariant

For any populated product P:

routes(P) ∪ INTERNAL_ROUTES = all routes in src/routes/index.js
routes(P) ∩ INTERNAL_ROUTES = ∅

For LLMO: 383 + 55 = 438 = total routes. Enforced by test/routes/facs-capabilities.test.js — adding a new route to src/routes/index.js without categorising it as either product-scoped or internal will fail the test.

Wrapper chain

const wrappedMain = wrap(run)
  .with(facsWrapper, { routeFacsCapabilities })   // NEW — innermost, runs last
  .with(readOnlyAdminWrapper, { routeCapabilities: routeRequiredCapabilities, internalRoutes: INTERNAL_ROUTES })
  .with(authWrapper, { authHandlers: AUTH_HANDLERS })
  .with(s2sAuthWrapper, { routeCapabilities: routeRequiredCapabilities });

facsWrapper is permissive-by-default — bypasses for OPTIONS preflight, internal identities (is_admin / is_s2s_admin / is_s2s_consumer / is_read_only_admin), Adobe internal IMS orgs, requests without x-product, products with no sub-map, and disabled per-product LD flags. Deny-by-default fires only inside an enrolled product when the route is unmapped or the caller lacks the required FACS permission.

Dependency note (must be unwound before merge)

package.json temporarily pins @adobe/spacecat-shared-http-utils to a gist tarball that includes the facsWrapper implementation. The corresponding change is on its way to the package via the parallel PR in adobe/spacecat-shared. Once that releases, the dependency line reverts to a normal semver pin and package-lock.json updates with npm install.

-    "@adobe/spacecat-shared-http-utils": "1.27.2",
+    "@adobe/spacecat-shared-http-utils": "https://gist.github.com/.../adobe-spacecat-shared-http-utils-1.27.2.tgz",

Test plan

  • test/routes/facs-capabilities.test.js — 14 contract + invariant tests pass.
  • Coverage check: routes(LLMO) ∪ INTERNAL_ROUTES = 438 (full route surface).
  • No stale entries: every route in both buckets exists in src/routes/index.js.
  • test/index.test.js — 15/15 (wrapper wiring exercised; OPTIONS bypass; bypasses for legacy api-key callers).
  • Full suite locally: 10,277 passing.
  • CI passing on feat/mac-facs-capabilities.
  • Manual verification of one LLMO end-to-end flow per permission group (view / configure / onboard / deploy) before merge.

Related

  • Design doc: mysticat-architecture/platform/decisions/mac-state-layer.md — updated to reflect the per-product capability model and to add the is_llmo_administrator → FACS RBAC migration plan.
  • Parallel PR in adobe/spacecat-shared adding facsWrapper to @adobe/spacecat-shared-http-utils.
  • Auth-service login.js change that populates the JWT facs_permissions claim (already merged).

Out of scope

  • is_llmo_administrator retirement and controller-side dual checks — see the "Migration" section of the design doc; tracked as a separate per-org rollout exercise.
  • Phase 2 state-layer DB (facs_access_mappings table, /facs/access-mappings/* endpoints, resource-level enforcement) — explicitly deferred.
  • ASO and ACO sub-maps — empty stubs until their MAC policies land.

🤖 Generated with Claude Code

ravverma and others added 9 commits May 18, 2026 15:59
Implements api-service side of Phase 1 of the MAC state layer design
(mysticat-architecture/platform/decisions/mac-state-layer.md).

src/routes/facs-capabilities.js — new file. Top-level shape:

  { INTERNAL_ROUTES: [...], PRODUCTS_ROUTES: { LLMO, ASO, ACO } }

Each product owns full FACS permission strings (<product>/<action>) per
route — decoupled from the original flat route → action map so each
product MAC policy can name roles independently. Coverage invariant:
routes(product) ∪ INTERNAL_ROUTES = all routes in src/routes/index.js,
with routes(product) ∩ INTERNAL_ROUTES = ∅.

LLMO populated with 383 routes:
  - llmo/can_view        × 288
  - llmo/can_configure   × 76
  - llmo/can_onboard     × 10
  - llmo/can_deploy      × 9

INTERNAL_ROUTES × 55 — admin/S2S/restricted/infra surfaces excluded
from FACS enforcement (each entry annotated with its gating mechanism
inline). ASO/ACO sub-maps stubbed empty pending MAC policy.

src/index.js — import facsWrapper from @adobe/spacecat-shared-http-utils
and add .with(facsWrapper, { routeFacsCapabilities }) as the innermost
wrapper (runs last, after readOnlyAdminWrapper).

test/routes/facs-capabilities.test.js — pins the shape contract and the
coverage invariant: top-level shape, INTERNAL_ROUTES uniqueness, product
keys uppercase, permission strings prefixed with their product, no stale
routes (every entry must exist in src/routes/index.js), and the
union/disjointness invariant for every populated product.

package.json — temporarily pinned spacecat-shared-http-utils to a gist
tarball containing the facsWrapper implementation; will revert to a
released version once the package publishes facsWrapper.

Co-Authored-By: Claude Sonnet 4.7 <noreply@anthropic.com>
Cross-checked LLMO POST routes against the S2S source-of-truth in
src/routes/required-capabilities.js. Two corrections in each direction:

POSTs marked :read in S2S (confirmed query operations) — moved to can_view:
  - POST /sites/:siteId/autofix-checks                              (was can_deploy)
  - POST /sites/:siteId/llmo/sheet-data/:dataSource                 (was can_configure)
  - POST /sites/:siteId/llmo/sheet-data/:sheetType/:dataSource      (was can_configure)
  - POST /sites/:siteId/llmo/sheet-data/:sheetType/:week/:dataSource(was can_configure)

POSTs marked :write in S2S (mutating, not body-based queries) — moved
to can_configure:
  - POST /llmo/agentic-traffic/global              (S2S: report:write; was can_view)
  - POST /sites/:siteId/traffic/predominant-type   (S2S: site:write;   was can_view)
  - POST /sites/:siteId/traffic/predominant-type/:channel (S2S: site:write; was can_view)

Counts after the rebalance:
  - llmo/can_view:        288 -> 289
  - llmo/can_configure:   76 -> 76 (out 3 sheet-data, in 3 traffic writes)
  - llmo/can_onboard:     10 -> 10
  - llmo/can_deploy:      9 -> 8 (autofix-checks moved out)
  - LLMO total:           383 -> 383

Shape contract + coverage invariant tests still pass (14/14). Lint clean.

Co-Authored-By: Claude Sonnet 4.7 <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented May 18, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@ravverma ravverma deployed to dev-branches May 18, 2026 19:07 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant