Skip to content

Commit a4dae46

Browse files
feat(multi-tenant-saas): model a real HR SaaS with EU vs US tenants (#54)
Replace the acme-corp/globex-financial placeholders with PeopleGraph, a fictional HR/people-analytics SaaS whose two tenants differ by genuine data processing agreement terms rather than by a flag. - metzler-eu (EU employer, enforcing): GDPR guardrails enforced - declared legal basis (Art. 6), EEA data residency (Art. 44-45), no special-category processing (Art. 9). - summit-us (US employer, advisory): permissive baseline; special-category access is flagged for review but not blocked. The same four calls (headcount, employee lookup, cross-region export, special-category lookup) run against both tenants: the EU tenant blocks the cross-border export and the special-category lookup, the US tenant allows the export and only logs the special-category flag. - New people_directory module holds employee fixtures and tool logic; the mock server serves from it. Responses are free of scanner-tripping PII patterns. - Add unit tests and a CI job; update the smoke matrix for the new tenants. - Regenerate one signed TRACE record per tenant from real runs. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0b67f3b commit a4dae46

27 files changed

Lines changed: 795 additions & 469 deletions

.github/workflows/ci.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,19 @@ jobs:
9191

9292
- name: Verify embodied-action receipt fixtures
9393
run: python -m unittest discover -s tests -v
94+
95+
multi-tenant-saas:
96+
runs-on: ubuntu-latest
97+
defaults:
98+
run:
99+
working-directory: multi-tenant-saas
100+
steps:
101+
- uses: actions/checkout@v7
102+
103+
- name: Set up Python 3.11
104+
uses: actions/setup-python@v6
105+
with:
106+
python-version: "3.11"
107+
108+
- name: Run people directory tests
109+
run: python -m unittest discover -s tests -v

.github/workflows/smoke.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,16 @@ jobs:
3434
config: cmcp-config.yaml
3535
agent: agent/clinical_decision_agent.py
3636
args: ""
37-
- name: multi-tenant-acme-corp
37+
- name: multi-tenant-metzler-eu
3838
dir: multi-tenant-saas
39-
config: cmcp-config-acme-corp.yaml
39+
config: cmcp-config-metzler-eu.yaml
4040
agent: agent/saas_agent.py
41-
args: "--tenant acme-corp"
42-
- name: multi-tenant-globex-financial
41+
args: "--tenant metzler-eu"
42+
- name: multi-tenant-summit-us
4343
dir: multi-tenant-saas
44-
config: cmcp-config-globex-financial.yaml
44+
config: cmcp-config-summit-us.yaml
4545
agent: agent/saas_agent.py
46-
args: "--tenant globex-financial"
46+
args: "--tenant summit-us"
4747
steps:
4848
- uses: actions/checkout@v7
4949

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ End-to-end integration examples showing cMCP, Agent Manifest, and TRACE working
1515
| `financial-services/` | Corporate credit risk agent: six-step assessment with CDD, exposure and IFRS 9 guardrails on the write | SEV-SNP / TDX | EU AI Act Art. 9/12, CRR Art. 395, EBA/GL/2020/06, EU AML, DORA Art. 9 |
1616
| `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 |
1717
| `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 |
18-
| `multi-tenant-saas/` | Per-tenant Cedar policy bundles and enforcement modes (advisory vs enforcing) | TDX | GDPR Art. 6, customer contract SLA |
18+
| `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 |
1919
| `startup-tpm/` | 15-minute quickstart on any cloud VM with Trusted Launch | TPM 2.0 | Development / staging |
2020

2121
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.

multi-tenant-saas/README.md

Lines changed: 90 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,72 @@
11
# multi-tenant-saas: Per-Tenant Cedar Policy Isolation
22

3-
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.
3+
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.
44

5-
The same three tool calls produce different outcomes per tenant:
5+
| | `metzler-eu` | `summit-us` |
6+
|---|---|---|
7+
| Employer | Metzler Retail GmbH (EU) | Summit Brands Inc (US) |
8+
| Contract | EU data residency, declared legal basis, no special-category processing | US regions allowed, no legal-basis requirement |
9+
| Enforcement | `enforcing` (violations block) | `advisory` (violations logged, not blocked) |
10+
| Policy version | `metzler-eu-v1.0` | `summit-us-v1.0` |
11+
12+
The **same four tool calls** produce different outcomes per tenant:
613

7-
| Tool | acme-corp (advisory) | globex-financial (enforcing) |
14+
| Call | `metzler-eu` (enforcing) | `summit-us` (advisory) |
815
|---|---|---|
9-
| `saas.analytics_query` | allow | allow |
10-
| `saas.user_data_export` | advisory_deny (logged, not blocked) | deny |
11-
| `saas.config_update` | allow | deny |
16+
| `people.headcount_analytics` | allow | allow |
17+
| `people.employee_record_lookup` (with legal basis) | allow | allow |
18+
| `people.data_export` to `us-east-1` | **deny** (GDPR data residency) | allow |
19+
| `people.employee_record_lookup` special-category | **deny** (GDPR Art. 9) | advisory_deny (logged) |
1220

1321
---
1422

1523
## What the demo shows
1624

1725
**1. Policy-as-isolation at the tool boundary**
18-
Each tenant has its own Cedar bundle under `tenants/<name>/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`).
26+
Each tenant has its own Cedar bundle under `tenants/<name>/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.
1927

20-
**2. Progressive compliance posture via enforcement mode**
21-
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.
28+
**2. The tenants differ by real contract terms, not just a flag**
29+
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.
2230

23-
**3. Structured advice on denies**
24-
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.
31+
**3. Progressive posture via enforcement mode**
32+
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.
2533

2634
---
2735

2836
## File layout
2937

3038
```
3139
multi-tenant-saas/
32-
cmcp-config-acme-corp.yaml advisory mode -> tenants/acme-corp/policy
33-
cmcp-config-globex-financial.yaml enforcing mode -> tenants/globex-financial/policy
34-
catalog.json shared three-tool catalog
40+
cmcp-config-metzler-eu.yaml enforcing -> tenants/metzler-eu/policy
41+
cmcp-config-summit-us.yaml advisory -> tenants/summit-us/policy
42+
catalog.json shared four-tool catalog
43+
people_directory.py employee fixtures + tool logic (one source of truth)
3544
tenants/
36-
acme-corp/policy/ acme-corp-v1.0 (permissive)
37-
globex-financial/policy/ globex-financial-v3.2 (default-deny)
38-
server/
39-
mock_mcp_server.py mock upstream MCP server (stdlib only)
40-
agent/
41-
saas_agent.py demo agent (run this)
45+
metzler-eu/policy/ metzler-eu-v1.0 (GDPR guardrails, enforced)
46+
summit-us/policy/ summit-us-v1.0 (permissive, advisory)
47+
server/mock_mcp_server.py mock PeopleGraph MCP server (stdlib only)
48+
agent/saas_agent.py demo agent (run this)
49+
tests/test_people_directory.py unit tests
4250
trace-output/
43-
acme-corp-example.json real captured TRACE record (advisory)
44-
globex-financial-example.json real captured TRACE record (enforcing)
51+
metzler-eu-example.json real captured TRACE record (enforcing)
52+
summit-us-example.json real captured TRACE record (advisory)
4553
```
4654

4755
---
4856

57+
## The catalog
58+
59+
| Tool | compliance_domain | sensitivity_level |
60+
|---|---|---|
61+
| `people.headcount_analytics` | internal | public |
62+
| `people.employee_record_lookup` | pii | confidential |
63+
| `people.data_export` | pii | confidential |
64+
| `people.config_update` | internal | public |
65+
66+
`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.
67+
68+
---
69+
4970
## Run it
5071

5172
```bash
@@ -61,94 +82,99 @@ cd multi-tenant-saas
6182
python server/mock_mcp_server.py
6283
```
6384

64-
**Terminal 2 - runtime with Acme Corp's policy** (run from inside `multi-tenant-saas/`):
85+
**Terminal 2 - runtime with Metzler's (EU) policy** (run from inside `multi-tenant-saas/`):
6586

6687
```bash
6788
cd multi-tenant-saas
68-
CMCP_DEV_MODE=1 cmcp start --config cmcp-config-acme-corp.yaml
89+
CMCP_DEV_MODE=1 cmcp start --config cmcp-config-metzler-eu.yaml
6990
```
7091

7192
**Terminal 3 - agent:**
7293

7394
```bash
7495
cd examples
75-
python multi-tenant-saas/agent/saas_agent.py --tenant acme-corp
96+
python multi-tenant-saas/agent/saas_agent.py --tenant metzler-eu
7697
```
7798

7899
```
79-
[1/3] Calling saas.analytics_query ...
80-
-> decision: allow
81-
[2/3] Calling saas.user_data_export ...
82-
-> decision: advisory_deny (logged, not blocked)
100+
[3/4] people.data_export (scope=engineering, destination_region=us-east-1)
101+
-> decision: deny (POLICY_DENY)
83102
advice from policy:
84-
id: gdpr-justification-missing
85-
reason: gdpr-justification-missing
86-
regulation: gdpr-art-6
87-
[3/3] Calling saas.config_update ...
88-
-> decision: allow
103+
id: data-residency-eea
104+
reason: eea-data-residency-required
105+
regulation: gdpr-art-44
106+
[4/4] people.employee_record_lookup (employee_id=EMP-DE-4821, include_special_category=True)
107+
-> decision: deny (POLICY_DENY)
108+
advice from policy:
109+
id: special-category-block
110+
reason: special-category-processing-prohibited
111+
regulation: gdpr-art-9
89112
```
90113

91-
**Switch tenants** - stop the runtime (Ctrl-C) and restart with Globex Financial's config:
114+
**Switch tenants** - stop the runtime (Ctrl-C) and restart with Summit's (US) config:
92115

93116
```bash
94-
CMCP_DEV_MODE=1 cmcp start --config cmcp-config-globex-financial.yaml
117+
CMCP_DEV_MODE=1 cmcp start --config cmcp-config-summit-us.yaml
118+
python multi-tenant-saas/agent/saas_agent.py --tenant summit-us
95119
```
96120

97-
```bash
98-
python multi-tenant-saas/agent/saas_agent.py --tenant globex-financial
99121
```
100-
101-
```
102-
[1/3] Calling saas.analytics_query ...
122+
[3/4] people.data_export (scope=engineering, destination_region=us-east-1)
103123
-> decision: allow
104-
[2/3] Calling saas.user_data_export ...
105-
-> decision: deny (POLICY_DENY)
106-
advice from policy:
107-
id: export-requires-compliance-workflow
108-
reason: export-requires-data-compliance-workflow
109-
regulation: gdpr-art-6
110-
[3/3] Calling saas.config_update ...
111-
-> decision: deny (POLICY_DENY)
124+
[4/4] people.employee_record_lookup (employee_id=EMP-DE-4821, include_special_category=True)
125+
-> decision: advisory_deny (logged, not blocked)
112126
advice from policy:
113-
id: config-update-requires-admin-workflow
114-
reason: config-update-requires-admin-workflow
127+
id: special-category-review
128+
reason: special-category-access-flagged-for-review
129+
regulation: us-state-privacy
115130
```
116131

117-
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/`.
132+
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/`.
118133

119134
---
120135

121136
## How the policies differ
122137

123-
**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.
124-
125-
**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`:
138+
**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:
126139

127140
```cedar
128-
permit (
141+
@id("data-residency-eea")
142+
@reason("eea-data-residency-required")
143+
@regulation("gdpr-art-44")
144+
forbid (
129145
principal,
130-
action == Action::"Saas.userDataExport",
146+
action == Action::"People.dataExport",
131147
resource
132148
) when {
133-
context has workflow_id &&
134-
context.workflow_id == "data-compliance-workflow"
149+
context.arguments has destination_region &&
150+
!(["eu-central-1", "eu-west-1", "eu-north-1", "eu-west-3"].contains(context.arguments.destination_region))
135151
};
136152
```
137153

138-
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.
154+
**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.
155+
156+
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.
139157

140158
---
141159

142160
## Running both tenants simultaneously
143161

144-
Run two runtimes on different ports (edit `listen_addr` in one config), one per tenant, against the same mock server:
162+
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:
145163

146164
```bash
147-
python multi-tenant-saas/agent/saas_agent.py --tenant acme-corp --gateway http://localhost:8443
148-
python multi-tenant-saas/agent/saas_agent.py --tenant globex-financial --gateway http://localhost:9443
165+
python multi-tenant-saas/agent/saas_agent.py --tenant metzler-eu --gateway http://localhost:8443
166+
python multi-tenant-saas/agent/saas_agent.py --tenant summit-us --gateway http://localhost:9443
149167
```
150168

151-
This is the production topology: one attested runtime instance per tenant isolation boundary, each measuring its own policy bundle hash into its TRACE records.
169+
---
170+
171+
## The tests
172+
173+
`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.
174+
175+
```bash
176+
python -m unittest discover -s tests -v
177+
```
152178

153179
---
154180

0 commit comments

Comments
 (0)