feat: integrate mac facs capabilities#2433
Draft
ravverma wants to merge 60 commits into
Draft
Conversation
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 Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…vocab
Phase 2 of the MAC/FACS integration per
mysticat-architecture/platform/decisions/mac-state-layer.md. Adds the
state-layer management endpoints customers use to assign and revoke ReBAC
grants, plus the per-product resource-param vocabulary the upgraded
facsWrapper consumes for its state-layer check.
Routes (POST/DELETE/:id/GET facs/access-mappings)
POST /facs/access-mappings Bulk-create many subjects on
one (resource, permission)
DELETE /facs/access-mappings Body-shaped bulk delete
DELETE /facs/access-mappings/:id Single removal by row id
GET /facs/access-mappings List + query filters
All four are dual-classified:
- PRODUCTS_ROUTES.LLMO: llmo/can_manage_user for every endpoint
including GET (listing who has access to what is itself sensitive)
- required-capabilities.INTERNAL_ROUTES: S2S consumers excluded, never.
src/controllers/facs-access-mappings.js (new)
- listMappings / createMappings / deleteMappingsBulk / deleteMappingById
- Always scoped to caller's IMS org (resolved from authInfo.getTenantIds)
- Bulk POST capped at MAX_SUBJECTS_PER_REQUEST = 100, idempotent via the
unique key (duplicates land in `skipped`, batch doesn't fail)
- Bulk DELETE symmetric with POST; single-by-id returns 204 / 404
src/support/facs-access-mappings.js (new)
- PostgREST helpers for the table - management endpoints only
(list / bulk-create / bulk-delete / single-delete-by-id)
- The wrapper-side point read findFacsAccessMapping lives in
@adobe/spacecat-shared-http-utils/src/auth/facs-state-layer.js next to
facsWrapper - single source of truth for the authorisation query
- requirePostgrestForFacsMappings - same 503 guard pattern as v2 brands
- Every helper takes imsOrgId; cross-org access is structurally impossible
src/routes/facs-capabilities.js
- Adds PRODUCTS_FACS_RESOURCE_PARAM_ALIASES (Phase 2 vocab passed to
facsWrapper). LLMO/brand only; ASO/ACO empty until their MAC policies
land.
- Adds FACS_NON_RESOURCE_PARAMS (every :param in routes/index.js that
no product currently treats as a ReBAC resource).
- Adds the 4 new /facs/access-mappings entries under PRODUCTS_ROUTES.LLMO
with llmo/can_manage_user; the single-id DELETE goes into the can_view
/ can_configure / can_manage_user block where the rest live.
src/routes/required-capabilities.js
- Adds the 4 new /facs/access-mappings routes to INTERNAL_ROUTES (S2S
excluded).
src/routes/index.js
- Wires FacsAccessMappingsController into getRouteHandlers and registers
the four routes.
src/index.js
- Instantiates FacsAccessMappingsController and passes it into
getRouteHandlers (last positional arg, after fanoutReportController).
- facsWrapper wiring unchanged from Phase 1 - the wrapper picks up
PRODUCTS_FACS_RESOURCE_PARAM_ALIASES from routeFacsCapabilities itself
and reads postgrestClient directly from context for the state-layer
check.
test/routes/facs-capabilities.test.js
- Top-level shape now requires PRODUCTS_FACS_RESOURCE_PARAM_ALIASES and
FACS_NON_RESOURCE_PARAMS as keys
- New describe block PRODUCTS_FACS_RESOURCE_PARAM_ALIASES with 7
assertions enforcing exhaustive per-product classification, no
stale entries, and within-product alias uniqueness
test/routes/index.test.js
- Adds mockFacsAccessMappingsController to the getRouteHandlers call
- Extends the static- and dynamic-route assertion lists with the
three static routes and one dynamic (:id) route
Full api-service suite: 10,285 passing.
Co-Authored-By: Claude Sonnet 4.7 <noreply@anthropic.com>
|
This PR will trigger a minor release when merged. |
Updates the FACS capability map to the array-only contract the wrapper
now expects, plus the new exempt-permissions field.
1. Every route value is now a string[]
All 387 LLMO route values converted: single-permission routes wrap their
permission in a one-element array; read routes (can_view) gain
'llmo/can_view_all' as a second any-of permission so brand-scoped viewers
and org-wide readers both pass the authorisation check.
- llmo/can_configure: 76 routes (one-element)
- llmo/can_deploy: 8 routes (one-element)
- llmo/can_manage_user: 4 routes (one-element; management endpoints)
- llmo/can_onboard: 10 routes (one-element)
- llmo/can_view: 289 routes, each with llmo/can_view_all alongside
(290th 'llmo/can_view' is in the file docstring)
2. New PRODUCTS_FACS_STATE_LAYER_EXEMPT_PERMISSIONS field
LLMO: ['llmo/can_view_all', 'llmo/can_manage_user']
ASO: []
ACO: []
Drives the wrapper's exempt-preference held-permission resolution
(Option B): callers holding can_view_all or can_manage_user bypass the
Phase 2 state-layer check entirely, regardless of which permission was
listed first on a route. This keeps llmo_manager (holds both can_view
and can_view_all) from being incorrectly forced into a state-layer row
that was never granted.
3. Coverage test updates
test/routes/facs-capabilities.test.js:
- top-level shape now requires the new exempt-permissions key
- "each permission value is a string..." renamed to "each route value
is a non-empty array of '<product>/<action>' strings scoped to its
product", and the assertion iterates the array
- new describe('PRODUCTS_FACS_STATE_LAYER_EXEMPT_PERMISSIONS') with
three assertions: uppercase product keys also in PRODUCTS_ROUTES,
each entry matches the product prefix, every exempt permission
appears as a required permission on at least one route (no orphans)
Full api-service suite still passes (10,288).
Co-Authored-By: Claude Sonnet 4.7 <noreply@anthropic.com>
Adds PRODUCTS_FACS_ADMIN_PERMISSIONS as a new top-level key in
facs-capabilities.js and removes llmo/can_manage_user from
PRODUCTS_FACS_STATE_LAYER_EXEMPT_PERMISSIONS.
The two concepts were previously conflated in a single list. They're
structurally different:
- Product-admin permissions (this commit, new): wrapper step 9 —
holders of an admin permission for the request's product bypass
the route gate AND the state-layer evaluation entirely. They are
"fully privileged within this product".
- State-layer-exempt permissions (existing, now narrowed): wrapper
step 11 — a HELD permission in this set skips the per-resource
binding lookup, but the request still has to pass the route gate
and held-permission resolution. Used for org-wide reads (e.g.
llmo/can_view_all) that don't need a per-brand row.
llmo/can_manage_user moves from the exempt list to the admin list.
The behaviour shift: admins now bypass before the wrapper even
consults the route map, so they're never subjected to the
held-permission resolver or the state-layer check. The management
endpoints (which previously required can_manage_user as a non-admin
exempt permission) still work because the admin bypass admits
can_manage_user holders to those routes unconditionally.
aso/can_manage_owner is also declared as ASO's admin permission
(consistent with the design's per-product admin convention) so it's
ready when ASO enrolment lands; ACO remains empty.
The route table (PRODUCTS_ROUTES.LLMO) is unchanged in this commit —
the route surface reshuffle (drop bulk DELETE, add /history, rename
revoke-by-id) ships together with the controller change in a
follow-up so each commit leaves the build in a working state.
Tests
- top-level shape test asserts the new PRODUCTS_FACS_ADMIN_PERMISSIONS
key.
- new PRODUCTS_FACS_ADMIN_PERMISSIONS test block: uppercase + present-
in-PRODUCTS_ROUTES keys, well-formed product/action permissions
scoped to their product, regression guard that LLMO declares
llmo/can_manage_user.
- new disjointness invariant on the exempt list: admin permissions
must NOT also appear in PRODUCTS_FACS_STATE_LAYER_EXEMPT_PERMISSIONS
(the two lists are conceptually different and shouldn't overlap).
29 facs-capabilities tests passing; 10,292 total tests passing across
the repo. Lint clean.
…odule
`requirePostgrestForV2Config` was a private nested function inside
`BrandsController` (~20 call sites). `requirePostgrestForFacsMappings`
in `src/support/facs-access-mappings.js` was a near-identical copy with
a different error message. Two copies of the same five-line check.
Consolidates into `src/support/postgrest-availability.js`:
- `requirePostgrest(context, { errorMessage })` is the underlying
generic helper. Returns 503 when `context.dataAccess.services.
postgrestClient.from` is unavailable, null otherwise.
- `requirePostgrestForV2Config(context)` and
`requirePostgrestForFacsMappings(context)` are thin aliases bound to
their feature-specific error messages, preserving the original
call-site shapes (the V2 controller's 20 sites don't churn).
`controllers/brands.js` imports the alias and drops the local
definition. `support/facs-access-mappings.js` re-exports the alias
from the new module (the local copy is replaced with a single
re-export). The 503 Response is constructed via
`createResponse` from `@adobe/spacecat-shared-http-utils` in both
cases now (was a hand-rolled `new Response(...)` in the FACS variant);
behaviour is identical.
Tests
- New `test/support/postgrest-availability.test.js` covers the
generic helper (returns null when available; returns 503 with the
passed message when not; handles missing context / dataAccess /
.from) plus both alias wrappers (correct error messages).
- BrandsController's 244 tests pass unchanged (call-site shape
preserved).
10,292 tests pass repo-wide. Lint clean.
…soft-revoke via RPC
Rewrites src/support/facs-access-mappings.js to match the binding-only
state-layer model. No facsPermission in any helper signature; soft-revoke
is the only mutation path; active vs history listing are split.
Helpers:
- listFacsAccessMappings: active rows only (.is('revoked_at', null));
default page 50, hard cap 500; malformed limits fall back to default.
- listFacsAccessMappingHistory: NEW. Active + revoked rows ordered
by created_at DESC; optional `since` filter.
- createFacsAccessMappings (renamed from bulkCreateFacsAccessMappings):
capability-less binding rows; onConflict uses the active-row partial
unique index so re-grant after revoke is a fresh row.
- revokeFacsAccessMappingById (renamed from deleteFacsAccessMappingById):
invokes the wrpc_revoke_facs_access_mapping SECURITY DEFINER RPC,
the only legal mutation path. Cross-org revoke structurally
impossible; idempotent re-revoke returns null.
Transitional aliases for the old names (bulkCreateFacsAccessMappings,
deleteFacsAccessMappingById, bulkDeleteFacsAccessMappings) are kept in
the module solely to preserve the controller's import graph until the
next commit (D) updates the controller. They'll be removed there.
Tests: new test/support/facs-access-mappings.test.js with 26 cases
covering every helper. 10,327 tests pass repo-wide.
…hip preconditions, soft-revoke
Closes the FACS state-layer management refactor. Aligns the controller
and the route surface with the binding-only design.
Route surface (mac-state-layer.md §"State Layer Management Endpoints"):
four endpoints, no bulk DELETE, history listing surfaces tombstones.
- GET /facs/access-mappings (active bindings)
- GET /facs/access-mappings/history (active + revoked, audit) NEW
- POST /facs/access-mappings (bulk-create bindings)
- DELETE /facs/access-mappings/:id (soft-revoke by id, RPC) RENAMED
The bulk-by-body `DELETE /facs/access-mappings` is removed.
Controller (src/controllers/facs-access-mappings.js):
- POST body shape drops `facsPermission`. Capability lives in the JWT;
no field on the row.
- POST validates `resourceType === 'brand'` (only resource type in v1)
and a UUID `resourceId`.
- POST adds two preconditions per the design's "Resource-ownership
precondition" subsection:
(1) Resource ownership — `Organization.findByImsOrgId(callerOrg)` →
`getBrandById(spaceCatOrgId, resourceId)`. Brand not under
caller's org → 403 for the whole request.
(2) Subject membership — for each subject, IMS lookup via
`imsClient.getImsAdminOrganizations(subjectId)` and compare
`orgRef.ident` to the caller's IMS org. Subjects that fail land
in `rejected: 'not-in-org'`, distinct from `skipped: 'duplicate'`.
Org-typed subjects accepted only when they equal the caller's
own IMS org (cross-org binding is v2). Fails closed on IMS
errors and on missing imsClient.
- DELETE :id calls the new `revokeFacsAccessMappingById` (soft-revoke
via the wrpc_revoke_facs_access_mapping RPC). Reason can come from
the JSON body OR the `?reason=` query param (CDN-strip tolerant).
Returns the tombstoned row on success, 404 when no active row
matched (idempotent re-revoke).
- NEW `listHistory` controller method exposing audit-history listing
with the same filters as the active list plus an optional `since=`
query param.
- `resolveCallerUserIdent` now picks `profile.sub` only, matching the
facsWrapper convention now that auth-service canonicalizes
sub === email === userId. No fallback to email.
Routing + capability wiring (the deferred parts from commit A):
- src/routes/index.js: drops `DELETE /facs/access-mappings →
deleteMappingsBulk`; adds `GET /facs/access-mappings/history →
listHistory`; renames `deleteMappingById` → `revokeMappingById`.
- src/routes/facs-capabilities.js PRODUCTS_ROUTES.LLMO: drops bulk
DELETE, adds history; all four under `llmo/can_manage_user`.
- src/routes/required-capabilities.js INTERNAL_ROUTES: same shape
(S2S excluded).
Support cleanup:
- Removes the transitional aliases (bulkCreateFacsAccessMappings,
deleteFacsAccessMappingById, bulkDeleteFacsAccessMappings) from
src/support/facs-access-mappings.js — the controller no longer
imports them.
Tests:
- New test/controllers/facs-access-mappings.test.js with 32 cases
covering validation, resource-ownership 403, subject-membership
rejection, eligible vs rejected vs skipped partitioning, soft-
revoke success / 404 / body-or-query reason, listHistory `since`
filter, postgrest-availability guard.
- test/routes/index.test.js: mock controller updated to the new
shape; route key list updated.
10,359 tests pass repo-wide. Lint clean.
…ov/patch Adds seven small tests so every branch in the controller and the `createFacsAccessMappings` helper is exercised: Controller: - revokeMappingById returns the guard response when postgrest is unavailable (lines 367-368) - revokeMappingById returns 403 when caller has no IMS org (lines 372-373) - listHistory returns the guard response when postgrest is unavailable - listHistory returns 403 when caller has no IMS org - listHistory returns 400 when subjectType filter is invalid (line 243) - createMappings returns 403 when caller has no IMS org Helper: - createFacsAccessMappings: PostgREST returning `data: null` (no error) is treated as an empty created list and lands every requested subject in `skipped: duplicate` (line 216, the `data ?? []` branch). Coverage on src/controllers/facs-access-mappings.js and src/support/facs-access-mappings.js: 100% lines / statements / functions. 10,366 tests pass repo-wide.
…ibility v1) + workspaceId param Post-rebase: the routes(LLMO) ∪ INTERNAL_ROUTES coverage invariant and the :param-classification invariant flagged 12 newly-merged routes and one new path param from main. Routes (all LLMO, brand-scoped under /v2/orgs/:spaceCatId/brands/:brandId/semrush/* or under /llmo/ai-visibility/v1/*): PRODUCTS_ROUTES.LLMO: - GET /v2/orgs/:spaceCatId/brands/:brandId/semrush/prompts → [can_view, can_view_all] - POST /v2/orgs/:spaceCatId/brands/:brandId/semrush/prompts → [can_configure] - POST /v2/orgs/:spaceCatId/brands/:brandId/semrush/prompts/bulk-delete → [can_configure] - PATCH /v2/orgs/:spaceCatId/brands/:brandId/semrush/prompts/:promptId → [can_configure] - GET /v2/orgs/:spaceCatId/brands/:brandId/semrush/projects → [can_view, can_view_all] - POST /v2/orgs/:spaceCatId/brands/:brandId/semrush/projects → [can_configure] - GET /v2/orgs/:spaceCatId/brands/:brandId/semrush/projects/:workspaceId/:projectId/tags → [can_view, can_view_all] - GET /v2/orgs/:spaceCatId/brands/:brandId/semrush/projects/:workspaceId/:projectId/models → [can_view, can_view_all] - GET /v2/orgs/:spaceCatId/brands/:brandId/semrush/workspaces/:workspaceId/projects → [can_view, can_view_all] - GET /llmo/ai-visibility/v1/topic/gap-topics → [can_view, can_view_all] - GET /llmo/ai-visibility/v1/prompt/gap-prompts → [can_view, can_view_all] - GET /llmo/ai-visibility/v1/prompt/prompt-response → [can_view, can_view_all] Reads gate on the brand-presence read pair (matches every other brand-scoped GET); writes gate on `llmo/can_configure` (matches the existing prompt-write routes). The :brandId in the Semrush path is the FACS resource for these routes; resolveFacsResource picks it via the LIFO scan. FACS_NON_RESOURCE_PARAMS: - workspaceId — Semrush workspace identifier from the Semrush API, not a SpaceCat resource. The enclosing :brandId is the FACS resource for the routes that carry it.
…vice image
Adds end-to-end PostgREST integration coverage for the hybrid-model state
layer (facs_access_mappings), which previously had only unit tests:
- test/it/shared/tests/state-access-mappings.js — shared suite (~25 cases):
full create/list/patch/soft-revoke/history lifecycle, active-duplicate
409, re-create-after-revoke 201 (partial unique index), org-scoped
subject rules, body/param validation, and an ASO site-scoped smoke test.
- test/it/postgres/state-access-mappings.test.js — Postgres wiring.
- test/it/postgres/seed.js — clear facs_access_mappings between runs
(standalone table: TEXT ims_org_id, no FK, so the organizations cascade
does not reach it).
- test/it/README.md — listing + coverage table.
Bumps the IT data-service image v5.13.0 -> v5.33.0 (first release containing
the facs_access_mappings hybrid migration from mysticat-data-service #666).
NOTE: this is a ~20-minor image jump; the FULL IT suite must be green in CI
(which has ECR access) to confirm no other suite regresses on intervening
schema changes. Cannot be validated locally (no Docker/ECR in dev env).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ite_id The data-service image bump (v5.13.0 -> v5.33.0) pulled in #638's chk_active_brand_has_site_id constraint: an 'active' brand must have a non-null site_id. The baseline brands seed predated the constraint and seeded BRAND_1 active with no site_id, so the insert failed with 23514 — aborting the whole seed batch and cascading to 94 failures across every suite (all sharing 'Failed to seed brands'). Fix: - seed BRAND_1 with site_id = SITE_1 (its base site, same org), matching the bindBrandToSite(BRAND_1, SITE_1) calls the brand-for-org-site tests already make and the active+site_id shape the legacy-brand insert uses. - move the brands insert from Level 1a to Level 2: brands.site_id -> sites.id is an FK, so brands must seed after sites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two production bugs surfaced by the new PostgREST integration suite (the unit tests mocked these paths, so neither was caught before): 1. List/history returned 400 for every request. The controller read query filters from ctx.pathInfo.queryParams, which the Lambda runtime does not populate; the raw query string lives on context.invocation.event. rawQueryString. Switched to a getQueryParams() helper matching the established pattern in controllers/feature-flags.js and brands.js. 2. POST returned 500: 'there is no unique or exclusion constraint matching the ON CONFLICT specification'. createFacsAccessMappings used a PostgREST upsert with onConflict against the active-row uniqueness index, but that index is PARTIAL (WHERE revoked_at IS NULL) and ON CONFLICT (cols) cannot target a partial index without its predicate. Replaced with per-row inserts that treat a 23505 unique-violation as a skipped duplicate (the controller maps created==0 && skipped>0 to 409). Re-grant-after-revoke still creates a fresh row since revoked rows are outside the partial index. Unit tests updated to the runtime query-param source and the insert/23505 mock shape. Full unit suite green (11715 passing); coverage gates hold. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lax IT ts assert Third batch of IT-surfaced fixes: 1. PATCH/DELETE/.../:id and GET /user/capabilities/:resourceId returned 400 for valid UUIDs because the controller read path params from ctx.pathInfo?.params, which the Lambda runtime does not populate — path params live on context.params (the source 234 other call sites use). Switched all three reads to ctx.params. Unit makeContext now exposes ctx.params alongside pathInfo.params. 2. IT timestamp assertion: PostgREST returns timestamptz as ISO 8601 with a +00:00 offset, but the shared expectISOTimestamp helper only accepts a Z suffix. The state-access-mapping DTO passes the value through unmodified, so the IT now uses a local Date.parse-based check that accepts both forms. (This was masking the real lifecycle as a cascade of 'undefined.id'.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…4 on no-match Fourth batch of IT-surfaced fixes: 1. PATCH 500: the table grants no UPDATE to any REST role (mutation is RPC-only by design), so the handler's direct .update() always failed. Switched patchMapping to a new updateFacsAccessMappingCapabilities() helper that calls the wrpc_set_facs_access_mapping_capabilities RPC (added in mysticat-data-service; SECURITY DEFINER, active-row + org + product scoped). Returns 404 when no active row matched. 2. DELETE unknown id returned 204 instead of 404: the revoke RPC is declared RETURNS facs_access_mappings, so a no-match yields an all-NULL composite row (not SQL NULL). revokeFacsAccessMappingById now treats a row without an id as 'not found'. Same normalization applied to the new update helper. Requires mysticat-data-service PR #674 (the RPC) to merge + release, then the IT data-service image to be re-bumped. Unit suite green (11724 passing); coverage 99.92%. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dit RPC v5.34.0 (mysticat-data-service #674) adds wrpc_set_facs_access_mapping_capabilities, the RPC the PATCH /state/access-mappings/:id handler now calls. Bumping the IT image makes the state-access-mappings PATCH integration cases runnable against the real RPC. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ities # Conflicts: # docs/index.html
The main merge brought four new routes that the union-equality invariant requires classifying: - GET /sites/:siteId/identity → llmo/can_view + aso/can_view (site read, like the bare GET /sites/:siteId in both products) - GET /llmo/ai-visibility/v1/brand/stats-by-country|stats-by-llm + /meta/meta → llmo/can_view (LLMO read-only, alongside the existing ai-visibility reads) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Read endpoint for the FACS state-mapping operation log
(facs_access_mapping_audit_events). The path carries the SpaceCat org UUID;
its IMS org id is resolved server-side and used as the query/tenant key.
- Helper listFacsAccessMappingAuditEvents (org + product scoped; optional
operation/outcome/resource/actor/mapping/since/until filters; created_at DESC;
cursor pagination).
- Handler getAuditLogs: resolves the org -> imsOrgId, scopes by x-product, maps
rows to a flat camelCase DTO (UI adapts the shape). Returns { items, cursor }.
- Gating: route is in PRODUCTS_ROUTES under <product>/can_manage_users; because
the route has no ReBAC resource (facsWrapper defers), the controller also
enforces admin OR FACS-layer can_manage_users.
- Tenant isolation: a non-admin caller may only read their OWN org's audit
(resolved org IMS id must equal the caller's); admins bypass. Wired into
required-capabilities.js INTERNAL_ROUTES (S2S excluded).
Unit tests for the helper and handler (gating, org resolution, cross-org 403,
error paths, pagination). Full suite green; coverage >90%.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Bump the IT data-service image v5.34.0 -> v5.36.0 (includes #682 updated_by and #684 facs_access_mapping_audit_events). - Seed three LLMO audit events for ORG_1's IMS org (the consumer Lambda that hydrates this table in prod is not part of the IT, so the rows are seeded directly); registered in seed.js (Level 0) + cleared in clearData. - New IT suite for GET /organizations/:organizationId/permission/audit-logs: admin reads the org's audit (org UUID resolved to IMS org), outcome/operation filters, 404 unknown org, 400 invalid UUID. The non-admin can_manage_users gate + cross-org isolation stay in the controller unit tests (driving them through the IT would also traverse facsWrapper's unconfigured LD gate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The audit table revokes INSERT from postgrest_anon (writer-only, append-only),
but the IT seed inserts unauthenticated (anon) — so it hit 401/42501 permission
denied and cascaded to every reset. insertRows now takes { asWriter: true },
which sends the postgrest_writer JWT; used for the audit-events seed. Reads
stay on anon SELECT (granted), so the endpoint path is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
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:
src/routes/facs-capabilities.js— new file. The route → permission map thatfacsWrapperenforces against the JWT'sfacs_permissionsclaim.src/index.js— wiresfacsWrapperfrom@adobe/spacecat-shared-http-utilsas the innermost wrapper in the chain (runs afterreadOnlyAdminWrapper).test/routes/facs-capabilities.test.js— pins the shape contract and the coverage invariant againstsrc/routes/index.js.Capability model
The map departs from the original flat
route → actionproposal 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(55 routes) — admin-only / S2S-only / restricted / pure infrastructure surfaces.facsWrapperdoes 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 byfacsWrapper(no runtime composition).LLMO permission set (agreed with the MAC team)
llmo/can_viewllmo/can_configurellmo/can_onboardllmo/can_deployllmo/can_manage_userPOST classification cross-checked against
src/routes/required-capabilities.js(the S2S source of truth).:readPOSTs map tocan_view,:writePOSTs tocan_configure(with onboard/deploy exceptions).Coverage invariant
For any populated product P:
For LLMO: 383 + 55 = 438 = total routes. Enforced by
test/routes/facs-capabilities.test.js— adding a new route tosrc/routes/index.jswithout categorising it as either product-scoped or internal will fail the test.Wrapper chain
facsWrapperis 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 withoutx-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.jsontemporarily pins@adobe/spacecat-shared-http-utilsto a gist tarball that includes thefacsWrapperimplementation. The corresponding change is on its way to the package via the parallel PR inadobe/spacecat-shared. Once that releases, the dependency line reverts to a normal semver pin andpackage-lock.jsonupdates withnpm install.Test plan
test/routes/facs-capabilities.test.js— 14 contract + invariant tests pass.routes(LLMO) ∪ INTERNAL_ROUTES = 438(full route surface).src/routes/index.js.test/index.test.js— 15/15 (wrapper wiring exercised; OPTIONS bypass; bypasses for legacy api-key callers).feat/mac-facs-capabilities.Related
mysticat-architecture/platform/decisions/mac-state-layer.md— updated to reflect the per-product capability model and to add theis_llmo_administrator→ FACS RBAC migration plan.adobe/spacecat-sharedaddingfacsWrapperto@adobe/spacecat-shared-http-utils.facs_permissionsclaim (already merged).Out of scope
is_llmo_administratorretirement and controller-side dual checks — see the "Migration" section of the design doc; tracked as a separate per-org rollout exercise.facs_access_mappingstable,/facs/access-mappings/*endpoints, resource-level enforcement) — explicitly deferred.🤖 Generated with Claude Code