Manual test plan — resource labelling¶
How to exercise per-tenant resource classification labelling by hand, against a real stack, in the state the feature is in today.
Every step below was executed end to end against a live stack on 2026-08-19. The output shown under each step is what was actually observed, not what the code suggests should happen — where the two disagreed, the observed behaviour won, and five commands in an earlier draft were wrong as a result. Each surprise worth knowing is flagged inline.
Ids, ports and tokens will differ on your run. Everything else should match character for character.
There is a pass/fail checklist at the end.
What you can test¶
The feature lets an operator register per-tenant rules mapping a resource-id
prefix to a (hierarchy, level) classification. At decision time the PDP
resolves a resource's label by longest matching prefix and hands it to the
delegation's classification cap check.
Everything on the labelling side is reachable through the admin plane, and so
is the cap. PUT /v1/agents/{id}/classification-caps (RoleAdmin) sets it on
the agent; a root delegation copies it at issuance
(internal/pdpgrpc/delegation.go), and children copy their parent's, never
widening. B2 exercises the route live.
One SQL statement survives in Part B, for a reason that has nothing to do
with the cap. This plan deliberately keeps the tenant free of principal
policies, so B3 can show delegationEffect diverging from the top-level
effect — and CreateDelegation over gRPC refuses to issue anything to a
principal with no policy at all. B2's delegation row is still built by hand
because of that, not because the cap is unreachable; the statement no longer
hand-types a cap, it copies whatever the PUT above just set.
Part D3 needs a caveat as a result — read it after B2, not before.
0. Bring up a stack¶
From the repository root:
Expected: every service reaches Healthy, ending with lines like
✔ Container docker-keycloak-1 Healthy
✔ Container docker-pdp-pap-1 Healthy
✔ Container docker-stratium-pap-1 Healthy
PASS if the command exits 0. If postgres exits 1, check the logs before
retrying — initdb: could not create directory … No space left on device means
the Docker disk is full, not that anything is broken. See "If the stack will not
start" at the end.
Resolve the ports — they are ephemeral, so read them rather than assuming:
dc() { docker compose -f deploy/docker/docker-compose.yml "$@"; } # a function, not a variable
export PAP="localhost:$(dc port stratium-pap 8080 | sed -E 's/.*://')"
export PDP="localhost:$(dc port pdp-pap 50051 | sed -E 's/.*://')"
export KC="localhost:$(dc port keycloak 8080 | sed -E 's/.*://')"
export TENANT=tenant-pap-e2e-integration
echo "PAP=$PAP PDP=$PDP KC=$KC"
Expected — three localhost: addresses with non-empty port numbers:
PASS if all three have a port. A trailing bare localhost: means dc did
not run.
dcis a function deliberately. Putting the command in a variable and calling$C port …fails — the shell looks for a single command whose name is the whole string. That was one of the five wrong commands.
stratium-pap and pdp-pap are both pinned to tenant-pap-e2e-integration.
The tenant is startup configuration and no request can carry one, so a PAP
process serves exactly one tenant for its whole life.
Two psql helpers, used throughout. -h 127.0.0.1 forces TCP so password
authentication is used consistently rather than depending on the image's local
socket rules:
psqlm() { # the owner role — used for setup and for reading state
dc exec -T -e PGPASSWORD=stratium_migrate_local_dev_only postgres \
psql -h 127.0.0.1 -U stratium_migrate -d stratium "$@"
}
psqlapp() { # the PDP's serving role — used to prove what it CANNOT do
dc exec -T -e PGPASSWORD=stratium_local_dev_only postgres \
psql -h 127.0.0.1 -U stratium -d stratium "$@"
}
1. Get operator tokens¶
The realm is stratium, the client is stratium-pap (public, direct access
grants enabled).
| User | Password | Role |
|---|---|---|
pap-viewer |
pap_viewer_local_dev_only |
viewer |
pap-editor |
pap_editor_local_dev_only |
editor |
pap-admin |
pap_admin_local_dev_only |
admin |
tok() {
curl -sS -X POST "http://$KC/realms/stratium/protocol/openid-connect/token" \
-d grant_type=password -d client_id=stratium-pap \
-d "username=$1" -d "password=$2" -d 'scope=openid email' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'
}
export EDITOR=$(tok pap-editor pap_editor_local_dev_only)
export ADMIN=$(tok pap-admin pap_admin_local_dev_only)
export VIEWER=$(tok pap-viewer pap_viewer_local_dev_only)
echo "editor=${#EDITOR} viewer=${#VIEWER} admin=${#ADMIN} chars"
Expected — three tokens around 1,100 characters:
PASS if all three are over 500 characters. A 0 means the token request
failed; re-check $KC.
Sanity check:
curl -sS -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $VIEWER" "http://$PAP/v1/resource-label-rules"
Expected: 200
These tokens expire after 5 minutes. The realm sets no
accessTokenLifespan, so Keycloak's 300-second default applies — shorter than a hand-paced walk through this plan. A bareunauthorizedbody, or a401, means the token aged out rather than anything being wrong with the request. Re-run the threeexportlines and continue; nothing else needs redoing, and rules you already created are unaffected.
Part A — the admin plane¶
No SQL. This is the whole operator-facing surface of the feature.
A1 — Register rules and read them back¶
Valid hierarchies and levels are fixed in the code:
nato:UNCLASSIFIED,RESTRICTED,CONFIDENTIAL,SECRET,TOP-SECRETcommercial:PUBLIC,INTERNAL,CONFIDENTIAL-COMMERCIAL,RESTRICTED-COMMERCIAL,HIGHLY-CONFIDENTIAL
mkrule() {
curl -sS -w '\n%{http_code}\n' -X POST "http://$PAP/v1/resource-label-rules" \
-H "Authorization: Bearer $EDITOR" -H 'Content-Type: application/json' \
-d "{\"prefix\": \"$1\", \"hierarchy\": \"$2\", \"level\": \"$3\"}"
}
mkrule "" nato UNCLASSIFIED # the catch-all
mkrule "/exports/" nato SECRET
mkrule "/exports/public/" nato UNCLASSIFIED # a lowering carve-out
Expected — three creates, each 201:
{"id":"3bbbf04c-…","tenant_id":"tenant-pap-e2e-integration","prefix":"","hierarchy":"nato","level":"UNCLASSIFIED"}
201
{"id":"66b00420-…","tenant_id":"tenant-pap-e2e-integration","prefix":"/exports/","hierarchy":"nato","level":"SECRET"}
201
{"id":"49246e8b-…","tenant_id":"tenant-pap-e2e-integration","prefix":"/exports/public/","hierarchy":"nato","level":"UNCLASSIFIED"}
201
PASS if all three return 201 and echo back the rule you sent.
Expected — three rules, the empty prefix first:
[{"prefix":"","hierarchy":"nato","level":"UNCLASSIFIED"},
{"prefix":"/exports/","hierarchy":"nato","level":"SECRET"},
{"prefix":"/exports/public/","hierarchy":"nato","level":"UNCLASSIFIED"}]
PASS if three rules come back ordered by prefix. Ordering is asserted by the query itself, so a different order is a real regression.
A2 — A duplicate prefix is refused¶
Expected:
PASS if 409. Note the existing rule is nato and this one is
commercial — the different hierarchy does not make it a different rule.
The key is UNIQUE (tenant_id, prefix), deliberately not
(tenant_id, prefix, hierarchy): a resource carries exactly one classification
in one hierarchy, so one prefix under two hierarchies would be a pair no
resource could satisfy, whose only symptom is total silent denial.
A3 — An invalid level or hierarchy is refused at write time¶
Expected — both 400, each naming the known values:
store: invalid request: level "SEKRET" is not a level of hierarchy "nato" (known: UNCLASSIFIED, RESTRICTED, CONFIDENTIAL, SECRET, TOP-SECRET)
400
store: invalid request: unknown classification hierarchy "natoo" (known: commercial, nato)
400
PASS if both are 400 and the message enumerates the valid values — an
error that only says "invalid" would leave an operator guessing.
Note what is not validated: the prefix. A typo there is accepted and produces a rule that silently never matches anything. It is the one field nothing can check, since any string is a legitimate prefix. See D4.
A4 — Role gating¶
curl -sS -o /dev/null -w 'viewer POST -> %{http_code}\n' \
-X POST "http://$PAP/v1/resource-label-rules" \
-H "Authorization: Bearer $VIEWER" -H 'Content-Type: application/json' \
-d '{"prefix":"/nope/","hierarchy":"nato","level":"SECRET"}'
RULE_ID=$(curl -sS -H "Authorization: Bearer $VIEWER" \
"http://$PAP/v1/resource-label-rules" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["id"])')
curl -sS -o /dev/null -w 'editor DELETE -> %{http_code}\n' \
-X DELETE "http://$PAP/v1/resource-label-rules/$RULE_ID" \
-H "Authorization: Bearer $EDITOR"
Expected:
PASS if both are 403. Create and update are editor; delete is admin.
A5 — Every write bumps the tenant epoch¶
The epoch is what invalidates a PEP's decision cache. A labelling change that did not bump it would leave stale authorization decisions in flight.
epoch() {
psqlm -tAc "SELECT set_config('app.tenant_id','$TENANT',false);
SELECT epoch FROM tenant_epoch WHERE tenant_id='$TENANT';" | tail -1
}
P="/probe-$(python3 -c 'import uuid;print(uuid.uuid4().hex[:8])')/" # unique per run
echo "before: $(epoch)"; mkrule "$P" nato RESTRICTED | tail -1; echo "after: $(epoch)"
Expected — a 201 between two values, the second strictly higher (yours
will differ; only the increase matters):
PASS if the create returned 201 and the epoch increased. Repeat with a
PUT and a DELETE if you want all three verbs — the trigger fires on each.
The prefix must be unique per run, and the status must be visible. An earlier draft used a fixed
/tmp-probe/and piped the create to/dev/null. On a second run that write returns409, the epoch correctly does not move, and the discarded output leaves you looking at a flat epoch with no explanation — reading a working trigger as a broken one. Measured on a re-run:before: 7 / after: 7.
Part B — the decision path¶
B1 — Seed an agent and a catalogued tool¶
export AGENT=$(curl -sS -X POST "http://$PAP/v1/agents" \
-H "Authorization: Bearer $EDITOR" -H 'Content-Type: application/json' \
-d '{"name":"manual-test-agent","provider":"anthropic","model_id":"claude",
"trust_tier":3,"enabled":true,"suspended":false,"allowed_tools":[]}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
echo "AGENT=$AGENT"
curl -sS -w '\n%{http_code}\n' -X POST "http://$PAP/v1/tool-catalog" \
-H "Authorization: Bearer $EDITOR" -H 'Content-Type: application/json' \
-d '{"tool_name":"read_file","action_tier":1,"verb":"read","resource_field":"path"}'
Expected:
AGENT=4eced803-d8bd-4a51-8e2c-4d846b47a189
{"id":"9cd10261-…","tenant_id":"tenant-pap-e2e-integration","tool_name":"read_file","action_tier":1,"verb":"read","resource_field":"path"}
201
PASS if $AGENT is a UUID and the catalog create returns 201 with
resource_field echoed back. An empty AGENT here breaks every later step.
On a second run the catalog create returns
409, sinceread_fileis already catalogued and there is no delete route for catalog entries. That is harmless — confirm the existing row is right and carry on:curl -sS -H "Authorization: Bearer $VIEWER" "http://$PAP/v1/tool-catalog" \ | python3 -c 'import json,sys for e in json.load(sys.stdin): print(e["tool_name"], "| resource_field:", e.get("resource_field","(none)"))'The agent, by contrast, needs a fresh
nameon each run —UNIQUE (tenant_id, name)applies there too.
resource_field is the load-bearing part. It names which field of the
call's raw_input is the resource identity. A tool whose row names none
produces no resource id, and therefore gets no label at all — see D2.
B2 — Set the agent's cap through the API, then seed a delegation that carries it¶
CreateDelegation over gRPC still will not work here — a root delegation
is clamped by what the principal holds, and this tenant deliberately carries
no principal policy (that absence is what makes B3 worth doing: reading
delegationEffect instead of the top-level effect). So it still fails:
That is a different gap from the one this step used to route around. The cap itself is no longer one of them:
curl -sS -w '\n%{http_code}\n' -X PUT "http://$PAP/v1/agents/$AGENT/classification-caps" \
-H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' \
-d '{"classification_caps":{"nato":"CONFIDENTIAL"}}'
Expected:
{"agent_id":"...","classification_caps":{"nato":"CONFIDENTIAL"},"catch_all":{"nato":"UNCLASSIFIED"},"unsatisfiable_tools":[...],"live_delegations":0,"applies":"at next issuance"}
200
PASS if 200 and classification_caps.nato echoes back CONFIDENTIAL.
This needs the catch-all rule from A1 (mkrule "" nato UNCLASSIFIED) to already
exist — SetAgentClassificationCaps refuses an uncovered hierarchy otherwise,
naming it in the error.
The delegation row itself still needs the SQL insert this plan always used —
for the principal-policy reason above, unrelated to the cap. What changes is
classification_caps: it is no longer a hand-typed literal. It reads back the
value the PUT above just set, off the agent row, so the statement copies an
operator-set value rather than choosing one of its own:
export DELEG=$(python3 -c 'import uuid;print(uuid.uuid4())')
psqlm -q -c "BEGIN;
SELECT set_config('app.tenant_id','$TENANT',true);
INSERT INTO delegations (id,tenant_id,user_id,agent_id,approved_tools,max_action_tier,
classification_caps,resource_constraints,purpose,expires_at,revoked,
parent_delegation_id,root_delegation_id,depth,chain_agent_ids,escalate_at)
VALUES ('$DELEG','$TENANT','tester@example.com','$AGENT',
'[\"read_file\",\"Read\",\"list_dir\"]',3,
(SELECT classification_caps FROM agents WHERE id='$AGENT'),'{}',
'manual test',now()+interval '1 hour',false,NULL,'$DELEG',0,'[\"$AGENT\"]',NULL);
COMMIT;"
Expected — the set_config echo, then silence. No ERROR: line:
PASS if no error appears. set_config is required in the same transaction:
RLS is the only tenant boundary and the table is FORCE ROW LEVEL SECURITY, so
even the owner is subject to the policy — without it the INSERT is rejected by
WITH CHECK. The (SELECT … FROM agents …) subquery, not a literal, is what
proves the two are the same value: rerun the PUT with a different level and the
next delegation you seed picks it up with no edit to this statement.
Then issue a token. ttl_seconds is required — omitting it fails with
ttl_seconds must be positive:
export TOKEN=$(grpcurl -plaintext -import-path proto -proto pdp/v1/pdp.proto \
-d "{\"delegation_id\":\"$DELEG\",\"ttl_seconds\":3600}" \
"$PDP" stratium.pdp.v1.PDPService/IssueToken \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
echo "token is ${#TOKEN} chars"
Expected:
PASS if the token is a few hundred characters. 0 means the RPC errored.
The token is a reference to the delegation, not a snapshot — the PDP loads it fresh on every decision, so later changes to the cap or the rules take effect on the next call without re-issuing.
B3 — Read the right field¶
This is the step most likely to make a working system look broken.
Effect's zero value is EFFECT_DENY and grpcurl omits default-valued fields,
so a denied decision has no effect key at all. Worse, the top-level
effect is DENY on every call below regardless of labelling, because the
decision is principal ∧ agent ∧ delegation and this tenant has no principal
policies. A full response looks like:
{
"derivedTier": 1,
"principalReason": "principal: no policies configured for this tenant",
"agentEffect": "EFFECT_ALLOW",
"delegationEffect": "EFFECT_ALLOW",
"deniedLeg": "principal",
"reason": "principal: no policies configured for this tenant",
"epoch": "7"
}
That response is a pass for labelling purposes: delegationEffect is
EFFECT_ALLOW. Read delegationEffect and delegationReason — the leg
classification caps are evaluated on. Treating an absent delegationEffect as
DENY is correct.
B4 — Run decisions¶
Run from the repository root — the -proto path is relative to it.
decide() { # decide <path> [tool] [extra-resource-json]
python3 - "$1" "${2:-read_file}" "${3:-{\}}" <<'PY' \
| grpcurl -plaintext -import-path proto -proto pdp/v1/pdp.proto -d @ \
"$PDP" stratium.pdp.v1.PDPService/Decide
import json, os, sys
path, tool, extra = sys.argv[1], sys.argv[2], json.loads(sys.argv[3])
print(json.dumps({
"agent_id": os.environ["AGENT"], "subject": "tester@example.com",
"tool": tool, "raw_input": json.dumps({"path": path}),
"resource": extra, "token": os.environ["TOKEN"],
}))
PY
}
show() { decide "$@" | python3 -c \
'import json,sys; d=json.load(sys.stdin); print(d.get("delegationEffect","EFFECT_DENY"), "|", d.get("delegationReason",""))'; }
for p in /exports/report.csv /exports/public/notice.txt /elsewhere/thing.txt; do
printf '%-30s ' "$p"; show "$p"
done
Expected — exactly these three lines:
/exports/report.csv EFFECT_DENY | resource classification SECRET exceeds delegation cap CONFIDENTIAL in hierarchy "nato" (depth 0)
/exports/public/notice.txt EFFECT_ALLOW |
/elsewhere/thing.txt EFFECT_ALLOW |
PASS if all three match. Each proves something different:
| Line | Proves |
|---|---|
| 1 denies | The label is resolved and the cap is enforced — /exports/ matches, SECRET > CONFIDENTIAL |
| 2 allows | Longest prefix wins even when it lowers. A public file inside a classified directory is genuinely public |
| 3 allows | The empty-prefix catch-all covers everything unmatched |
If line 2 denies, longest-prefix resolution is broken and the shorter rule is winning — the single most important behaviour in the feature.
B5 — An unlabelled resource denies¶
CATCHALL=$(curl -sS -H "Authorization: Bearer $VIEWER" \
"http://$PAP/v1/resource-label-rules" \
| python3 -c 'import json,sys; print([r["id"] for r in json.load(sys.stdin) if r["prefix"]==""][0])')
curl -sS -o /dev/null -w 'admin DELETE -> %{http_code}\n' \
-X DELETE "http://$PAP/v1/resource-label-rules/$CATCHALL" \
-H "Authorization: Bearer $ADMIN"
show /nowhere/thing.txt
Expected:
admin DELETE -> 204
EFFECT_DENY | resource declares no classification; the delegation's classification cap cannot be evaluated and therefore denies (depth 0)
PASS if the delete is 204 and the decision now denies where it allowed in
B4. This is the cliff: a cap with no catch-all denies every unmatched
resource. Correct behaviour that will read as an outage.
Re-create the catch-all before continuing: mkrule "" nato UNCLASSIFIED
B6 — The carve-out log line¶
show /exports/public/notice.txt >/dev/null # generate one
dc logs pdp-pap | grep carve-out | tail -1
Expected — one INFO line naming both rules:
pdp-pap-1 | time=… level=INFO msg="resource label carve-out lowers the classification it overrides" rule_prefix=/exports/public/ rule_hierarchy=nato rule_level=UNCLASSIFIED overrides_prefix=/exports/ overrides_hierarchy=nato overrides_level=SECRET
PASS if the line carries all six rule_*/overrides_* fields and no
resource id. The id derives from attacker-influenced input; the rules are
operator-authored configuration, which is why one is logged and the other is
not.
See D1 before concluding anything from an empty grep.
Part C — the security properties¶
These four are the ones to re-run after any change to the decision path. Two of them were live defects during development.
C1 — A caller cannot declare its own classification¶
Expected — identical to B4's first line, as though the wire had said nothing:
EFFECT_DENY | resource classification SECRET exceeds delegation cap CONFIDENTIAL in hierarchy "nato" (depth 0)
PASS if it still denies on SECRET. An EFFECT_ALLOW here is a privilege
escalation — it would mean any token holder can declare itself unclassified
and walk past every cap.
C2 — A caller cannot choose which rule labels it¶
The subtler half: raw_input names the classified path while the wire
resource.id names a permissive decoy, on an uncatalogued tool.
Expected:
EFFECT_DENY | resource declares no classification; the delegation's classification cap cannot be evaluated and therefore denies (depth 0)
PASS if it denies for that reason. Read has no catalog row, so the PDP
derives no identity and refuses to label rather than trusting the caller's id.
Read the reason, not just the effect: a denial naming SECRET exceeds… would
mean something else denied it and this property went untested. An
EFFECT_ALLOW means a caller can pick its own label by naming a decoy.
C3 — An unrankable label denies¶
psqlm -q -c "BEGIN; SELECT set_config('app.tenant_id','$TENANT',true);
INSERT INTO resource_label_rules (tenant_id,prefix,hierarchy,level)
VALUES ('$TENANT','/exports/typo/','natoo','UNCLASSIFIED'); COMMIT;"
show /exports/typo/x
Raw SQL is deliberate: the API refuses this rule (A3), and the point is that the decision path must refuse it too rather than trusting a row that bypassed validation.
Expected:
EFFECT_DENY | unknown classification hierarchy "natoo"; the delegation's classification cap cannot be evaluated and therefore denies (depth 0)
PASS if it denies naming the unknown hierarchy. An EFFECT_ALLOW means
one mistyped character turns a capped resource into an unconditional allow.
Clean up: psqlm -q -c "BEGIN; SELECT set_config('app.tenant_id','$TENANT',true); DELETE FROM resource_label_rules WHERE prefix='/exports/typo/'; COMMIT;"
C4 — The serving role cannot write¶
psqlapp -c "BEGIN; SELECT set_config('app.tenant_id','$TENANT',true);
DELETE FROM resource_label_rules; COMMIT;"
Expected:
PASS if the error appears and the rules survive — re-list them to be
sure. The PDP's role holds SELECT and nothing more; the absent grants are the
enforcement.
C5 — RLS is the tenant boundary¶
psqlm -tAc "SELECT set_config('app.tenant_id','some-other-tenant',false);
SELECT count(*) FROM resource_label_rules;"
Expected — the config echo, then:
PASS if the count is 0. This runs as the table owner, so a non-zero
count would mean FORCE ROW LEVEL SECURITY is not in effect. No Go query
filters by tenant_id; RLS is the only thing scoping reads.
Part D — traps¶
Four places where correct behaviour looks like a bug, or a bug looks like correct behaviour. Each was found during development.
D1 — Grepping for carve-out after a raise finds nothing¶
BEFORE=$(dc logs pdp-pap | grep -c carve-out)
mkrule "/exports/audit/" nato TOP-SECRET >/dev/null
show /exports/audit/x
AFTER=$(dc logs pdp-pap | grep -c carve-out)
echo "carve-out lines before=$BEFORE after=$AFTER"
Expected — the decision enforces, and the log count is unchanged:
EFFECT_DENY | resource classification TOP-SECRET exceeds delegation cap CONFIDENTIAL in hierarchy "nato" (depth 0)
carve-out lines before=3 after=3
PASS if the counts are equal. Only three overrides are logged — one that lowers within a hierarchy, one that switches hierarchy, one that covers an unrankable rule. A raise within one hierarchy is deliberately silent because it tightens.
The line is also emitted per decision, not per write, so nothing appears
until traffic reaches an id under that prefix. Finding nothing is not evidence
your rule is inert — read the rule set back with GET /v1/resource-label-rules
instead.
D2 — The catch-all does not cover a tool with no resource_field¶
curl -sS -o /dev/null -X POST "http://$PAP/v1/tool-catalog" \
-H "Authorization: Bearer $EDITOR" -H 'Content-Type: application/json' \
-d '{"tool_name":"list_dir","action_tier":1,"verb":"read"}' # no resource_field
show /exports/public/notice.txt list_dir
Expected — a denial, with the catch-all still registered and a path that would otherwise resolve to UNCLASSIFIED:
EFFECT_DENY | resource declares no classification; the delegation's classification cap cannot be evaluated and therefore denies (depth 0)
PASS if it denies. A rule labels an id; a tool naming no resource field
produces no id, so no rule matches it — not even the empty-prefix one. The
path in raw_input is irrelevant: nothing extracts an id from it.
Not a corner case — most tools have no single unambiguous resource argument, and
the shipped seed leaves Glob, Grep, WebFetch and WebSearch fieldless on
purpose. Before concluding the cliff is closed, walk the delegation's approved
tools and check each catalog row.
D3 — Revoking and re-issuing follows the AGENT's cap now, not the old delegation's¶
Updated for the classification-caps route (see the intro and B2). This trap predates
PUT /v1/agents/{id}/classification-caps. It used to be absolute — every root came back with{}, no matter what. It no longer is:TestCreateDelegation_RootCopiesAgentCaps(internal/pdpgrpc/delegation_test.go) pins that a root now copies whatever cap is on its agent. The trap is narrower, not gone: it now catches an operator who caps a delegation (by hand, as B2 still does, or believes the UI did) without capping the agent — that delegation's replacement, on revoke-and-reissue, still comes back with whatever the agent has,{}unlessPUT .../classification-capswas also called.
psqlm -tAc "SELECT set_config('app.tenant_id','$TENANT',false);
SELECT id, classification_caps FROM delegations ORDER BY created_at DESC LIMIT 3;"
Expected — every row you seeded in B2 shows the cap you wrote:
PASS if the caps are there. You still will not see a {} row to compare
against on this tenant — B2's delegation is still SQL-seeded, for the
principal-policy reason B2 explains, not because the API cannot produce a
capped row: deploy/stratium verify issues one through the real
CreateDelegation RPC on its own tenant and observes the same enforcement,
end to end, with no SQL at all.
The narrower claim this step now demonstrates — a delegation's cap tracks its agent's cap at issuance, not whatever an earlier delegation happened to carry — is pinned by these guard tests:
| Guard | Holds |
|---|---|
TestCreateDelegation_RootIsCreatedWithNoClassificationCap |
A CAPLESS agent's root is created with {} |
TestCreateDelegation_RootCopiesAgentCaps |
A CAPPED agent's root copies it |
TestRouteTable_ExposesNoWriteSurfaceForAClassificationCap |
No route sets a cap on the delegation directly — only PUT .../classification-caps on the agent does |
An empty map is not a low cap — the check allows immediately when no cap is
configured, so a session re-issued for a still-capless agent has classification
enforcement switched off entirely on every hierarchy, silently. Revoke-and-
re-issue now does adjust enforcement, but only through the agent: change what
PUT .../classification-caps set, then revoke and re-issue, and the new
delegation carries the new value. Revoking and re-issuing with the agent
untouched still just repeats whatever it already had — capping one delegation
by hand, as B2 does, and expecting a later re-issue to remember it, is the
one way this still bites.
D4 — /data matches /database/x¶
mkrule "/exp" nato TOP-SECRET >/dev/null # meant "/exports/", forgot the slash
show /expenses/q3.csv # a different directory entirely
Expected:
EFFECT_DENY | resource classification TOP-SECRET exceeds delegation cap CONFIDENTIAL in hierarchy "nato" (depth 0)
PASS if it denies — which is the hazard, not a failure. /exp is a prefix
of /expenses/q3.csv and is longer than the catch-all, so an unrelated
directory inherits a classification nobody assigned it. Matching is plain string
prefix with no separator vocabulary. Nothing warns about this; the rule is valid
and behaves exactly as specified.
Write the trailing separator — /exports/ — unless you intend the broader
match.
Clean up, or later runs inherit it: delete the /exp rule via
DELETE /v1/resource-label-rules/{id} as admin.
Checklist¶
| Step | Pass criterion |
|---|---|
| 0 | All services Healthy; three non-empty ports |
| 1 | Three tokens; sanity check 200 |
| A1 | Three 201s; list returns three rules, empty prefix first |
| A2 | 409 on a duplicate prefix in a different hierarchy |
| A3 | 400 twice, each naming the valid values |
| A4 | 403 for viewer-create and editor-delete |
| A5 | Epoch strictly increases |
| B1 | Agent UUID; catalog 201 echoing resource_field |
| B2 | Caps PUT returns 200 with nato: CONFIDENTIAL; insert with no ERROR:; token a few hundred chars |
| B4 | Deny / allow / allow — the middle one is longest-prefix-lowers |
| B5 | Delete 204; the previously-allowed path now denies |
| B6 | One INFO line, six rule fields, no resource id |
| C1 | Still denies on SECRET despite the wire claiming UNCLASSIFIED |
| C2 | Denies with no classification, not with exceeds cap |
| C3 | Denies naming the unknown hierarchy |
| C4 | permission denied; rules survive |
| C5 | Count 0 as the table owner |
| D1 | Carve-out log count unchanged across a raise |
| D2 | Fieldless tool denies with the catch-all present |
| D3 | Seeded delegations have a cap; a CAPLESS agent's root still comes back {} |
| D4 | /exp classifies /expenses/… — hazard reproduced |
The four that matter most are C1, C2, C3 and B4's middle line. The first three are security properties whose failure is a privilege escalation; the fourth is the feature's central behaviour.
What this plan does not test¶
- Anything reached through a PEP. These calls go straight to the PDP; the
proxyandextauthzsurfaces additionally require DPoP-bound tokens. - Cache invalidation end to end. A5 proves the epoch bumps; proving a PEP drops its cached decision needs a PEP in the path.
- Policy engines seeing the label. Classification and hierarchy reach Rego and Cedar policy input, but exercising that needs a tenant policy reading them.
- Multi-hierarchy tenants. Every step above uses
nato. A tenant runningnatoandcommercialside by side deserves its own pass: a carve-out that switches hierarchy escapes the outer hierarchy's cap entirely, and is logged as its own line rather than as a lowering.
If the stack will not start¶
postgres exiting 1 with No space left on device means the Docker disk is
full, not that anything here is broken.
Use docker builder prune -af only. Do not run docker system prune, and
never --volumes: unused volumes on a shared Docker host may hold another
project's data. Build cache is regenerable; volumes are not.
Cleaning up¶
-v here is scoped to this compose file by -f, so it removes only this
project's volume. The tenant used above is the same one make integration uses,
and that target destroys volumes before it runs — so leftover manual state
cannot corrupt a later automated run, or the reverse.