Chapter 09 / 09
ENSK

SmartExchange · .NET 10 · Chapter 09

InterestedParty read and Project Scope

Complete migration example: InterestedPartyRead assigned on a Project becomes InterestedParty/read scoped to that Project. Normalization and validation exist in source; the matching read policy and derived role are deployed.

Shared code · Intaker + Web APIReviewed · 2026-10-27

Both export examples: the complete path

StepPerson exportInterestedParty export
Role / assignmentCompanyReader → Company-AProject View / InterestedPartyRead → Project
Normalized grantCompany/read/ResourceInterestedParty/read/Project
Principal selectionPerson + CompanyInterestedParty + Project + Investor
Derived roleperson_with_readable_companyinterested_party_in_project_scope
Complete demo policyPerson / presentation-demo / exportInterestedParty / presentation-demo / export
Plan / SQLRequest · Expected AST · SQL EXISTS CompanyRequest · Expected AST · SQL EXISTS Project

Both complete derived roles/policies are shown side by side in Chapter 05; requests, expected plans and SQL are paired in Chapter 06. Both export examples are documentation proposals. The following migration reference preserves the separately verified InterestedParty/read behavior, its real captured AST and its deployment history.

1. Assignment → standard grant

The saved DEV snapshot (2026-09-17) contains role Project View, permission InterestedPartyRead and assignment a7f53aab-6f40-4204-b1d3-02ea54f1713e on Project b48e464a-88cf-4743-bbce-98d6ba89e399, for user 5a3e5b0f-fb6d-4abf-ba4f-8b581365a795.

LegacySqlAuthorizationGrantAdapter.Normalize() · isolated normalized fact
{
  "assignmentId": "a7f53aab-6f40-4204-b1d3-02ea54f1713e",
  "resourceId": "b48e464a-88cf-4743-bbce-98d6ba89e399",
  "resource": "InterestedParty",
  "action": "read",
  "scope": "Project",
  "isPublic": false
}

The permission identifies resource=InterestedParty and action=read. The assignment anchor becomes scope=Project; resourceId identifies that Project, not an InterestedParty. CRM permission metadata and the declared InterestedParty→Project relationship make this generic conversion possible.

SmartExchangeAuthorizationScopeValidator.Validate() checks the pair and ID. No new exception attribute is added. The role’s label does not decide the resource, and ordinary Project/read does not grant InterestedParty/read.

2. Resolver → planning request

SmartExchangeAuthorizationContextResolver.ResolveAsync() selects InterestedParty, Project and Investor grant kinds. The isolated example sends only the grant above, plus roles: ["user"] and attr.product. Real context may contain other qualifying grants; this is not the user’s complete current access.

Full PlanResources request · isolated example
HTTP JSON representation; the shared .NET client uses gRPC
{
  "requestId": "interested-party-project-scope-plan",
  "principal": {
    "id": "5a3e5b0f-fb6d-4abf-ba4f-8b581365a795",
    "roles": [
      "user"
    ],
    "attr": {
      "product": "API_Dev1",
      "grants": [
        {
          "assignmentId": "a7f53aab-6f40-4204-b1d3-02ea54f1713e",
          "resourceId": "b48e464a-88cf-4743-bbce-98d6ba89e399",
          "resource": "InterestedParty",
          "action": "read",
          "scope": "Project",
          "isPublic": false
        }
      ]
    }
  },
  "resource": {
    "kind": "InterestedParty",
    "policyVersion": "default",
    "attr": {}
  },
  "actions": [
    "read"
  ],
  "includeMeta": true
}

AuthorizationPlanRequest uses resource InterestedParty and action read. In list planning, record-specific projectIds stays unknown; the request does not enumerate InterestedParty IDs.

3. Deployed derived role + resource policy

Live derived-role-interested-party-project-scope · checked 2026-10-27
apiVersion: api.cerbos.dev/v1
description: InterestedParty read restricted by an explicit Project scope.
derivedRoles:
  name: interested_party_project_scope
  variables:
    local:
      projectScopeIds: >-
        has(P.attr.grants) ? P.attr.grants.filter(g,
          g.resource == "InterestedParty" &&
          g.action == "read" &&
          g.scope == "Project" &&
          g.resourceId != "00000000-0000-0000-0000-000000000000"
        ).map(g, g.resourceId) : []
  definitions:
    - name: interested_party_in_project_scope
      parentRoles: [user]
      condition:
        match:
          expr: >-
            R.kind == "InterestedParty" &&
            size(V.projectScopeIds) > 0 &&
            R.attr.projectIds.exists(projectId,
              projectId in V.projectScopeIds)
Live policy-interested-party · scoped-read fragment
# Extract from live InterestedParty/default.
# Existing export and other read rules are omitted here.
resourcePolicy:
  version: default
  resource: InterestedParty
  importDerivedRoles:
    - interested_party_project_scope
  rules:
    - name: read-in-project-scope
      actions: [read]
      derivedRoles: [interested_party_in_project_scope]
      effect: EFFECT_ALLOW

The role selects InterestedParty/read/Project grant IDs and compares them with the record’s projectIds. It does not select Project/read grants. The resource policy imports the set and explicitly allows read through the role. Other live read rules cover direct Resource scope and Instance scope.

Full live policy, including retained export rules · Live derived-role YAML

4. Actual Cerbos response → SQL

The response below was captured on 2026-10-26 for verification request verify-project-scope-loaded-20261026. It uses the same Project scope fact; it is a separate call from the illustrative request above. The AST is complete, not simplified.

Actual PlanResources response · complete JSON AST
Captured Cerbos response · KIND_CONDITIONAL
{
  "requestId": "verify-project-scope-loaded-20261026",
  "actions": [
    "read"
  ],
  "resourceKind": "InterestedParty",
  "policyVersion": "default",
  "filter": {
    "kind": "KIND_CONDITIONAL",
    "condition": {
      "expression": {
        "operator": "exists",
        "operands": [
          {
            "variable": "request.resource.attr.projectIds"
          },
          {
            "expression": {
              "operator": "lambda",
              "operands": [
                {
                  "expression": {
                    "operator": "eq",
                    "operands": [
                      {
                        "variable": "projectId"
                      },
                      {
                        "value": "b48e464a-88cf-4743-bbce-98d6ba89e399"
                      }
                    ]
                  }
                },
                {
                  "variable": "projectId"
                }
              ]
            }
          }
        ]
      }
    }
  },
  "meta": {
    "filterDebug": "(exists request.resource.attr.projectIds (lambda (eq projectId \"b48e464a-88cf-4743-bbce-98d6ba89e399\") projectId))",
    "matchedScopes": {
      "read": ""
    }
  },
  "cerbosCallId": "01M5W4E4R3TT90WTC83BBFBQCG"
}

CerbosQueryPlanMapper.Map() converts exists/lambda/eq to AuthorizationCollectionExists and a comparison. Registry mapping binds resource.attr.projectIds to InterestedParty→Project; SqlKataAuthorizationQueryAdapter.Apply() can translate the supported shape into EXISTS.

Equivalent SQL · target integration shape, not captured runtime SQL
-- Target SQL shape for the isolated InterestedParty/read Project-scope example.
-- Illustration, not captured runtime output. Bind @ProjectId from the approved plan.
-- Every schema/table/column mapping comes from trusted application metadata.
DECLARE @ProjectId uniqueidentifier = 'b48e464a-88cf-4743-bbce-98d6ba89e399';

SELECT ip.Id
FROM dbo.InterestedParty AS ip
WHERE ip.Deleted = 0
  AND EXISTS (
      SELECT 1
      FROM dbo.Project AS acl_project
      WHERE acl_project.Id = ip.ProjectId
        AND acl_project.Deleted = 0
        AND acl_project.Id = @ProjectId
  );
-- Ordinary business filters are ANDed with this predicate.
-- Apply authorization before COUNT, ORDER BY/OFFSET and export materialization.
-- No user-supplied ProjectId filter is required to activate this authorization.

These shared translation components exist. Actual Web API query enforcement and a real adapter→Cerbos→database test for this case are still pending.

5. Confirmed result and remaining integration

StepStatus
Generic scope normalization + validationImplemented in source; no C# build/test in this documentation update.
Policy and derived role in non-prodDeployed 2026-10-26; mounted YAML compiled again 2026-10-27.
Policy behavior27 API scenarios / 51 record decisions verified 2026-10-26. Matching project ALLOW; other/empty project DENY; ordinary Project/read DENY.
Web API and CheckResources integrationPending shared client method/resource-attribute provider and endpoint wiring.

For a concrete-record check the application must load trusted projectIds. For list planning the SQL adapter supplies the relation through the query. IQ500, portal and PmTask rules must use this same resource/action/scope model; PmTask special behavior still needs source investigation.

Plan request · Captured response · CheckResources request · SQL