diff --git a/src/controllers/state-access-mappings.js b/src/controllers/state-access-mappings.js index 8a9522ce6..417f013b3 100644 --- a/src/controllers/state-access-mappings.js +++ b/src/controllers/state-access-mappings.js @@ -352,6 +352,22 @@ function StateAccessMappingsController(context) { || authInfo?.hasFacsPermission?.(`${product.toLowerCase()}/can_manage_users`)); } + /** + * True when the caller holds the FACS-layer `/can_manage_users` + * **claim** itself — the internal-admin bypass does NOT count. Use this where + * the answer must reflect the caller's own grant authority rather than + * platform-operator reach: internal admins may not author/revoke bindings + * (see `blockInternalAdminWrite`), so being an admin must not surface + * `can_manage_users` as assignable in the capability catalog either. + * + * @param {object} ctx + * @param {string} product Uppercase product code. + * @returns {boolean} + */ + function callerHoldsFacsManageUsersClaim(ctx, product) { + return !!ctx.attributes?.authInfo?.hasFacsPermission?.(`${product.toLowerCase()}/can_manage_users`); + } + /** * Resolves the caller's management authority for the product (hybrid-model * §8.3). Two tiers: @@ -990,11 +1006,14 @@ function StateAccessMappingsController(context) { * `PRODUCTS_CAPABILITIES[product]`, then shaped by the caller's management * authority (hybrid-model §8.3): * - * - FACS-layer manager (or admin) → the full catalog, including - * `can_manage_users` (only FACS managers may mint new managers). - * - Otherwise (state-layer manager / any other caller) → the full catalog - * **minus** `can_manage_users` — a state-layer manager can assign every - * other capability but cannot grant management authority. + * - FACS-layer `can_manage_users` claim holder → the full catalog, including + * `can_manage_users` (only FACS managers may mint new managers). The + * internal-admin bypass does NOT unlock this — an internal admin cannot + * author bindings (see `blockInternalAdminWrite`), so `can_manage_users` + * must not appear assignable for them either. + * - Otherwise (state-layer manager, internal admin, any other caller) → the + * full catalog **minus** `can_manage_users` — assignable of every other + * capability but not management authority. */ async function getProductCapabilities(ctx) { // Intentionally skips the PostgREST/IMS-org preamble used by the data @@ -1006,7 +1025,7 @@ function StateAccessMappingsController(context) { return badRequest('x-product header is required and must reference a known product'); } const catalog = getProductCapabilityCatalog(product); - if (callerHasFacsManageUsers(ctx, product)) { + if (callerHoldsFacsManageUsersClaim(ctx, product)) { return ok({ product, capabilities: catalog }); } const manageCap = `${product.toLowerCase()}/can_manage_users`; diff --git a/test/controllers/state-access-mappings.test.js b/test/controllers/state-access-mappings.test.js index af4cfd611..dd3a4022f 100644 --- a/test/controllers/state-access-mappings.test.js +++ b/test/controllers/state-access-mappings.test.js @@ -1371,6 +1371,19 @@ describe('StateAccessMappingsController', () => { expect(body.capabilities).to.include('llmo/can_manage_users'); }); + it('omits can_manage_users for an internal admin (no FACS claim)', async () => { + // Internal admins may not author bindings (blockInternalAdminWrite), so + // being an admin must NOT surface can_manage_users as assignable — only a + // real FACS can_manage_users claim does. + const { Controller } = await loadController(); + const ctx = makeContext({ facsPermissions: [], isAdmin: true }); + const res = await Controller(ctx).getProductCapabilities(ctx); + expect(res.status).to.equal(200); + const body = await res.json(); + expect(body.capabilities).to.include('llmo/can_view'); + expect(body.capabilities).to.not.include('llmo/can_manage_users'); + }); + it('returns the catalog for ASO', async () => { const { Controller } = await loadController(); const ctx = makeContext({ product: 'ASO' });