diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50fcc9e..a0a79e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,3 +75,19 @@ jobs: - name: Verify embodied-action receipt fixtures run: python -m unittest discover -s tests -v + + multi-tenant-saas: + runs-on: ubuntu-latest + defaults: + run: + working-directory: multi-tenant-saas + steps: + - uses: actions/checkout@v7 + + - name: Set up Python 3.11 + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Run people directory tests + run: python -m unittest discover -s tests -v diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 767e373..d3aeb59 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -34,16 +34,16 @@ jobs: config: cmcp-config.yaml agent: agent/clinical_decision_agent.py args: "" - - name: multi-tenant-acme-corp + - name: multi-tenant-metzler-eu dir: multi-tenant-saas - config: cmcp-config-acme-corp.yaml + config: cmcp-config-metzler-eu.yaml agent: agent/saas_agent.py - args: "--tenant acme-corp" - - name: multi-tenant-globex-financial + args: "--tenant metzler-eu" + - name: multi-tenant-summit-us dir: multi-tenant-saas - config: cmcp-config-globex-financial.yaml + config: cmcp-config-summit-us.yaml agent: agent/saas_agent.py - args: "--tenant globex-financial" + args: "--tenant summit-us" steps: - uses: actions/checkout@v7 diff --git a/README.md b/README.md index f4911f5..1552b99 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ End-to-end integration examples showing cMCP, Agent Manifest, and TRACE working | `financial-services/` | Credit risk agent: MiFID II escalation deny above EUR 500k with structured policy advice | SEV-SNP / TDX | EU AI Act Art. 9/12, MiFID II Art. 25, DORA Art. 9 | | `healthcare/` | Clinical decision agent: EU AI Act Art. 14 HITL deny on high-risk treatment plans | SEV-SNP / TDX | EU AI Act Art. 14, HIPAA | | `industrial-embodied-ai/` | Material-movement agent with cMCP authorization, an independent safety-controller boundary and offline-verifiable closed-session evidence | TEE / software-only development mode | OT security and industrial robot safety references | -| `multi-tenant-saas/` | Per-tenant Cedar policy bundles and enforcement modes (advisory vs enforcing) | TDX | GDPR Art. 6, customer contract SLA | +| `multi-tenant-saas/` | HR SaaS with an EU tenant (enforcing GDPR residency/Art. 9) and a US tenant (advisory) on one catalog | TDX | GDPR Art. 6/9/44, customer DPA | | `startup-tpm/` | 15-minute quickstart on any cloud VM with Trusted Launch | TPM 2.0 | Development / staging | Each example is fully runnable with no external dependencies: it ships a mock upstream MCP server, an agent script, an attested tool catalog, and a Cedar policy bundle, and ends by printing the signed TRACE Trust Record for the session. The `trace-output/` files in each example are captured from real runs. diff --git a/multi-tenant-saas/README.md b/multi-tenant-saas/README.md index 4a1f646..de832ae 100644 --- a/multi-tenant-saas/README.md +++ b/multi-tenant-saas/README.md @@ -1,27 +1,35 @@ # multi-tenant-saas: Per-Tenant Cedar Policy Isolation -End-to-end demo of a SaaS platform serving tenants with different compliance postures, each enforced by a separate Cedar policy bundle - and different *enforcement modes* - in the cMCP Runtime. +PeopleGraph is a fictional HR / people-analytics SaaS. It serves two tenants on the same platform and the same tool catalog, but each tenant has its own data processing agreement, its own Cedar policy bundle, and its own enforcement mode in the cMCP Runtime. -The same three tool calls produce different outcomes per tenant: +| | `metzler-eu` | `summit-us` | +|---|---|---| +| Employer | Metzler Retail GmbH (EU) | Summit Brands Inc (US) | +| Contract | EU data residency, declared legal basis, no special-category processing | US regions allowed, no legal-basis requirement | +| Enforcement | `enforcing` (violations block) | `advisory` (violations logged, not blocked) | +| Policy version | `metzler-eu-v1.0` | `summit-us-v1.0` | + +The **same four tool calls** produce different outcomes per tenant: -| Tool | acme-corp (advisory) | globex-financial (enforcing) | +| Call | `metzler-eu` (enforcing) | `summit-us` (advisory) | |---|---|---| -| `saas.analytics_query` | allow | allow | -| `saas.user_data_export` | advisory_deny (logged, not blocked) | deny | -| `saas.config_update` | allow | deny | +| `people.headcount_analytics` | allow | allow | +| `people.employee_record_lookup` (with legal basis) | allow | allow | +| `people.data_export` to `us-east-1` | **deny** (GDPR data residency) | allow | +| `people.employee_record_lookup` special-category | **deny** (GDPR Art. 9) | advisory_deny (logged) | --- ## What the demo shows **1. Policy-as-isolation at the tool boundary** -Each tenant has its own Cedar bundle under `tenants//policy/` and its own runtime config pointing at it. The `trace.policy.version` field in each TRACE record identifies exactly which tenant policy was enforced (`acme-corp-v1.0` vs `globex-financial-v3.2`). +Each tenant has its own Cedar bundle under `tenants//policy/` and its own runtime config pointing at it. The `trace.policy.version` field in each TRACE record identifies exactly which tenant policy was enforced. -**2. Progressive compliance posture via enforcement mode** -Acme Corp runs `enforcement_mode: advisory`: a matched forbid is logged in the audit chain and surfaced as `would_have_denied` + advice in the response metadata, but the call proceeds. Globex Financial runs `enforcing` with no catch-all permit - Cedar's default-deny blocks anything not explicitly permitted. +**2. The tenants differ by real contract terms, not just a flag** +Metzler's EU processing agreement is encoded as three GDPR guardrails: a declared legal basis for personal-data processing (Art. 6), EEA data residency (Art. 44-45), and no special-category processing through the agent (Art. 9). Summit's US agreement carries none of those; it only flags special-category access for review. The bundles genuinely differ. -**3. Structured advice on denies** -Both tenants' forbid rules carry `@annotation` metadata (GDPR article, required workflow) that the runtime returns to the caller - in `error.data.advice` for hard denies, in `_cmcp.advice` for advisory ones. +**3. Progressive posture via enforcement mode** +Metzler runs `enforcing`: a matched forbid blocks the call. Summit runs `advisory`: the same match is recorded in the audit chain and surfaced as `would_have_denied` + advice in the response metadata, but the call proceeds. The advisory record still counts the matched forbid in `gateway.call_summary.tool_calls_denied`, so an auditor sees what *would* have been blocked. --- @@ -29,23 +37,36 @@ Both tenants' forbid rules carry `@annotation` metadata (GDPR article, required ``` multi-tenant-saas/ - cmcp-config-acme-corp.yaml advisory mode -> tenants/acme-corp/policy - cmcp-config-globex-financial.yaml enforcing mode -> tenants/globex-financial/policy - catalog.json shared three-tool catalog + cmcp-config-metzler-eu.yaml enforcing -> tenants/metzler-eu/policy + cmcp-config-summit-us.yaml advisory -> tenants/summit-us/policy + catalog.json shared four-tool catalog + people_directory.py employee fixtures + tool logic (one source of truth) tenants/ - acme-corp/policy/ acme-corp-v1.0 (permissive) - globex-financial/policy/ globex-financial-v3.2 (default-deny) - server/ - mock_mcp_server.py mock upstream MCP server (stdlib only) - agent/ - saas_agent.py demo agent (run this) + metzler-eu/policy/ metzler-eu-v1.0 (GDPR guardrails, enforced) + summit-us/policy/ summit-us-v1.0 (permissive, advisory) + server/mock_mcp_server.py mock PeopleGraph MCP server (stdlib only) + agent/saas_agent.py demo agent (run this) + tests/test_people_directory.py unit tests trace-output/ - acme-corp-example.json real captured TRACE record (advisory) - globex-financial-example.json real captured TRACE record (enforcing) + metzler-eu-example.json real captured TRACE record (enforcing) + summit-us-example.json real captured TRACE record (advisory) ``` --- +## The catalog + +| Tool | compliance_domain | sensitivity_level | +|---|---|---| +| `people.headcount_analytics` | internal | public | +| `people.employee_record_lookup` | pii | confidential | +| `people.data_export` | pii | confidential | +| `people.config_update` | internal | public | + +`people.data_export` and `people.employee_record_lookup` accept a `legal_basis` argument, `data_export` a `destination_region`, and the lookup an `include_special_category` flag. The Cedar guardrails act on those arguments. + +--- + ## Run it ```bash @@ -61,94 +82,99 @@ cd multi-tenant-saas python server/mock_mcp_server.py ``` -**Terminal 2 - runtime with Acme Corp's policy** (run from inside `multi-tenant-saas/`): +**Terminal 2 - runtime with Metzler's (EU) policy** (run from inside `multi-tenant-saas/`): ```bash cd multi-tenant-saas -CMCP_DEV_MODE=1 cmcp start --config cmcp-config-acme-corp.yaml +CMCP_DEV_MODE=1 cmcp start --config cmcp-config-metzler-eu.yaml ``` **Terminal 3 - agent:** ```bash cd examples -python multi-tenant-saas/agent/saas_agent.py --tenant acme-corp +python multi-tenant-saas/agent/saas_agent.py --tenant metzler-eu ``` ``` -[1/3] Calling saas.analytics_query ... - -> decision: allow -[2/3] Calling saas.user_data_export ... - -> decision: advisory_deny (logged, not blocked) +[3/4] people.data_export (scope=engineering, destination_region=us-east-1) + -> decision: deny (POLICY_DENY) advice from policy: - id: gdpr-justification-missing - reason: gdpr-justification-missing - regulation: gdpr-art-6 -[3/3] Calling saas.config_update ... - -> decision: allow + id: data-residency-eea + reason: eea-data-residency-required + regulation: gdpr-art-44 +[4/4] people.employee_record_lookup (employee_id=EMP-DE-4821, include_special_category=True) + -> decision: deny (POLICY_DENY) + advice from policy: + id: special-category-block + reason: special-category-processing-prohibited + regulation: gdpr-art-9 ``` -**Switch tenants** - stop the runtime (Ctrl-C) and restart with Globex Financial's config: +**Switch tenants** - stop the runtime (Ctrl-C) and restart with Summit's (US) config: ```bash -CMCP_DEV_MODE=1 cmcp start --config cmcp-config-globex-financial.yaml +CMCP_DEV_MODE=1 cmcp start --config cmcp-config-summit-us.yaml +python multi-tenant-saas/agent/saas_agent.py --tenant summit-us ``` -```bash -python multi-tenant-saas/agent/saas_agent.py --tenant globex-financial ``` - -``` -[1/3] Calling saas.analytics_query ... +[3/4] people.data_export (scope=engineering, destination_region=us-east-1) -> decision: allow -[2/3] Calling saas.user_data_export ... - -> decision: deny (POLICY_DENY) - advice from policy: - id: export-requires-compliance-workflow - reason: export-requires-data-compliance-workflow - regulation: gdpr-art-6 -[3/3] Calling saas.config_update ... - -> decision: deny (POLICY_DENY) +[4/4] people.employee_record_lookup (employee_id=EMP-DE-4821, include_special_category=True) + -> decision: advisory_deny (logged, not blocked) advice from policy: - id: config-update-requires-admin-workflow - reason: config-update-requires-admin-workflow + id: special-category-review + reason: special-category-access-flagged-for-review + regulation: us-state-privacy ``` -Each run ends by closing the session and printing the signed TRACE Trust Record. Compare `trace.policy.version` and `gateway.call_summary.tool_calls_denied` across the two captured examples in `trace-output/`. +Each run ends by closing the session and printing the signed TRACE Trust Record. Compare `trace.policy.version`, `trace.policy.enforcement_mode` and `gateway.call_summary.tool_calls_denied` across the two captured examples in `trace-output/`. --- ## How the policies differ -**Acme Corp** (`tenants/acme-corp/policy/allow.cedar`): a catch-all permit plus one annotated forbid - user data export without a `gdpr_justification` argument. Under advisory mode this logs and flags but does not block. - -**Globex Financial** (`tenants/globex-financial/policy/allow.cedar`): no catch-all. Explicit permits per tool, gated on the workflow the agent declares via `_cmcp.workflow_id`: +**Metzler (`tenants/metzler-eu/policy/allow.cedar`, enforcing):** aggregate analytics are open; individual lookups and exports are gated on the `people-analytics` workflow; and three GDPR forbids block the call when a control fails: ```cedar -permit ( +@id("data-residency-eea") +@reason("eea-data-residency-required") +@regulation("gdpr-art-44") +forbid ( principal, - action == Action::"Saas.userDataExport", + action == Action::"People.dataExport", resource ) when { - context has workflow_id && - context.workflow_id == "data-compliance-workflow" + context.arguments has destination_region && + !(["eu-central-1", "eu-west-1", "eu-north-1", "eu-west-3"].contains(context.arguments.destination_region)) }; ``` -The demo agent runs as `analytics-workflow`, so exports and config updates deny. Annotated forbid rules make those denies carry structured advice instead of being silent default-denies. +**Summit (`tenants/summit-us/policy/allow.cedar`, advisory):** a catch-all permit plus a single forbid that flags special-category access for review. Under advisory mode this logs and surfaces advice but does not block. + +Action names follow the cMCP convention: `people.data_export` becomes `Action::"People.dataExport"` (the segment before the dot PascalCase, each underscore segment after it camelCase). Tool arguments are available under `context.arguments`; the `@annotation` values are returned in `error.data.advice` for hard denies and in `_cmcp.advice` for advisory ones. --- ## Running both tenants simultaneously -Run two runtimes on different ports (edit `listen_addr` in one config), one per tenant, against the same mock server: +This is the production topology: one attested runtime instance per tenant isolation boundary, each measuring its own policy bundle hash into its TRACE records. Run two runtimes on different ports (edit `listen_addr` in one config) against the same mock server: ```bash -python multi-tenant-saas/agent/saas_agent.py --tenant acme-corp --gateway http://localhost:8443 -python multi-tenant-saas/agent/saas_agent.py --tenant globex-financial --gateway http://localhost:9443 +python multi-tenant-saas/agent/saas_agent.py --tenant metzler-eu --gateway http://localhost:8443 +python multi-tenant-saas/agent/saas_agent.py --tenant summit-us --gateway http://localhost:9443 ``` -This is the production topology: one attested runtime instance per tenant isolation boundary, each measuring its own policy bundle hash into its TRACE records. +--- + +## The tests + +`tests/test_people_directory.py` checks that headcount output is aggregate-only, that special-category is a request flag rather than emitted data, and that EEA region classification and per-region currency are correct. + +```bash +python -m unittest discover -s tests -v +``` --- diff --git a/multi-tenant-saas/agent/saas_agent.py b/multi-tenant-saas/agent/saas_agent.py index 15ba5db..81aac23 100644 --- a/multi-tenant-saas/agent/saas_agent.py +++ b/multi-tenant-saas/agent/saas_agent.py @@ -1,18 +1,19 @@ #!/usr/bin/env python3 """ -SaaS platform agent demo showing per-tenant Cedar policy isolation. +PeopleGraph agent demo showing per-tenant Cedar policy isolation. -Runs the same three tool calls; the outcome depends on which tenant's policy -bundle the runtime was started with: +Runs the same four tool calls; the outcome depends on which tenant's policy +bundle and enforcement mode the runtime was started with: - acme-corp (advisory): analytics allow, user_data_export - advisory_deny with GDPR advice (logged, not blocked), - config_update allow - globex-financial (enforcing): analytics allow, user_data_export deny, - config_update deny -- both with structured advice + metzler-eu (EU, enforcing): + headcount allow, employee lookup allow, cross-region export DENY + (GDPR data residency), special-category lookup DENY (GDPR Art. 9) + summit-us (US, advisory): + headcount allow, employee lookup allow, export allow, + special-category lookup advisory_deny (logged, not blocked) Usage: - python saas_agent.py --tenant acme-corp|globex-financial [--gateway http://localhost:8443] + python saas_agent.py --tenant metzler-eu|summit-us [--gateway http://localhost:8443] """ import argparse @@ -20,11 +21,26 @@ import sys import httpx -DEFAULT_GATEWAY = "http://localhost:8443" -WORKFLOW_ID = "analytics-workflow" - +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") -def call_tool(client: httpx.Client, gateway: str, tool_name: str, arguments: dict, req_id: int) -> dict: +DEFAULT_GATEWAY = "http://localhost:8443" +WORKFLOW_ID = "people-analytics" + +# The same four calls run against whichever tenant policy the runtime loaded. +CALLS = [ + ("people.headcount_analytics", + {"metric": "attrition", "period": "2026-Q2"}), + ("people.employee_record_lookup", + {"employee_id": "EMP-DE-4821", "legal_basis": "legitimate_interest"}), + ("people.data_export", + {"scope": "engineering", "destination_region": "us-east-1", "legal_basis": "legitimate_interest"}), + ("people.employee_record_lookup", + {"employee_id": "EMP-DE-4821", "include_special_category": True, "legal_basis": "legitimate_interest"}), +] + + +def call_tool(client, gateway, tool_name, arguments, req_id): payload = { "jsonrpc": "2.0", "id": req_id, @@ -40,68 +56,52 @@ def call_tool(client: httpx.Client, gateway: str, tool_name: str, arguments: dic if "error" in body: return {"ok": False, "error": body["error"], "session_id": None} result = body["result"] - return { - "ok": True, - "result": result, - "session_id": result.get("_cmcp", {}).get("session_id"), - } + return {"ok": True, "result": result, "session_id": result.get("_cmcp", {}).get("session_id")} -def print_outcome(outcome: dict) -> None: +def print_outcome(outcome): if outcome["ok"]: meta = outcome["result"].get("_cmcp", {}) if meta.get("would_have_denied"): print(" -> decision: advisory_deny (logged, not blocked)") - advice = meta.get("advice") - if advice: - print(" advice from policy:") - for key, value in advice.items(): - print(f" {key}: {value}") + _print_advice(meta.get("advice")) else: print(" -> decision: allow") else: data = outcome["error"].get("data", {}) print(f" -> decision: deny ({data.get('error_code', 'unknown')})") - advice = data.get("advice") - if advice: - print(" advice from policy:") - for key, value in advice.items(): - print(f" {key}: {value}") + _print_advice(data.get("advice")) + + +def _print_advice(advice): + if advice: + print(" advice from policy:") + for key, value in advice.items(): + print(f" {key}: {value}") -def close_session(client: httpx.Client, gateway: str, session_id: str) -> dict: +def close_session(client, gateway, session_id): resp = client.post(f"{gateway}/sessions/{session_id}/close", timeout=10) resp.raise_for_status() return resp.json() -def run(gateway: str, tenant: str) -> None: +def run(gateway, tenant): print(f"Connecting to cMCP Runtime at {gateway}") print(f"Tenant: {tenant}") print(f"Workflow: {WORKFLOW_ID}") print() - print(f"Running the same three tool calls against {tenant}'s policy bundle.") + print(f"Running the same {len(CALLS)} tool calls against {tenant}'s policy bundle.") print() session_id = None with httpx.Client(headers={"Content-Type": "application/json"}) as client: - print("[1/3] Calling saas.analytics_query ...") - o1 = call_tool(client, gateway, "saas.analytics_query", - {"metric": "daily_active_users", "time_range_days": 30}, 1) - print_outcome(o1) - session_id = o1.get("session_id") or session_id - - print("[2/3] Calling saas.user_data_export ...") - o2 = call_tool(client, gateway, "saas.user_data_export", - {"user_id": "usr_abc123", "format": "json"}, 2) - print_outcome(o2) - session_id = o2.get("session_id") or session_id - - print("[3/3] Calling saas.config_update ...") - o3 = call_tool(client, gateway, "saas.config_update", - {"key": "session_timeout_minutes", "value": "60"}, 3) - print_outcome(o3) - session_id = o3.get("session_id") or session_id + for i, (tool, args) in enumerate(CALLS, start=1): + summary = ", ".join(f"{k}={v}" for k, v in args.items() if k != "legal_basis") + print(f"[{i}/{len(CALLS)}] {tool} ({summary})") + o = call_tool(client, gateway, tool, args, i) + print_outcome(o) + session_id = o.get("session_id") or session_id print() if session_id is None: @@ -119,11 +119,10 @@ def run(gateway: str, tenant: str) -> None: if __name__ == "__main__": - parser = argparse.ArgumentParser(description="SaaS multi-tenant policy isolation demo") + parser = argparse.ArgumentParser(description="PeopleGraph multi-tenant policy isolation demo") parser.add_argument("--gateway", default=DEFAULT_GATEWAY, help=f"cMCP Runtime base URL (default: {DEFAULT_GATEWAY})") - parser.add_argument("--tenant", required=True, - choices=["acme-corp", "globex-financial"], + parser.add_argument("--tenant", required=True, choices=["metzler-eu", "summit-us"], help="Which tenant config the runtime was started with") args = parser.parse_args() run(args.gateway, args.tenant) diff --git a/multi-tenant-saas/catalog.json b/multi-tenant-saas/catalog.json index a3a8165..d0dcb5c 100644 --- a/multi-tenant-saas/catalog.json +++ b/multi-tenant-saas/catalog.json @@ -1,82 +1,137 @@ [ { - "tool_name": "saas.user_data_export", + "tool_name": "people.headcount_analytics", "server": { - "display_name": "SaaS Platform MCP Server", + "display_name": "PeopleGraph MCP Server", "url": "http://localhost:8080/mcp", "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "transport": "http-sse" }, "approved_definition": { - "description": "Export user account and usage data for the tenant", + "description": "Run a read-only, aggregate people-analytics query (no individual records)", "input_schema": { "type": "object", "properties": { - "user_id": {"type": "string"}, - "format": {"type": "string", "enum": ["json", "csv"]}, - "gdpr_justification": {"type": "string"} + "metric": { + "type": "string" + }, + "period": { + "type": "string" + } }, - "required": ["user_id"] + "required": [ + "metric" + ] } }, - "definition_hash": "sha256:f12befaccc0eb9801583cb94d6902f6899364d13a3d6b2f8c07f450aee4dc521", + "definition_hash": "sha256:08676333bb67c406a83409a0c806535b6431685b49898e3881a5982bae94a252", + "compliance_domain": "internal", + "requires_baa": false, + "sensitivity_level": "public", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "platform-security@peoplegraph.example" + }, + { + "tool_name": "people.employee_record_lookup", + "server": { + "display_name": "PeopleGraph MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Look up an individual employee record", + "input_schema": { + "type": "object", + "properties": { + "employee_id": { + "type": "string" + }, + "legal_basis": { + "type": "string" + }, + "include_special_category": { + "type": "boolean" + } + }, + "required": [ + "employee_id" + ] + } + }, + "definition_hash": "sha256:1bb7cce8b5654794544ac88a146c30be7802a9873f9bd3c3e44963552eab6bf0", "compliance_domain": "pii", "requires_baa": false, "sensitivity_level": "confidential", "added_at": "2026-06-01T00:00:00Z", - "approved_by": "platform-security@saas.example" + "approved_by": "platform-security@peoplegraph.example" }, { - "tool_name": "saas.analytics_query", + "tool_name": "people.data_export", "server": { - "display_name": "SaaS Platform MCP Server", + "display_name": "PeopleGraph MCP Server", "url": "http://localhost:8080/mcp", "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "transport": "http-sse" }, "approved_definition": { - "description": "Run a read-only analytics query over tenant event data", + "description": "Export employee records to a destination region", "input_schema": { "type": "object", "properties": { - "metric": {"type": "string"}, - "time_range_days": {"type": "integer"}, - "filters": {"type": "object"} + "scope": { + "type": "string" + }, + "destination_region": { + "type": "string" + }, + "legal_basis": { + "type": "string" + } }, - "required": ["metric"] + "required": [ + "destination_region" + ] } }, - "definition_hash": "sha256:dbfe9ce96ebc7580a162e45e82c65c076e62f39c293c32b93ed1ce20a4b59655", - "compliance_domain": "internal", + "definition_hash": "sha256:9cf09bc74122256f4fdd6a8d8f6623b085936cce3cc95af5ea993343ab5a577e", + "compliance_domain": "pii", "requires_baa": false, - "sensitivity_level": "public", + "sensitivity_level": "confidential", "added_at": "2026-06-01T00:00:00Z", - "approved_by": "platform-security@saas.example" + "approved_by": "platform-security@peoplegraph.example" }, { - "tool_name": "saas.config_update", + "tool_name": "people.config_update", "server": { - "display_name": "SaaS Platform MCP Server", + "display_name": "PeopleGraph MCP Server", "url": "http://localhost:8080/mcp", "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "transport": "http-sse" }, "approved_definition": { - "description": "Update platform configuration settings for the tenant", + "description": "Update tenant configuration settings", "input_schema": { "type": "object", "properties": { - "key": {"type": "string"}, - "value": {"type": "string"} + "key": { + "type": "string" + }, + "value": { + "type": "string" + } }, - "required": ["key", "value"] + "required": [ + "key", + "value" + ] } }, - "definition_hash": "sha256:9bf90691ce6f3ed9a18da33b6b41f71d12ee77e51d8a81137c1723ea97e1647a", + "definition_hash": "sha256:1761ee6862e8509983983543450a6bda6ea1e91536492daeb8d11b0b380e163c", "compliance_domain": "internal", "requires_baa": false, "sensitivity_level": "public", "added_at": "2026-06-01T00:00:00Z", - "approved_by": "platform-security@saas.example" + "approved_by": "platform-security@peoplegraph.example" } ] diff --git a/multi-tenant-saas/cmcp-config-acme-corp.yaml b/multi-tenant-saas/cmcp-config-acme-corp.yaml deleted file mode 100644 index cc518f8..0000000 --- a/multi-tenant-saas/cmcp-config-acme-corp.yaml +++ /dev/null @@ -1,10 +0,0 @@ -policy_bundle_path: ./tenants/acme-corp/policy -catalog_path: ./catalog.json -listen_addr: 0.0.0.0:8443 -audit_db_path: ./audit-acme-corp.db -attestation: - provider: auto - # Acme Corp runs advisory: policy violations are logged in the audit chain - # and surfaced as would_have_denied + advice, but calls are not blocked. - enforcement_mode: advisory - validity_seconds: 86400 diff --git a/multi-tenant-saas/cmcp-config-globex-financial.yaml b/multi-tenant-saas/cmcp-config-globex-financial.yaml deleted file mode 100644 index 402adf6..0000000 --- a/multi-tenant-saas/cmcp-config-globex-financial.yaml +++ /dev/null @@ -1,8 +0,0 @@ -policy_bundle_path: ./tenants/globex-financial/policy -catalog_path: ./catalog.json -listen_addr: 0.0.0.0:8443 -audit_db_path: ./audit-globex-financial.db -attestation: - provider: auto - enforcement_mode: enforcing - validity_seconds: 86400 diff --git a/multi-tenant-saas/cmcp-config-metzler-eu.yaml b/multi-tenant-saas/cmcp-config-metzler-eu.yaml new file mode 100644 index 0000000..9a553e7 --- /dev/null +++ b/multi-tenant-saas/cmcp-config-metzler-eu.yaml @@ -0,0 +1,9 @@ +policy_bundle_path: ./tenants/metzler-eu/policy +catalog_path: ./catalog.json +listen_addr: 0.0.0.0:8443 +audit_db_path: ./audit-metzler-eu.db +attestation: + provider: auto + # Metzler (EU) runs enforcing: GDPR guardrails block the call. + enforcement_mode: enforcing + validity_seconds: 86400 diff --git a/multi-tenant-saas/cmcp-config-summit-us.yaml b/multi-tenant-saas/cmcp-config-summit-us.yaml new file mode 100644 index 0000000..03445f7 --- /dev/null +++ b/multi-tenant-saas/cmcp-config-summit-us.yaml @@ -0,0 +1,10 @@ +policy_bundle_path: ./tenants/summit-us/policy +catalog_path: ./catalog.json +listen_addr: 0.0.0.0:8443 +audit_db_path: ./audit-summit-us.db +attestation: + provider: auto + # Summit (US) runs advisory: a matched forbid is logged and surfaced as + # would_have_denied + advice, but the call is not blocked. + enforcement_mode: advisory + validity_seconds: 86400 diff --git a/multi-tenant-saas/people_directory.py b/multi-tenant-saas/people_directory.py new file mode 100644 index 0000000..dbcc031 --- /dev/null +++ b/multi-tenant-saas/people_directory.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +People-directory domain logic for the multi-tenant-saas example. + +PeopleGraph is a fictional HR / people-analytics SaaS. This module holds the +employee fixtures and the pure functions the mock MCP server serves, so the +server, the tests and the agent agree on one source of truth. No dependencies. + +The data is deliberately free of phone numbers, e-mail addresses and other +patterns a response scanner would flag, so the demo turns purely on the +per-tenant policy difference rather than on incidental PII detections. +""" + +from __future__ import annotations + +from typing import Any + +# Regions that count as inside the EEA for data-residency purposes. +EEA_REGIONS = ["eu-central-1", "eu-west-1", "eu-north-1", "eu-west-3"] + +EMPLOYEES: dict[str, dict[str, Any]] = { + "EMP-DE-4821": { + "name": "Katharina Vogel", + "role": "Senior Data Engineer", + "department": "Engineering", + "location": "Munich, DE", + "region": "eu-central-1", + "manager_id": "EMP-DE-3300", + "comp_band": "L5", + "base_salary_eur": 96_000, + "tenure_years": 4.2, + }, + "EMP-DE-3300": { + "name": "Stefan Brandt", + "role": "Engineering Manager", + "department": "Engineering", + "location": "Munich, DE", + "region": "eu-central-1", + "manager_id": None, + "comp_band": "M2", + "base_salary_eur": 128_000, + "tenure_years": 7.1, + }, + "EMP-US-1099": { + "name": "Marcus Bell", + "role": "Product Manager", + "department": "Product", + "location": "Columbus, US", + "region": "us-east-1", + "manager_id": "EMP-US-1000", + "comp_band": "L5", + "base_salary_usd": 132_000, + "tenure_years": 3.4, + }, +} + +DEFAULT_EMPLOYEE = "EMP-DE-4821" + + +def headcount_analytics(metric: str = "attrition", period: str = "2026-Q2") -> dict[str, Any]: + """Aggregate, non-identifying people metrics.""" + by_department: dict[str, int] = {} + for emp in EMPLOYEES.values(): + by_department[emp["department"]] = by_department.get(emp["department"], 0) + 1 + return { + "metric": metric, + "period": period, + "headcount_total": len(EMPLOYEES), + "headcount_by_department": by_department, + "attrition_rate": 0.081, + "voluntary_share": 0.63, + "status": "completed", + } + + +def employee_record_lookup(employee_id: str, include_special_category: bool = False) -> dict[str, Any]: + """Return a single employee record. + + Special-category data (GDPR Art. 9) is never emitted here; access to it is a + request attribute the policy decides on, so the sensitive fields do not flow + at all when a tenant forbids them. + """ + emp = EMPLOYEES.get(employee_id, EMPLOYEES[DEFAULT_EMPLOYEE]) + record = { + "employee_id": employee_id, + "name": emp["name"], + "role": emp["role"], + "department": emp["department"], + "location": emp["location"], + "region": emp["region"], + "manager_id": emp["manager_id"], + "comp_band": emp["comp_band"], + "tenure_years": emp["tenure_years"], + } + if "base_salary_eur" in emp: + record["base_salary_eur"] = emp["base_salary_eur"] + if "base_salary_usd" in emp: + record["base_salary_usd"] = emp["base_salary_usd"] + record["special_category_included"] = bool(include_special_category) + record["status"] = "retrieved" + return record + + +def data_export(scope: str, destination_region: str) -> dict[str, Any]: + """Export a set of employee records to a destination region.""" + matched = [e for e in EMPLOYEES.values() if scope in ("all", e["department"].lower())] + return { + "scope": scope, + "destination_region": destination_region, + "destination_in_eea": destination_region in EEA_REGIONS, + "records_exported": len(matched) or len(EMPLOYEES), + "export_id": "EXP-2026-00917", + "status": "exported", + } + + +def config_update(key: str, value: str) -> dict[str, Any]: + return { + "key": key, + "value": value, + "previous_value": "30", + "status": "updated", + } diff --git a/multi-tenant-saas/server/mock_mcp_server.py b/multi-tenant-saas/server/mock_mcp_server.py index 6ef5f4a..2022eaa 100644 --- a/multi-tenant-saas/server/mock_mcp_server.py +++ b/multi-tenant-saas/server/mock_mcp_server.py @@ -1,53 +1,54 @@ #!/usr/bin/env python3 """ -Mock SaaS Platform MCP Server for the multi-tenant-saas demo. +Mock PeopleGraph MCP Server for the multi-tenant-saas demo. -Serves the three catalog tools with canned responses on port 8080. -Stdlib only -- no dependencies. +Serves the four catalog tools on port 8080. Tool responses come from +``people_directory`` so the server, the tests and the agent share one source +of truth. Stdlib only -- no dependencies. Usage: python multi-tenant-saas/server/mock_mcp_server.py """ import json +import sys from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_DIR)) + +import people_directory # noqa: E402 PORT = 8080 -def _user_data_export(args: dict) -> str: - return json.dumps({ - "user_id": args.get("user_id", ""), - "format": args.get("format", "json"), - "records_exported": 1342, - "export_id": "EXP-2026-00917", - "status": "exported", - }) +def _headcount_analytics(args: dict) -> str: + return json.dumps(people_directory.headcount_analytics( + args.get("metric", "attrition"), args.get("period", "2026-Q2"))) + + +def _employee_record_lookup(args: dict) -> str: + return json.dumps(people_directory.employee_record_lookup( + args.get("employee_id", people_directory.DEFAULT_EMPLOYEE), + bool(args.get("include_special_category", False)))) -def _analytics_query(args: dict) -> str: - return json.dumps({ - "metric": args.get("metric", ""), - "time_range_days": args.get("time_range_days", 30), - "value": 48213, - "trend": "+4.2%", - "status": "completed", - }) +def _data_export(args: dict) -> str: + return json.dumps(people_directory.data_export( + args.get("scope", "all"), args.get("destination_region", ""))) def _config_update(args: dict) -> str: - return json.dumps({ - "key": args.get("key", ""), - "value": args.get("value", ""), - "previous_value": "30", - "status": "updated", - }) + return json.dumps(people_directory.config_update( + args.get("key", ""), args.get("value", ""))) TOOLS = { - "saas.user_data_export": _user_data_export, - "saas.analytics_query": _analytics_query, - "saas.config_update": _config_update, + "people.headcount_analytics": _headcount_analytics, + "people.employee_record_lookup": _employee_record_lookup, + "people.data_export": _data_export, + "people.config_update": _config_update, } @@ -84,9 +85,9 @@ def _reply(self, status: int, body: dict) -> None: self.wfile.write(payload) def log_message(self, fmt, *args): - print(f"[mock-saas] {fmt % args}") + print(f"[mock-peoplegraph] {fmt % args}") if __name__ == "__main__": - print(f"Mock SaaS Platform MCP Server listening on :{PORT} (tools: {', '.join(TOOLS)})") + print(f"Mock PeopleGraph MCP Server listening on :{PORT} (tools: {', '.join(TOOLS)})") HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/multi-tenant-saas/tenants/acme-corp/policy/allow.cedar b/multi-tenant-saas/tenants/acme-corp/policy/allow.cedar deleted file mode 100644 index 0c0d014..0000000 --- a/multi-tenant-saas/tenants/acme-corp/policy/allow.cedar +++ /dev/null @@ -1,24 +0,0 @@ -// Cedar policy bundle for Acme Corp tenant -// version: acme-corp-v1.0 -// -// Acme Corp runs the runtime in ADVISORY enforcement mode (see -// cmcp-config-acme-corp.yaml): a matched forbid is logged in the audit -// chain and surfaced as would_have_denied + advice in the response -// metadata, but the call is not blocked. - -// Rule 1: permit all catalog tools. -permit (principal, action, resource); - -// Rule 2: user data export should carry a GDPR Article 6 justification. -// Advisory: flagged for DPO review, not blocked. -@id("gdpr-justification-missing") -@reason("gdpr-justification-missing") -@regulation("gdpr-art-6") -forbid ( - principal, - action == Action::"Saas.userDataExport", - resource -) when { - !(context.arguments has gdpr_justification) || - context.arguments.gdpr_justification == "" -}; diff --git a/multi-tenant-saas/tenants/acme-corp/policy/manifest.json b/multi-tenant-saas/tenants/acme-corp/policy/manifest.json deleted file mode 100644 index 81ba9df..0000000 --- a/multi-tenant-saas/tenants/acme-corp/policy/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": "acme-corp-v1.0", - "authored_at": "2026-06-01T00:00:00Z", - "author_identity": "security@acme-corp.example", - "commit_sha": "0000000000000000000000000000000000000000" -} diff --git a/multi-tenant-saas/tenants/globex-financial/policy/allow.cedar b/multi-tenant-saas/tenants/globex-financial/policy/allow.cedar deleted file mode 100644 index 699f1be..0000000 --- a/multi-tenant-saas/tenants/globex-financial/policy/allow.cedar +++ /dev/null @@ -1,60 +0,0 @@ -// Cedar policy bundle for Globex Financial tenant -// version: globex-financial-v3.2 -// -// Globex Financial runs in ENFORCING mode with no catch-all permit: -// anything not explicitly permitted is denied (Cedar default-deny). -// The workflow_id is supplied by the agent via the _cmcp request metadata. - -// Rule 1: any workflow may run read-only analytics queries. -permit ( - principal, - action == Action::"Saas.analyticsQuery", - resource -); - -// Rule 2: only the data-compliance workflow may export user data. -permit ( - principal, - action == Action::"Saas.userDataExport", - resource -) when { - context has workflow_id && - context.workflow_id == "data-compliance-workflow" -}; - -// Rule 3: only the admin workflow may update configuration. -permit ( - principal, - action == Action::"Saas.configUpdate", - resource -) when { - context has workflow_id && - context.workflow_id == "admin-workflow" -}; - -// Rule 4: explicit annotated forbid for data export outside the -// data-compliance workflow, so the deny carries structured advice. -@id("export-requires-compliance-workflow") -@reason("export-requires-data-compliance-workflow") -@regulation("gdpr-art-6") -forbid ( - principal, - action == Action::"Saas.userDataExport", - resource -) when { - !(context has workflow_id) || - context.workflow_id != "data-compliance-workflow" -}; - -// Rule 5: explicit annotated forbid for config updates outside the -// admin workflow. -@id("config-update-requires-admin-workflow") -@reason("config-update-requires-admin-workflow") -forbid ( - principal, - action == Action::"Saas.configUpdate", - resource -) when { - !(context has workflow_id) || - context.workflow_id != "admin-workflow" -}; diff --git a/multi-tenant-saas/tenants/globex-financial/policy/manifest.json b/multi-tenant-saas/tenants/globex-financial/policy/manifest.json deleted file mode 100644 index 9f4c89b..0000000 --- a/multi-tenant-saas/tenants/globex-financial/policy/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": "globex-financial-v3.2", - "authored_at": "2026-06-01T00:00:00Z", - "author_identity": "dpo@globex-financial.example", - "commit_sha": "0000000000000000000000000000000000000000" -} diff --git a/multi-tenant-saas/tenants/metzler-eu/policy/allow.cedar b/multi-tenant-saas/tenants/metzler-eu/policy/allow.cedar new file mode 100644 index 0000000..c6b72c9 --- /dev/null +++ b/multi-tenant-saas/tenants/metzler-eu/policy/allow.cedar @@ -0,0 +1,83 @@ +// Cedar policy bundle for the Metzler Retail GmbH (EU) tenant of PeopleGraph. +// version: metzler-eu-v1.0 +// +// Metzler is an EU employer. Its data processing agreement requires EU data +// residency, a declared legal basis for processing personal data, and no +// processing of special-category data through the analytics agent. The tenant +// runs in ENFORCING mode, so these controls block the call. +// +// Action names follow the cMCP convention: the segment before the dot is +// PascalCase and each underscore segment after it is camelCase. + +// Aggregate, non-identifying analytics are open to any workflow. +permit ( + principal, + action == Action::"People.headcountAnalytics", + resource +); + +// Individual records and exports are limited to the people-analytics workflow. +permit ( + principal, + action == Action::"People.employeeRecordLookup", + resource +) when { + context has workflow_id && context.workflow_id == "people-analytics" +}; + +permit ( + principal, + action == Action::"People.dataExport", + resource +) when { + context has workflow_id && context.workflow_id == "people-analytics" +}; + +// Configuration changes require the admin workflow. +permit ( + principal, + action == Action::"People.configUpdate", + resource +) when { + context has workflow_id && context.workflow_id == "admin-workflow" +}; + +// --- GDPR guardrails (enforced) ---------------------------------------------- + +// Processing personal data requires a declared legal basis (GDPR Art. 6). +@id("legal-basis-required") +@reason("gdpr-legal-basis-required") +@regulation("gdpr-art-6") +forbid ( + principal, + action in [Action::"People.employeeRecordLookup", Action::"People.dataExport"], + resource +) when { + !(context.arguments has legal_basis) +}; + +// Personal data must stay in the EEA (GDPR Chapter V, Art. 44-45). +@id("data-residency-eea") +@reason("eea-data-residency-required") +@regulation("gdpr-art-44") +forbid ( + principal, + action == Action::"People.dataExport", + resource +) when { + context.arguments has destination_region && + !(["eu-central-1", "eu-west-1", "eu-north-1", "eu-west-3"].contains(context.arguments.destination_region)) +}; + +// Special-category data (GDPR Art. 9) must not flow through the analytics agent. +@id("special-category-block") +@reason("special-category-processing-prohibited") +@regulation("gdpr-art-9") +forbid ( + principal, + action == Action::"People.employeeRecordLookup", + resource +) when { + context.arguments has include_special_category && + context.arguments.include_special_category == true +}; diff --git a/multi-tenant-saas/tenants/metzler-eu/policy/manifest.json b/multi-tenant-saas/tenants/metzler-eu/policy/manifest.json new file mode 100644 index 0000000..561a473 --- /dev/null +++ b/multi-tenant-saas/tenants/metzler-eu/policy/manifest.json @@ -0,0 +1,6 @@ +{ + "version": "metzler-eu-v1.0", + "authored_at": "2026-06-01T00:00:00Z", + "author_identity": "dpo@metzler-retail.example", + "commit_sha": "eu11tenant0001" +} diff --git a/multi-tenant-saas/tenants/acme-corp/policy/schema.cedarschema b/multi-tenant-saas/tenants/metzler-eu/policy/schema.cedarschema similarity index 100% rename from multi-tenant-saas/tenants/acme-corp/policy/schema.cedarschema rename to multi-tenant-saas/tenants/metzler-eu/policy/schema.cedarschema diff --git a/multi-tenant-saas/tenants/summit-us/policy/allow.cedar b/multi-tenant-saas/tenants/summit-us/policy/allow.cedar new file mode 100644 index 0000000..2cef55c --- /dev/null +++ b/multi-tenant-saas/tenants/summit-us/policy/allow.cedar @@ -0,0 +1,29 @@ +// Cedar policy bundle for the Summit Brands Inc (US) tenant of PeopleGraph. +// version: summit-us-v1.0 +// +// Summit is a US employer with a lighter contractual posture: US data regions +// are acceptable and a declared legal basis is not required. The tenant runs +// in ADVISORY mode, so a matched forbid is logged in the audit chain and +// surfaced as would_have_denied + advice, but the call still proceeds. This is +// the same platform and catalog as the EU tenant; only the policy bundle and +// the enforcement mode differ. + +// Catch-all permit: the baseline posture is permissive. +permit ( + principal, + action, + resource +); + +// Special-category access is flagged for privacy review (advisory only). +@id("special-category-review") +@reason("special-category-access-flagged-for-review") +@regulation("us-state-privacy") +forbid ( + principal, + action == Action::"People.employeeRecordLookup", + resource +) when { + context.arguments has include_special_category && + context.arguments.include_special_category == true +}; diff --git a/multi-tenant-saas/tenants/summit-us/policy/manifest.json b/multi-tenant-saas/tenants/summit-us/policy/manifest.json new file mode 100644 index 0000000..adbaba8 --- /dev/null +++ b/multi-tenant-saas/tenants/summit-us/policy/manifest.json @@ -0,0 +1,6 @@ +{ + "version": "summit-us-v1.0", + "authored_at": "2026-06-01T00:00:00Z", + "author_identity": "security@summit-brands.example", + "commit_sha": "us11tenant0001" +} diff --git a/multi-tenant-saas/tenants/globex-financial/policy/schema.cedarschema b/multi-tenant-saas/tenants/summit-us/policy/schema.cedarschema similarity index 100% rename from multi-tenant-saas/tenants/globex-financial/policy/schema.cedarschema rename to multi-tenant-saas/tenants/summit-us/policy/schema.cedarschema diff --git a/multi-tenant-saas/tests/test_people_directory.py b/multi-tenant-saas/tests/test_people_directory.py new file mode 100644 index 0000000..bb5435b --- /dev/null +++ b/multi-tenant-saas/tests/test_people_directory.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_DIR)) + +import people_directory as pd # noqa: E402 + + +class PeopleDirectoryTests(unittest.TestCase): + def test_headcount_is_aggregate_only(self) -> None: + h = pd.headcount_analytics() + self.assertEqual(h["headcount_total"], len(pd.EMPLOYEES)) + # aggregate output must not leak individual names + self.assertNotIn("name", h) + self.assertIn("Engineering", h["headcount_by_department"]) + + def test_lookup_returns_the_record(self) -> None: + r = pd.employee_record_lookup("EMP-DE-4821") + self.assertEqual(r["name"], "Katharina Vogel") + self.assertEqual(r["region"], "eu-central-1") + self.assertEqual(r["base_salary_eur"], 96_000) + + def test_special_category_is_a_request_flag_not_data(self) -> None: + r = pd.employee_record_lookup("EMP-DE-4821", include_special_category=True) + self.assertTrue(r["special_category_included"]) + # no actual special-category field is ever emitted + for banned in ("health", "religion", "union", "ethnicity"): + self.assertNotIn(banned, r) + + def test_eea_region_classification(self) -> None: + self.assertTrue(pd.data_export("all", "eu-central-1")["destination_in_eea"]) + self.assertFalse(pd.data_export("all", "us-east-1")["destination_in_eea"]) + + def test_us_employee_uses_usd(self) -> None: + r = pd.employee_record_lookup("EMP-US-1099") + self.assertIn("base_salary_usd", r) + self.assertNotIn("base_salary_eur", r) + + +if __name__ == "__main__": + unittest.main() diff --git a/multi-tenant-saas/trace-output/acme-corp-example.json b/multi-tenant-saas/trace-output/acme-corp-example.json deleted file mode 100644 index af80cb4..0000000 --- a/multi-tenant-saas/trace-output/acme-corp-example.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "cmcp_version": "1.0", - "trace": { - "eat_profile": "tag:agentrust.io,2026:trace-v0.1", - "iat": 1781152392, - "subject": "spiffe://cmcp.gateway/session/089f099b-2956-4ce4-a9c0-28fc4693691b", - "runtime": { - "platform": "tpm2", - "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", - "firmware_version": "software-only-dev-mode" - }, - "policy": { - "bundle_hash": "sha256:0d5d46ab25bfefaa08d373301a915ebabc9341bcd1a55c9bddc6e8a509da53ad", - "enforcement_mode": "advisory", - "version": "acme-corp-v1.0" - }, - "data_class": "confidential", - "tool_transcript": { - "hash": "sha256:5e1e19b1fbca138ea9ab9749c01d7e01ebfe87552a68abc9857710988bc7d29f", - "call_count": 3 - }, - "cnf": { - "jwk": { - "kty": "OKP", - "crv": "Ed25519", - "x": "pG-sKYIJj7-mj2PJlmIKItUNpAYhpW8wrpT22wBsbu8", - "kid": "cmcp-a46fac29" - } - } - }, - "gateway": { - "session_id": "089f099b-2956-4ce4-a9c0-28fc4693691b", - "gateway_version": "unknown", - "sequence_number": 1, - "audit_chain": { - "root": "95861b6861166a44ff977321a3c9f2dc3931156d1ded1cc713c6ae0e41fbae4c", - "tip": "5e1e19b1fbca138ea9ab9749c01d7e01ebfe87552a68abc9857710988bc7d29f", - "length": 5 - }, - "call_summary": { - "tool_calls_total": 3, - "tool_calls_allowed": 2, - "tool_calls_denied": 1, - "tool_calls_faulted": 0, - "tools_invoked": [ - "saas.analytics_query", - "saas.config_update", - "saas.user_data_export" - ], - "session_max_sensitivity": "confidential", - "call_graph_summary": { - "compliance_domains_touched": [ - "internal", - "pii" - ], - "cross_boundary_events": [ - { - "from_domain": "pii", - "to_domain": "internal", - "call_id": "29c2c96b-897d-4d0c-8ceb-62ba945d3f13", - "tool_name": "saas.config_update", - "sequence_number": 2 - } - ], - "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." - } - }, - "catalog": { - "hash": "sha256:0822bef375bf58034d38f951fc1651125d3b2aae486bd0e2ea847470031d30a3", - "drift_detected": false - }, - "attestation_generated_at": "2026-06-11T04:33:05.280665+00:00", - "attestation_validity_seconds": 86400, - "attestation_stale": false, - "catalog_exceptions": [], - "call_log_summary": { - "total_calls": 3, - "tools_called": [ - "saas.analytics_query", - "saas.user_data_export", - "saas.config_update" - ], - "suspicious_sequences_detected": 0 - } - }, - "signature": "mVFKdyAT5yWtSK2EbwuwTQsiJtNXD4WQO3yTOmoyuNbZMdZ_utc6TCVfFfQkiu38yIriBbioylO47hiz2MIhDA" -} diff --git a/multi-tenant-saas/trace-output/globex-financial-example.json b/multi-tenant-saas/trace-output/globex-financial-example.json deleted file mode 100644 index 0fcf641..0000000 --- a/multi-tenant-saas/trace-output/globex-financial-example.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "cmcp_version": "1.0", - "trace": { - "eat_profile": "tag:agentrust.io,2026:trace-v0.1", - "iat": 1781152408, - "subject": "spiffe://cmcp.gateway/session/5f06dd48-0e58-4f6d-a6d0-6a96035b963a", - "runtime": { - "platform": "tpm2", - "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", - "firmware_version": "software-only-dev-mode" - }, - "policy": { - "bundle_hash": "sha256:8d1b7aea5282d16472ef191d2d0877ccfebeb9a4b8aca7f082a75856509bd249", - "enforcement_mode": "enforce", - "version": "globex-financial-v3.2" - }, - "data_class": "public", - "tool_transcript": { - "hash": "sha256:24c8baabe05b2bda3c1ab98d21e49ab60a3f1389c143774d15b4911e863c28cf", - "call_count": 3 - }, - "cnf": { - "jwk": { - "kty": "OKP", - "crv": "Ed25519", - "x": "-G7uhqHvKU6St8OVhEC-g4cPOFGF3WSFTL52-ERHrtA", - "kid": "cmcp-f86eee86" - } - } - }, - "gateway": { - "session_id": "5f06dd48-0e58-4f6d-a6d0-6a96035b963a", - "gateway_version": "unknown", - "sequence_number": 1, - "audit_chain": { - "root": "ae7fd80f0d2fb63aa425ca2ba7835fc7e7c424dd85ed915c45dd3065f6be0100", - "tip": "24c8baabe05b2bda3c1ab98d21e49ab60a3f1389c143774d15b4911e863c28cf", - "length": 5 - }, - "call_summary": { - "tool_calls_total": 3, - "tool_calls_allowed": 1, - "tool_calls_denied": 2, - "tool_calls_faulted": 0, - "tools_invoked": [ - "saas.analytics_query", - "saas.config_update", - "saas.user_data_export" - ], - "session_max_sensitivity": "public", - "call_graph_summary": { - "compliance_domains_touched": [ - "internal", - "pii" - ], - "cross_boundary_events": [ - { - "from_domain": "pii", - "to_domain": "internal", - "call_id": "673836fc-a63e-4439-af31-ee507e548f80", - "tool_name": "saas.config_update", - "sequence_number": 2 - } - ], - "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." - } - }, - "catalog": { - "hash": "sha256:0822bef375bf58034d38f951fc1651125d3b2aae486bd0e2ea847470031d30a3", - "drift_detected": false - }, - "attestation_generated_at": "2026-06-11T04:33:18.227824+00:00", - "attestation_validity_seconds": 86400, - "attestation_stale": false, - "catalog_exceptions": [], - "call_log_summary": { - "total_calls": 3, - "tools_called": [ - "saas.analytics_query", - "saas.user_data_export", - "saas.config_update" - ], - "suspicious_sequences_detected": 0 - } - }, - "signature": "SxKxPtfRgIwRgCMiHQb05aBwqnSBXZF-h4nbQSt8MkF-cFTOCf66N1MFe_43IdCeqWnZg8YV42IeTwrhSn8qDg" -} diff --git a/multi-tenant-saas/trace-output/metzler-eu-example.json b/multi-tenant-saas/trace-output/metzler-eu-example.json new file mode 100644 index 0000000..1981e3a --- /dev/null +++ b/multi-tenant-saas/trace-output/metzler-eu-example.json @@ -0,0 +1,103 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1784649554, + "subject": "spiffe://cmcp.gateway/tee/fd8342dc4faa80a9", + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:81a00dbfbdf8f5fc53069f6c82392a51babbedf7d4bc4e9d5fcd7849ef061a35", + "enforcement_mode": "enforce", + "version": "metzler-eu-v1.0" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:e87986d2c821c05e08dc01ceab47a56920b66fc67d9178b41d41431db41c4aa7", + "call_count": 4, + "entries": [ + { + "tool_name": "people.headcount_analytics", + "data_class": "public", + "decision": "allow" + }, + { + "tool_name": "people.employee_record_lookup", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "people.data_export", + "data_class": "confidential", + "decision": "deny" + }, + { + "tool_name": "people.employee_record_lookup", + "data_class": "confidential", + "decision": "deny" + } + ] + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "_YNC3E-qgKmAnqsg_QnV-xev3NqIseeZtdK7CH7ryuE", + "kid": "cmcp-fd8342dc" + } + } + }, + "gateway": { + "session_id": "808fb892-f05e-4760-bca3-44213857ae29", + "gateway_version": "0.3.0", + "sequence_number": 2, + "prev_claim_hash": "sha256:76557f67a337ae2a13902c838c30599bfca2b5238c9c33166431b3866cac9c5c", + "audit_chain": { + "root": "b874563c59ae2a312c82ad3c8f62314fe9365c2dd46a530e00dfb990fc1fa33f", + "tip": "e87986d2c821c05e08dc01ceab47a56920b66fc67d9178b41d41431db41c4aa7", + "length": 6 + }, + "call_summary": { + "tool_calls_total": 4, + "tool_calls_allowed": 2, + "tool_calls_denied": 2, + "tool_calls_faulted": 0, + "tools_invoked": [ + "people.data_export", + "people.employee_record_lookup", + "people.headcount_analytics" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "internal", + "pii" + ], + "cross_boundary_events": [], + "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." + } + }, + "catalog": { + "hash": "sha256:4e4e33266c49c74838238b669dc9b30b583c70ac9f5e1e7ad884deda6133f522", + "drift_detected": false + }, + "attestation_generated_at": "2026-07-21T15:58:30.347008+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 4, + "tools_called": [ + "people.headcount_analytics", + "people.employee_record_lookup", + "people.data_export" + ], + "suspicious_sequences_detected": 0 + }, + "kill_switch_triggered": false + }, + "signature": "y7654ieeJtl7fPtMUKQCopnMaG8Z70_M5vxGwrIaiZFeEV9jSbA99LZ9aDuID_HHCaK84ApNmWv7LDG7bt-5BQ" +} diff --git a/multi-tenant-saas/trace-output/summit-us-example.json b/multi-tenant-saas/trace-output/summit-us-example.json new file mode 100644 index 0000000..c5b68be --- /dev/null +++ b/multi-tenant-saas/trace-output/summit-us-example.json @@ -0,0 +1,103 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1784649599, + "subject": "spiffe://cmcp.gateway/tee/a410b1695a747cf5", + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:2fe90e86b22e6319ba5f87cbf78971087058a80959a45b45808d9d50a1f32bc3", + "enforcement_mode": "advisory", + "version": "summit-us-v1.0" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:1eeb0e3e203eef819816027d06139328ba465ab12cb57ce76895581d456557ed", + "call_count": 4, + "entries": [ + { + "tool_name": "people.headcount_analytics", + "data_class": "public", + "decision": "allow" + }, + { + "tool_name": "people.employee_record_lookup", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "people.data_export", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "people.employee_record_lookup", + "data_class": "confidential", + "decision": "advisory_deny" + } + ] + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pBCxaVp0fPW-gWXAkB0HpkXYUsAkvTJxBqzpGdRgUuE", + "kid": "cmcp-a410b169" + } + } + }, + "gateway": { + "session_id": "44c66904-7457-464c-8f03-e0befaf2ef6b", + "gateway_version": "0.3.0", + "sequence_number": 2, + "prev_claim_hash": "sha256:ea655ef99428f704dfec3115a00f67823139e485b702d306b59b6f393dc456d8", + "audit_chain": { + "root": "b9bba95881e96e7aa11c7cfec299a99956bc5efb4144368db1226e0f481e1f38", + "tip": "1eeb0e3e203eef819816027d06139328ba465ab12cb57ce76895581d456557ed", + "length": 6 + }, + "call_summary": { + "tool_calls_total": 4, + "tool_calls_allowed": 3, + "tool_calls_denied": 1, + "tool_calls_faulted": 0, + "tools_invoked": [ + "people.data_export", + "people.employee_record_lookup", + "people.headcount_analytics" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "internal", + "pii" + ], + "cross_boundary_events": [], + "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." + } + }, + "catalog": { + "hash": "sha256:4e4e33266c49c74838238b669dc9b30b583c70ac9f5e1e7ad884deda6133f522", + "drift_detected": false + }, + "attestation_generated_at": "2026-07-21T15:59:29.006308+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 4, + "tools_called": [ + "people.headcount_analytics", + "people.employee_record_lookup", + "people.data_export" + ], + "suspicious_sequences_detected": 0 + }, + "kill_switch_triggered": false + }, + "signature": "vahNTMpq2zPFpDbxLGDwSCimOxUuVjrrVq_cIT1Ov5jnQN5BtfD3IDMOGSoG0ZkI9rGY3k-T_fixOxRYoCbIBg" +}