Local Compose quickstart
One command brings up the standalone server, a synthetic fixture API and a local token issuer, and proves an agent-compatible MCP request returns a pseudonymised response — no source reading required. It then takes one pseudonym through re-identification: a request, a second person approving, the real identifier released to the operator only, and the audit lines each step left (Re-identify a pseudonym).
This is a local demonstration, not a production deployment. Every host
name, port, certificate and secret below is fixture-only, and compose.yaml
labels each one. For what a real deployment configures instead — its own
identity provider, its own reviewed source adapters, its own secret
provider — see docs/configuration.md, the deployment contract this
quickstart is a worked instance of.
There is no development bypass. Some platforms let a local demo skip
authentication entirely. This one cannot: the standalone server refuses
dataprism.transport.fixture-development=true (task 21's own decision), so
every request below — including the very first one — carries a real,
signature-verified JWT. data-prism-quickstart-issuer exists to make getting
one a single command, at POST /token, not to be a shortcut around needing
one.
Prerequisites
- Docker with Compose v2 (
docker compose version). - Nothing else.
docker compose uppulls all four quickstart images —ghcr.io/aindriub/data-prism-quickstart-{server,fixtures,issuer,certs-init}, tagged${QUICKSTART_IMAGE_TAG:-0.8.0}— pre-built; no local JDK or Maven install is required to run them (data-prism-quickstart-extension's own Docker-free integration test,QuickstartSmokeIT, is what proves the same chain works without Docker too, for contributors who do have one). To build every image from source instead — for local development, or before a version's images have been published — rundocker compose -f compose.yaml -f compose.build.yaml up --build, which layers each service'sbuild:block back on top ofcompose.yaml.
Start it
docker compose up
pulls the four published images. To build them from source instead:
docker compose -f compose.yaml -f compose.build.yaml up --build
The images default to the version this quickstart was written for, 0.8.0,
never latest: an older image ignores the cluster-key, name-key and operator
settings the re-identification walkthrough needs. To pin another released
version, set QUICKSTART_IMAGE_TAG in the environment (or a .env file)
before either command.
The first run (either command) takes a few minutes — pulling four images, or building four (a Maven reactor build inside each of three) — later runs are fast. When it settles you have:
| Service | What it is | Reachable at |
|---|---|---|
cert-init |
Generates this run's throwaway TLS material, then exits | — |
issuer |
The local JWT issuer (data-prism-quickstart-issuer) |
https://localhost:8544 |
fixtures |
The synthetic customer API (data-prism-quickstart-fixtures) |
Compose network only |
server |
The standalone server, with the quickstart's reviewed adapter loaded via -Dloader.path |
MCP at http://localhost:8080; the operator surface at http://localhost:8081, on loopback only |
fixtures is deliberately not published to the host: nothing outside the
server is meant to call a source adapter's own API directly, which is the
same reason a real deployment would put its own source APIs behind the
server rather than expose them to a caller.
The server's own startup does not wait on issuer or fixtures being fully
ready — JWKS and adapter calls happen lazily, on the first real request —
so if the very first command below fails, wait a few seconds and retry it.
Get a token
The issuer's TLS certificate is the same self-signed, locally generated one
server and fixtures trust (see "How the pieces trust each other"
below); curl does not trust it by default, so every command against
issuer needs -k. That is specific to this local demonstration — see
docs/configuration.md for how a production deployment's JWKS location is
reached over a certificate that is trusted for real.
With no request body, POST /token mints a token for a fixture development
principal, in the investigator role, for purpose investigation and case
CASE-QUICKSTART-1 — enough to call get_entity_context. To mint one for a
different role, purpose or case, POST a JSON body instead:
curl -sk -X POST https://localhost:8544/token \
-H 'Content-Type: application/json' \
-d '{"roles":["investigator"],"purpose":"investigation","caseId":"CASE-DEMO-1"}'
The demo command below mints and uses its own token, always for case
CASE-QUICKSTART-1 (run.sh has no way to take a token minted for a
different case); to see a call made under a different case, drive the
JSON-RPC exchange yourself with the token above, following
examples/quickstart-demo/mcp-handshake.sh's
calls. You do not need to run the curl above first to run the demo command
below.
Run the demo
MCP's Streamable HTTP transport is a JSON-RPC exchange, not a plain REST
call: the first response carries an Mcp-Session-Id header every later
request in the session must echo back, and the initialize /
notifications/initialized handshake must happen before a tool can be
called. An MCP-aware client (an agent, or the
MCP Inspector
pointed at http://localhost:8080/mcp with an Authorization: Bearer
header) handles this for you.
examples/quickstart-demo/run.sh is
that same exchange as one runnable command: it mints a token, runs the
handshake, calls get_entity_context for the fixture customer, and prints
the fixture's real values beside the pseudonymised response the server
actually returned. See
examples/quickstart-demo/mcp-handshake.sh
for the underlying JSON-RPC requests if you want to see the exchange itself.
examples/quickstart-demo/run.sh
prints (this is real output from an actual run against this stack):
PASS: get_entity_context for CUSTOMER 1001 returned a pseudonymised response.
field real fixture value pseudonymised response
------------ ------------------------------------- --------------------------
subjectId 1001 SUBJ-KNSYWNZ9
customerName Fixture Person One Rowan Okafor (D1B5CR19)
email fixture.person.one@example.invalid [REDACTED]
With the defaults above, these pseudonyms are the same every time you run
this: customerName and subjectId are derived by a keyed HMAC over the
case id and the subject id (ScopeResolver, HmacSyntheticGenerator), and
run.sh always mints a token for the same default case,
CASE-QUICKSTART-1. They change for a different case id, a different HMAC
key, or a different pseudonymisation version or vocabulary
(HmacSyntheticGenerator) — never merely from running the demo again.
The fixture customer API holds a second record, for subject 1002:
examples/quickstart-demo/run.sh CUSTOMER 1002
PASS: get_entity_context for CUSTOMER 1002 returned a pseudonymised response.
field real fixture value pseudonymised response
------------ ------------------------------------- --------------------------
subjectId 1002 SUBJ-F08KVXP6
customerName Fixture Person Two Oakley Castellano (KKHAGCFX)
email fixture.person.two@example.invalid [REDACTED]
Neither subject's real name nor real email — Fixture Person One /
fixture.person.one@example.invalid for 1001, Fixture Person Two /
fixture.person.two@example.invalid for 1002, per
data-prism-quickstart-fixtures' own CustomerController — appears in
either response: customerName is a synthetic value, not the fixture's own;
email is redacted outright; status, not shown above but present in the
raw MCP response, passes through because it was classified @NonSensitive,
a decision data-prism-quickstart-extension's CustomerModel states
explicitly (see docs/configuration.md and pack.md §30 on why a field
nobody classified is never exposed at all, rather than being disclosed by
default). run.sh checks this itself — it fails loudly if a response ever
carries the subject it was asked about's own raw name or email — and that
check is real: forcing the script to treat 1001's or 1002's own raw values
as the "pseudonymised" result (simulating a leak) makes it print FAIL:
get_entity_context returned the fixture's raw value, unpseudonymised and
exit non-zero for each, in place of the PASS lines above.
Both tools the platform ships today are reachable this way — this demo calls
get_entity_context; compare_entity_sources takes the same input schema
(entityType and subjectId, both required, nothing else — an MCP argument
can never choose a host, a path, a source or a caller identity, see
docs/configuration.md) but this quickstart's investigator role
(docker/server/application.yaml) is granted GET_ENTITY_CONTEXT only. See
docs/tools.md for what compare_entity_sources returns,
worked against this repository's own fixture data.
Two requests worth trying, to see the boundary itself rather than take it on faith:
# No token: refused with 401 before the request reaches the privacy engine.
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{}'
# A syntactically invalid token: refused the same way, not treated as anonymous.
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'Authorization: Bearer not-a-real-token' -d '{}'
Both print 401.
Re-identify a pseudonym
An investigation ends with a person needing to know who a pseudonym stands for.
Re-identification is the one controlled way back, and it is never an MCP tool:
it is served on the operator port (8081), under its own audience and
scope, so a token an agent holds cannot reach it. This stack runs it with
four-eyes on (dataprism.reidentification.four-eyes), so the person who asks is
never the person who approves. The settings are in docker/server/application.yaml
(dataprism.reidentification, dataprism.operator, dataprism.hazelcast).
The full flow, every endpoint and every refusal code are in
Re-identification; this section walks one request
through it.
The server runs one embedded member that joins nothing (topology: embedded,
join.mode: none). Re-identification refuses single-node, because its index
lives in the cluster member. The member's cluster key and name key are
generated by the server container each time it starts, from the container's
own random source, and passed by reference (environment-variable): nothing is
committed, written to a volume or printed. Restarting makes new ones, which is
harmless for a member that is alone.
The re-identification index and every pending approval live in the server's
memory, so any restart of the server, docker compose restart as well as
docker compose down, clears them. After a restart, run step 2 again before
you request: a pseudonym can be re-identified only if the index was running
when it was handed out. The pseudonyms themselves do not change, because the
HMAC key (QUICKSTART_HMAC_KEY) is fixed.
Every command below was run against this stack, and the output is what it
printed. The generated identifiers (the approval id and the timestamps) will
differ on your run. The pseudonym SUBJ-KNSYWNZ9 is the one the demo above
shows for subject 1001, and stays the same for case CASE-QUICKSTART-1.
1. Get three tokens. The same issuer mints them. An operator token names the
operator audience and scope in the request body; without them you get the MCP
token from earlier. The requester also holds the approver role, so that
approving their own request is refused by the four-eyes rule and not merely
for want of a permission. One principal holding both roles is a demo
arrangement only: a real deployment gives investigator and approver to
different people, and the four-eyes rule then holds because they are different
people.
field() { python3 -c 'import json,sys; print(json.load(sys.stdin)[sys.argv[1]])' "$1"; }
mint() { curl -sk -X POST https://localhost:8544/token -H 'Content-Type: application/json' -d "$1" | field access_token; }
MCP_TOKEN=$(mint '{"subject":"requester-1","roles":["investigator"],"caseId":"CASE-QUICKSTART-1"}')
REQUESTER=$(mint '{"subject":"requester-1","roles":["investigator","approver"],"caseId":"CASE-QUICKSTART-1","audience":"data-prism-quickstart-operator","scope":"operator"}')
APPROVER=$(mint '{"subject":"approver-1","roles":["approver"],"caseId":"CASE-QUICKSTART-1","audience":"data-prism-quickstart-operator","scope":"operator"}')
The issuer mints only two shapes: the MCP token (no audience or the MCP audience, and no scope) and
the operator token (the operator audience with exactly the scope operator).
Any other combination is refused with 400 {"code":"INVALID_TOKEN_REQUEST"},
without echoing the input. An MCP token on the operator port is refused (401), and an
operator token on the MCP port is refused the same way.
2. Ask for the entity, as an agent would. This registers the pseudonym in the re-identification index; a pseudonym handed out before the index existed cannot be re-identified.
. examples/quickstart-demo/mcp-handshake.sh
mcp_initialize http://localhost:8080/mcp "$MCP_TOKEN"
mcp_notify_initialized http://localhost:8080/mcp "$MCP_TOKEN" "$MCP_SESSION"
mcp_call_get_entity_context http://localhost:8080/mcp "$MCP_TOKEN" "$MCP_SESSION" CUSTOMER 1001 \
| grep -o '"subject":"[^"]*"' | head -1
"subject":"SUBJ-KNSYWNZ9"
3. Request re-identification. The scope id of a case is case: followed by
the case id (it is also the scope= field of the audit line for step 2). The
pseudonym of a subject id is in namespace NONE.
REQUEST=$(curl -s -X POST http://localhost:8081/operator/reidentifications \
-H "Authorization: Bearer $REQUESTER" -H 'Content-Type: application/json' \
-d '{"scopeId":"case:CASE-QUICKSTART-1","namespace":"NONE","syntheticValue":"SUBJ-KNSYWNZ9","purpose":"investigation","caseId":"CASE-QUICKSTART-1"}')
echo "$REQUEST"
APPROVAL_ID=$(echo "$REQUEST" | field approvalId)
{"approvalId":"00b97563-f965-45e0-a660-07532dbd1eef","status":"PENDING_APPROVAL"}
Nothing has been released: four-eyes returns a pending approval and does not touch the index.
4. A refusal: the requester cannot approve their own request.
curl -s -w '\nHTTP %{http_code}\n' -X POST "http://localhost:8081/operator/reidentifications/$APPROVAL_ID/approve" \
-H "Authorization: Bearer $REQUESTER"
{"code":"SELF_APPROVAL"}
HTTP 409
The refusal is audited as DENY:SELF_APPROVAL (step 7). Every other code is in
the refusal table.
5. The second person sees what is being asked for, and approves. The approver's list shows the pseudonym, its namespace, the requester, the purpose and the expiry. It never shows the subject id, which an approval has no field for.
curl -s -w '\nHTTP %{http_code}\n' http://localhost:8081/operator/approvals -H "Authorization: Bearer $APPROVER"
{"approvals":[{"approvalId":"00b97563-f965-45e0-a660-07532dbd1eef","kind":"REIDENTIFICATION","status":"PENDING","requesterPrincipalId":"requester-1","requesterClientId":"quickstart-cli","scopeId":"case:CASE-QUICKSTART-1","tool":"reidentify","purpose":"investigation","caseId":"CASE-QUICKSTART-1","createdAt":"2026-10-28T18:05:13.516108530Z","expiresAt":"2026-10-28T18:20:13.516108530Z","namespace":"NONE","syntheticValue":"SUBJ-KNSYWNZ9"}]}
HTTP 200
curl -s -w '\nHTTP %{http_code}\n' -X POST "http://localhost:8081/operator/reidentifications/$APPROVAL_ID/approve" \
-H "Authorization: Bearer $APPROVER"
{"status":"APPROVED"}
HTTP 200
6. The requester collects the real identifier. Only the requester can, and only once, before the approval expires.
curl -s -w '\nHTTP %{http_code}\n' "http://localhost:8081/operator/reidentifications/$APPROVAL_ID" \
-H "Authorization: Bearer $REQUESTER"
{"subjectId":"1001","status":"RESOLVED"}
HTTP 200
1001 is the fixture's subject id. It appears in this one response, to the
requester on the operator port. It is in no error, no log line and no audit
event. Asking again is refused, because the approval was used:
curl -s -w '\nHTTP %{http_code}\n' "http://localhost:8081/operator/reidentifications/$APPROVAL_ID" \
-H "Authorization: Bearer $REQUESTER"
{"code":"APPROVAL_NOT_APPROVED"}
HTTP 409
7. Read the audit. The audit sink here is the server's log
(dataprism.audit.sink: slf4j). Each step wrote an operator: event saying the
operator asked, and the re-identification service wrote its own reidentify
event with the outcome. This prints the principal, the tool and the decision of
each, in order:
docker compose logs --no-color server \
| grep -E 'dataprism.audit' | grep -E ' tool=(reidentify|operator:)' \
| sed -E 's/.* principal=([^ ]*) .* tool=([^ ]*) .* decision=([^ ]*) .*/\1 \2 \3/'
requester-1 operator:reidentify-request ALLOW:FORWARDED
requester-1 reidentify ALLOW:REQUESTED
requester-1 operator:reidentify-approve ALLOW:FORWARDED
requester-1 reidentify DENY:SELF_APPROVAL
approver-1 operator:approvals ALLOW:READ
approver-1 operator:reidentify-approve ALLOW:FORWARDED
requester-1 reidentify ALLOW:APPROVED
requester-1 operator:reidentify-collect ALLOW:FORWARDED
requester-1 reidentify ALLOW:RESOLVED
requester-1 operator:reidentify-collect ALLOW:FORWARDED
requester-1 reidentify DENY:APPROVAL_NOT_APPROVED
The reidentify lines are the decisions: requested, refused for self-approval,
approved, resolved, then refused on the second collect. A reidentify event is
written under the requester's principal; the operator:reidentify-approve line
just before ALLOW:APPROVED is the one that names the approver. The full lines
also carry the scope, purpose, case id, correlation id and the pseudonym, never
the subject id.
docker/smoke/reidentification-smoke.sh
runs these same steps against a throwaway copy of the stack and exits 0 only if
the resolved identifier matches the fixture and the audit holds these
decisions in order; see docker/smoke/README.md.
The operator port is plain HTTP and bound here to loopback by compose.yaml
(127.0.0.1:8081). Never publish it beyond a machine you trust; see
What this does not do.
Stop and reset
docker compose down
stops every service and removes the containers, but leaves the certs
volume (and its generated keystore) in place, so the next docker compose up
reuses the same certificate rather than minting a new one. To reset
completely — a fresh certificate, and a clean slate for anything a future
version of this quickstart might persist (the server's cluster and name keys
and every pending approval live in the server process only, so even a plain
docker compose down ends them):
docker compose down --volumes
How the pieces trust each other
cert-init (docker/certs-init/) generates one self-signed PKCS12 keystore
and a matching truststore at first run, into the certs Docker volume —
never into this repository, and never reused as a real certificate for
anything. issuer and fixtures serve HTTPS from that keystore, because
task 21 refuses a plaintext http:// JWKS location and a plaintext
http:// source base-url outright, fixture exception or not. server
trusts that same certificate for its own outbound calls (JWKS discovery,
the fixture API) via the standard Java javax.net.ssl.trustStore system
properties, set through JAVA_TOOL_OPTIONS in compose.yaml — the same
per-JVM mechanism a production deployment would use to trust its own
organisation's CA, not something this quickstart invents.
None of this material — the keystore, the truststore, the signing key
data-prism-quickstart-issuer generates in memory at its own startup, or
any token minted from it — is ever written into this repository. See
CLAUDE.md rule 7.
Docker-free alternative
data-prism-quickstart-extension's QuickstartSmokeIT proves the same
chain — fixture API, issuer, the packaged standalone server with this
extension loaded via -Dloader.path — end to end without a Docker daemon,
as three real JVM subprocesses driven by the MCP SDK's own client transport.
It runs as part of mvn -B verify from the repository root and is the
reference for anyone extending this quickstart who cannot rely on Docker
being available.
What next
This quickstart's adapter, models and fixture data are already written for
you, purely so the one command above has something real to answer with. The
next step is protecting your own API instead of the fixture one:
docs/protect-your-own-api.md walks a flat or
one-level-nested JSON REST API from nothing to a working
get_entity_context call, writing only YAML — no Java, no rebuild of
data-prism-server itself. If your API needs custom fetch logic or a model
that walkthrough's configuration-driven mode cannot express,
docs/extending.md covers the general, Java-adapter path
instead. Either way, docs/configuration.md is the
deployment contract both paths sit on top of.