Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 48 additions & 14 deletions docs/index.html

Large diffs are not rendered by default.

41 changes: 37 additions & 4 deletions docs/openapi/state-access-mappings-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,11 @@ state-access-mapping-by-id:
summary: Replace the granted capabilities on a mapping
description: |
Single-field mutation: replaces the row's `grantedCapabilities` array.
An empty array is permitted — it leaves the binding active but granting
nothing (the effective-capability evaluation treats it as no grant), which
is the supported way to remove access. 404 when the row does not exist, has
been revoked, or belongs to a different org / product.
The array must be **non-empty** — to remove access (empty the set) use
`DELETE` on this resource. The product's `<product>/can_view` is always
included: if the request omits it, it is added automatically. 404 when the
row does not exist, has been revoked, or belongs to a different
org / product.
operationId: patchStateAccessMapping
requestBody:
required: true
Expand Down Expand Up @@ -211,6 +212,38 @@ state-access-mapping-by-id:
$ref: './responses.yaml#/503'
security:
- ims_key: []
delete:
tags:
- state-access-mappings
summary: Remove access (empty the granted capabilities)
description: |
Empties the row's `grantedCapabilities` (sets it to `[]`), leaving the
binding active but granting nothing — the supported way to remove access.
The row is **not** tombstoned/revoked; capabilities can be re-added later
via `PATCH`. 404 when the row does not exist, has been revoked, or belongs
to a different org / product.
operationId: deleteStateAccessMapping
responses:
'200':
description: The mapping with its capabilities emptied.
content:
application/json:
schema:
$ref: './schemas.yaml#/StateAccessMapping'
'400':
$ref: './responses.yaml#/400'
'401':
$ref: './responses.yaml#/401'
'403':
$ref: './responses.yaml#/403'
'404':
$ref: './responses.yaml#/404'
'500':
$ref: './responses.yaml#/500'
'503':
$ref: './responses.yaml#/503'
security:
- ims_key: []

product-capabilities:
get:
Expand Down
117 changes: 108 additions & 9 deletions src/controllers/state-access-mappings.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,21 @@ function StateAccessMappingsController(context) {
return null;
}

/**
* Returns `capabilities` with the product's baseline `<product>/can_view`
* guaranteed present. `can_view` is the read capability implied by every
* other grant (you cannot manage/configure/deploy a resource you cannot
* view), so create/patch always persist it even when the caller omits it.
*
* @param {string[]} capabilities - Already validated, deduped capabilities.
* @param {string} product - Uppercase product code.
* @returns {string[]}
*/
function ensureBaselineCanView(capabilities, product) {
const canView = `${product.toLowerCase()}/can_view`;
return capabilities.includes(canView) ? capabilities : [...capabilities, canView];
}

function buildListFilters(ctx, imsOrgId, product) {
const queryParams = getQueryParams(ctx);
return {
Expand Down Expand Up @@ -689,9 +704,13 @@ function StateAccessMappingsController(context) {
if (grantGuard) {
return grantGuard;
}
// De-duplicate so a payload like ['llmo/can_view', 'llmo/can_view'] is not
// stored verbatim.
const dedupedCapabilities = [...new Set(grantedCapabilities)];
// De-duplicate, then guarantee the baseline `<product>/can_view`: it is the
// read capability implied by any other grant, so it is always stored even
// when the request omits it.
const capabilitiesToStore = ensureBaselineCanView(

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): emitAuditEvent in both createMapping and patchMapping logs grantedCapabilities (the raw request input) rather than capabilitiesToStore (the value written to the DB with the auto-injected can_view).

When a caller sends ['llmo/can_configure'], the DB stores ['llmo/can_configure', 'llmo/can_view'], but the audit trail records only ['llmo/can_configure']. For compliance and SOC analysis on an access-management system, the authoritative record should reflect what capabilities the subject actually received.

Fix: pass capabilitiesToStore to emitAuditEvent instead of grantedCapabilities, or add a separate requestedCapabilities field alongside grantedCapabilities: capabilitiesToStore to preserve both.

[...new Set(grantedCapabilities)],
product,
);

try {
const { postgrestClient } = ctx.dataAccess.services;
Expand All @@ -700,7 +719,7 @@ function StateAccessMappingsController(context) {
product,
resourceType,
resourceId,
grantedCapabilities: dedupedCapabilities,
grantedCapabilities: capabilitiesToStore,
subjects: [{ type: subjectType, id: subjectId }],
createdBy,
});
Expand Down Expand Up @@ -776,17 +795,21 @@ function StateAccessMappingsController(context) {
return badRequest('request body is required');
}
const { grantedCapabilities } = data;
// PATCH may empty the capability set (active row that grants nothing =
// remove access); create still requires at least one capability.
const capErr = validateGrantedCapabilities(grantedCapabilities, product, { allowEmpty: true });
// PATCH requires a non-empty capability set: removing access is done via
// DELETE (which empties the set), not by PATCHing an empty array.
const capErr = validateGrantedCapabilities(grantedCapabilities, product);
if (capErr) {
return badRequest(capErr);
}
const grantGuard = requireFacsManageToGrant(ctx, product, grantedCapabilities);
if (grantGuard) {
return grantGuard;
}
const dedupedCapabilities = [...new Set(grantedCapabilities)];
// De-dupe, then guarantee the baseline `<product>/can_view` (see create).
const capabilitiesToStore = ensureBaselineCanView(
[...new Set(grantedCapabilities)],
product,
);

try {
const { postgrestClient } = ctx.dataAccess.services;
Expand All @@ -811,7 +834,7 @@ function StateAccessMappingsController(context) {
id,
imsOrgId,
product,
grantedCapabilities: dedupedCapabilities,
grantedCapabilities: capabilitiesToStore,
updatedBy: resolveCallerUserIdent(ctx),
});
if (!updated) {
Expand Down Expand Up @@ -840,6 +863,81 @@ function StateAccessMappingsController(context) {
}
}

/**
* DELETE /state/access-mappings/:id — empties the row's `granted_capabilities`
* (sets it to `[]`), leaving the row active but granting nothing. This is the
* only path to an empty capability set (POST/PATCH require non-empty). It does
* NOT tombstone / soft-revoke the row — capabilities can be re-added later via
* PATCH. Audited as an `update_capabilities` to the empty set.
*
* 404 when the row does not exist, is revoked, or belongs to a different
* org / product. Same manager + per-resource scoping as PATCH (§8.3).
*/
async function deleteMapping(ctx) {
const pre = preamble(ctx);
if (pre.error) {
return pre.error;
}
const { product, imsOrgId } = pre;
const { error: gateErr, authority } = await gateManager(ctx, product, imsOrgId);
if (gateErr) {
return gateErr;
}

const { id } = ctx.params || {};
if (!hasText(id) || !isValidUUID(id)) {
return badRequest('id must be a valid UUID');
}

try {
const { postgrestClient } = ctx.dataAccess.services;
// Resource-scope check (§8.3): a state-layer manager may only act on
// resources they manage; org-wide managers skip the fetch.
if (!authority.orgWide) {
const existing = await getFacsAccessMappingById(postgrestClient, { id, imsOrgId, product });
if (!existing) {
return notFound('Mapping not found');
}
if (!canActOnResource(authority, existing.resource_id)) {
return forbidden(
`Caller may only manage resources where they hold ${product.toLowerCase()}/can_manage_users`,
);
}
}
// Empty the capability set via the capability-edit RPC (no tombstone).
const updated = await updateFacsAccessMappingCapabilities(postgrestClient, {
id,
imsOrgId,
product,
grantedCapabilities: [],
updatedBy: resolveCallerUserIdent(ctx),
});
if (!updated) {
return notFound('Mapping not found');
}
await emitAuditEvent(ctx, {
imsOrgId,
product,
operation: 'update_capabilities',
outcome: 'allow',
statusCode: 200,
mappingId: updated.id,
bindingSubjectType: updated.subject_type,
bindingSubjectId: updated.subject_id,
resourceType: updated.resource_type,
resourceId: updated.resource_id,
grantedCapabilities: [],
});
return ok(toMappingDto(updated));
} catch (error) {
log.error(
{ tag: 'state-access-mappings', err: error.message, id },
'Failed to delete (empty) state-layer access mapping',
);
return internalServerError('Failed to delete access mapping');
}
}

/**
* GET /product/capabilities — the catalog of capabilities the caller may
* **assign** to others under the product. Sourced from
Expand Down Expand Up @@ -1102,6 +1200,7 @@ function StateAccessMappingsController(context) {
listHistory: devOnly(listHistory),
createMapping: devOnly(createMapping),
patchMapping: devOnly(patchMapping),
deleteMapping: devOnly(deleteMapping),
getProductCapabilities: devOnly(getProductCapabilities),
getUserCapabilities: devOnly(getUserCapabilities),
getAuditLogs: devOnly(getAuditLogs),
Expand Down
2 changes: 2 additions & 0 deletions src/routes/facs-capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ const routeFacsCapabilities = {
'GET /state/access-mappings/history': 'llmo/can_manage_users',
'POST /state/access-mappings': 'llmo/can_manage_users',
'PATCH /state/access-mappings/:id': 'llmo/can_manage_users',
'DELETE /state/access-mappings/:id': 'llmo/can_manage_users',
'GET /organizations/:organizationId/permission/audit-logs': 'llmo/can_manage_users',
'GET /product/capabilities': 'llmo/can_view',
'GET /user/capabilities/:resourceId': 'llmo/can_view',
Expand Down Expand Up @@ -887,6 +888,7 @@ const routeFacsCapabilities = {
'GET /state/access-mappings/history': 'aso/can_manage_users',
'POST /state/access-mappings': 'aso/can_manage_users',
'PATCH /state/access-mappings/:id': 'aso/can_manage_users',
'DELETE /state/access-mappings/:id': 'aso/can_manage_users',
'GET /organizations/:organizationId/permission/audit-logs': 'aso/can_manage_users',

// ---- View (read-only) ----------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ export default function getRouteHandlers(
'GET /state/access-mappings/history': stateAccessMappingsController.listHistory,
'POST /state/access-mappings': stateAccessMappingsController.createMapping,
'PATCH /state/access-mappings/:id': stateAccessMappingsController.patchMapping,
'DELETE /state/access-mappings/:id': stateAccessMappingsController.deleteMapping,
'GET /product/capabilities': stateAccessMappingsController.getProductCapabilities,
'GET /user/capabilities/:resourceId': stateAccessMappingsController.getUserCapabilities,
'GET /organizations/:organizationId/permission/audit-logs': stateAccessMappingsController.getAuditLogs,
Expand Down
1 change: 1 addition & 0 deletions src/routes/required-capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ export const INTERNAL_ROUTES = [
'GET /state/access-mappings/history',
'POST /state/access-mappings',
'PATCH /state/access-mappings/:id',
'DELETE /state/access-mappings/:id',
'GET /organizations/:organizationId/permission/audit-logs',
'GET /product/capabilities',
'GET /user/capabilities/:resourceId',
Expand Down
Loading
Loading