Skip to content

Control↔policy mappings created from a framework control page don't appear on the policy's Mappings tab #3553

Description

@bibekthapa007

Issue Summary

Policies linked to a control from a framework control page are not visible on that policy's Mappings tab. The mapping exists and renders correctly on the control side, but the policy side reports no such control.

The cause is that the two pages read and write different storage. Since #2829 (feat/framework-control-link-isolation, May 2026), control↔policy links are stored per framework instance in FrameworkControlPolicyLink. That PR updated the controls API and the framework UI to be framework-aware, but GET /v1/policies/:id/controls was not updated — it still reads only the pre-migration implicit many-to-many relation _ControlToPolicy (Policy.controlsControl.policies).

Concretely:

Operation Writes / reads Table
POST /v1/controls/:id/policies/link?frameworkInstanceId=… writes FrameworkControlPolicyLink only
GET /v1/policies/:id/controls reads _ControlToPolicy only

Because the framework control page always supplies frameworkInstanceId, linkPolicies() always takes the framework-scoped branch and never touches the direct relation. The policy page therefore never sees the link.

The bug is masked in most orgs because organisation onboarding writes to both tables (frameworks-upsert.helper.ts:314 connects policies, :391 creates frameworkControlPolicyLink rows). Seeded mappings look correct on both pages. Only manually created links diverge.

Two related defects in the same area:

  1. Mirror case. POST /v1/policies/:id/controls writes only controls: { connect } (the direct relation). On the control side, direct policies are gated behind isCustomFramework ? control.policies : [], so for a built-in framework the link created from the policy page does not appear on the framework control page either. The asymmetry runs in both directions.
  2. Unlink is a no-op for scoped links. DELETE /v1/policies/:id/controls/:controlId only disconnects the direct relation, so the unlink button on the policy Mappings tab cannot remove a framework-scoped link. It appears to succeed and changes nothing.

Steps to Reproduce

  1. Use an organisation with a built-in framework instance (reproduced on SOC 2).
  2. Navigate to Frameworks → SOC 2 → P1.1 → Data Privacy (a control detail page).
    URL shape: /{orgId}/frameworks/{frameworkInstanceId}/controls/{controlId}
  3. Click Link Policy and select an existing policy — e.g. Authentication & Password.
  4. Confirm the policy now appears in the control's Policies tab. It does.
  5. Navigate to Policies → Authentication & Password → Mappings.
  6. Look at the Controls table.

Reverse case (same root cause):

  1. Open any policy → MappingsLink control, and link a control that belongs to a built-in framework.
  2. Open that control from the framework page.
  3. The policy is absent.

Unlink case:

  1. Take a policy that has a control mapping in FrameworkControlPolicyLink and is therefore visible on the policy Mappings tab (a seeded mapping from org onboarding works, since onboarding writes both tables).
  2. Click the unlink icon next to it.
  3. The row disappears optimistically, but the FrameworkControlPolicyLink row is still present after a refresh.

Actual Results

  • The policy's Mappings → Controls table does not list the control that was just linked from the framework control page.
  • Only controls whose mapping exists in _ControlToPolicy are listed — in practice, only those created during org onboarding.
  • The control detail page shows the mapping correctly, so the two pages disagree about the same relationship.
  • Linking from the policy page produces the inverse discrepancy on built-in frameworks.
  • Unlinking from the policy page silently fails to remove framework-scoped links.

Expected Results

  • A control↔policy mapping should be visible from both sides regardless of which page created it.
  • After linking a policy from a framework control page, that control should appear in the policy's Mappings → Controls table.
  • After linking a control from a policy page, that policy should appear on the framework control page.
  • Unlinking a control from the policy Mappings tab should actually remove the mapping, and it should stay removed after a refresh.
  • The two pages should never disagree about whether a mapping exists.

Technical details

Root cause — file references

Concern Location
Write (control side) apps/api/src/controls/controls.service.ts:662-678linkPolicies(); the frameworkInstanceId branch writes only frameworkControlPolicyLink
Read (policy side) apps/api/src/policies/policies.controller.ts:287-300getPolicyControls(); selects controls: only
Write (policy side) apps/api/src/policies/policies.controller.ts:1134-1141controls: { connect } only
Delete (policy side) apps/api/src/policies/policies.controller.ts:1174disconnect only
Caller apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkPolicySheet.tsx:65 — always sends frameworkInstanceId
Consumer apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappings.tsx

Schema

  • _ControlToPolicy — implicit Prisma m2m, columns A (controlId) / B (policyId). Created in 20250519172353_drop_artifacts.
  • FrameworkControlPolicyLink(frameworkInstanceId, controlId, policyId), unique on all three. Created in 20260513150611_framework_scoped_control_links.

Why the divergence exists (and why it is not a schema bug)

Framework scoping is deliberate and correct — a single Control is shared across framework instances, and unlinking a policy within SOC 2 must not remove it from ISO 27001. PR #2829 describes this as preventing "cross-framework bleed." The defect is that the policy module was never migrated to the new model.

Environment

  • Node.js: 22 (per apps/api/Dockerfile.multistage)
  • Browser:
  • Deployment:
  • Affects: any organisation using a built-in framework instance

Evidence

Screenshots

  1. control-page.png — Frameworks → SOC 2 → P1.1 → Data Privacy, Policies (6) tab listing Authentication & Password.
  2. policy-page.png — Policies → Authentication & Password → Mappings, Controls table showing only Access Rights and Credential Management. Data Privacy is absent.

Database verification

After performing steps 1–4, both queries were run against the same organisation:

-- Framework-scoped link created by the UI action — present
SELECT id, "frameworkInstanceId", "controlId", "policyId"
FROM "FrameworkControlPolicyLink"
WHERE "controlId" = '<controlId>' AND "policyId" = '<policyId>';
--> 1 row

-- Direct relation read by the policy page — absent
SELECT * FROM "_ControlToPolicy"
WHERE "A" = '<controlId>' AND "B" = '<policyId>';
--> 0 rows

This confirms the link was persisted, and that the policy endpoint queries a store it was never written to.

API verification

# Control side — policy present
curl -s "$API/v1/controls/<controlId>?frameworkInstanceId=<frameworkInstanceId>" \
  -H "x-api-key: $KEY" | jq '.policies[].name'
#> "Authentication & Password"

# Policy side — control absent
curl -s "$API/v1/policies/<policyId>/controls" \
  -H "x-api-key: $KEY" | jq '.mappedControls[].name'
#> "Access Rights"
#> "Credential Management"

Related

  • PR #2829feat/framework-control-link-isolation, introduced the framework-scoped tables
  • Commit a32ec3798fix(controls): include direct policy/task links in custom framework view, which patched four read paths in controls.service.ts. getPolicyControls in policies.controller.ts was not among them.
  • Commit 7cbbaa032fix(frameworks): populate framework-scoped link tables during org onboarding

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions