Skip to content

Add multi-org child organization tools#14

Open
tftd wants to merge 2 commits into
mainfrom
feature/multi-org-child-org-tools
Open

Add multi-org child organization tools#14
tftd wants to merge 2 commits into
mainfrom
feature/multi-org-child-org-tools

Conversation

@tftd

@tftd tftd commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add child-org token support with Cisco's documented X-Umbrella-OrgId token-request header
  • add multi-org read tools for child organization discovery, destination-list inventory, and report queries
  • group multi-org responses by organizationId and include per-org errors
  • document the multi-org flow and add test coverage

Testing

  • PYTHONPATH=secure-access-mcp-community secureAccessMcpAgent/.venv/bin/python -m unittest discover -s secure-access-mcp-community/tests

@suaswath

Copy link
Copy Markdown
Collaborator

Tested against a live multi-org (provider) tenant — child-org discovery returns 0 orgs

Thanks for this PR! The X-Umbrella-OrgId child-token design is correct and works end-to-end. However, when tested against a real provider org with 19 child tenants, all three new tools (list_child_organizations, list_multi_org_destination_lists, get_multi_org_report) return count: 0. Root cause is in _get_child_organizations.

What's failing

  1. Primary endpoint is wrong. GET /admin/v2/tenants/list (no s) returns HTTP 404 ({"message": "no Route matched with those values"}) — that path does not exist.
  2. Fallback response can't be parsed. On 404 the code falls back to GET /admin/v2/tenants/lists, which does return 200 — but the body is a bare list of integer org IDs ([8345469, 8345470, …]), not objects. The original _extract_child_organizations only keeps dict items, so bare integers are dropped → parses to []count: 0.

What the docs say

  • Getting Started — Token Authorization Request for Multi-Org and Managed Child Organizations documents the child-token flow verbatim: POST /auth/v2/token with header X-Umbrella-OrgId: <child organizationId>. ✅ The PR implements this correctly.
  • The Multi-Tenants API is listed under Admin Resources, and the official cisco-secure-access-python-sdk (TenantsApi.list_tenants, generated from OpenAPI spec v2.0.202605140830) defines the listing endpoint as GET /admin/v2/tenants/listsList[TenantResponse]. This confirms the PR's primary /admin/v2/tenants/list is incorrect.

Suggested fix

Use the full-object endpoint first, keep the documented tenants/lists as a graceful fallback, and make the parser tolerate bare-ID lists:

async def _get_child_organizations(client: SecureAccessClient) -> list[dict[str, Any]]:
    # /admin/v2/tenants returns full objects (id + name). The documented
    # /admin/v2/tenants/lists returns a bare list of org IDs (no names);
    # legacy /tenants/list now 404s. Keep all three as narrow 404 fallbacks.
    last_exc: SecureAccessAPIError | None = None
    for endpoint in ("tenants", "tenants/list", "tenants/lists"):
        try:
            data = await client.get(ADMIN_SCOPE, endpoint)
        except SecureAccessAPIError as exc:
            if exc.status_code != 404:
                raise
            last_exc = exc
            continue
        organizations = _extract_child_organizations(data)
        if organizations:
            return organizations
    if last_exc is not None:
        raise last_exc
    return []
# in _extract_child_organizations(...), inside the `for item in data:` loop:
for item in data:
    if isinstance(item, dict):
        org_id = _first_scalar(item, ("organizationId", "organization_id", "orgId", "org_id", "id"))
        org_name = _first_scalar(
            item, ("organizationName", "organization_name", "orgName", "org_name", "name", "label")
        )
    elif isinstance(item, (str, int)) and not isinstance(item, bool):
        # The documented /admin/v2/tenants/lists endpoint returns a bare
        # list of tenant organization IDs without names.
        org_id = str(item).strip()
        org_name = None
    else:
        continue
    if not org_id or org_id in seen:
        continue
    seen.add(org_id)
    organizations.append({"organizationId": org_id, "organizationName": org_name})

⚠️ Caveat on the tenants/lists fallback

The documented GET /admin/v2/tenants/lists endpoint works (200) but returns only org IDs — no organization names (verified live: response is [8345469, 8345470, …]). So if discovery ever falls back to it, results will have organizationName: null. The undocumented-but-functional GET /admin/v2/tenants returns full objects with names, which is why the fix prefers it first and treats tenants/lists as the spec-compliant degrade path (IDs only).

Verification after the fix

  • list_child_organizations19 orgs with names (Paradigm Enterprises, Spectrum Analytics, Zenith Manufacturing, …)
  • list_multi_org_destination_lists → real per-org destination lists, all status: ok
  • get_multi_org_report (/reports/v2/top-categories) → real per-org report data
  • Existing unit tests still pass (14/14)

@suaswath

Copy link
Copy Markdown
Collaborator

Live curl evidence — failing path vs. working path

All credentials, tokens, organization IDs, and organization names below are masked.

1. PR's primary endpoint → fails (404)

curl -s -w "HTTP %{http_code}\n" \
  --request GET --url 'https://api.sse.cisco.com/admin/v2/tenants/list' \
  -H 'Authorization: Bearer <token>'
HTTP 404
{
  "message": "no Route matched with those values",
  "request_id": "<masked>"
}

2. Documented fallback tenants/lists → 200, but IDs only (no names)

curl -s -w "HTTP %{http_code}\n" \
  --request GET --url 'https://api.sse.cisco.com/admin/v2/tenants/lists' \
  -H 'Authorization: Bearer <token>'
HTTP 200
[8XXXXXX, 8XXXXXX, 8XXXXXX, … (19 org IDs, no names)]

3. tenants (the fix's primary) → 200 with full objects incl. names

curl -s -w "HTTP %{http_code}\n" \
  --request GET --url 'https://api.sse.cisco.com/admin/v2/tenants' \
  -H 'Authorization: Bearer <token>'
HTTP 200
[
  {
    "id": 8XXXXXX,
    "name": "<org-name>",
    "isSSE": true,
    "isTrial": false,
    "licenseType": "TERM",
    "packageName": "Cisco Secure Access - Advantage",
    "seats": 100,
    "isOnboardingComplete": false,
    "createdAt": <ts>,
    "createdBy": "Operations Team"
  },
  … (19 objects total)
]

Takeaway: tenants/list doesn't exist (404); tenants/lists works but returns IDs only (→ organizationName: null); tenants returns full objects with names — which is why the fix uses tenants as primary and tenants/lists as the graceful fallback.

… id+name

The previous /admin/v2/tenants/list endpoint returns 404 and the bare /tenants/lists returns ID-only arrays, yielding count: 0. Query the documented /admin/v2/tenants/lists with optionalFields=[organizationName,includeUmbrellaOrgs] so every child org returns organizationId + organizationName, and harden the parser to accept both object and bare-ID list responses.
@oborys oborys self-requested a review June 30, 2026 10:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants