Skip to main content

Tag-Based Access Control

Permissions in Practice grants access by action and by resource. This tutorial grants it by attribute: two documents tagged team: finance and team: eng, and a user whose policy condition excludes the finance one — from the listing and from semantic search alike, without naming a single document ID.

The same tag bag drives three surfaces, so the vocabulary is written once:

SurfaceReads the tags as
GET /api/v1/documents?tags=a list filter
POST /api/v1/knowledge/searcha search filter over chunks
A policy condition on soat:ResourceTag/<key>an authorization rule

Prerequisites​

export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin and create a project​

Admin bypasses policy evaluation (IAM — Authentication), so every setup step below is unfiltered — that is what makes Step 5 and Step 6 meaningful.

ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN

PROJECT_ID=$(soat create-project --name "Handbook ABAC" | jq -r '.id')
echo "project: $PROJECT_ID"

Step 2 — Create two documents with different tags​

tags is a flat object of string values on POST /api/v1/documents; an array or a nested object is rejected with 400 VALIDATION_FAILED rather than coerced (Documents — Tags).

FINANCE_DOC_ID=$(soat create-document \
--project-id "$PROJECT_ID" \
--path /handbook/payroll.txt \
--content "Payroll runs on the 25th of each month. Salary bands are reviewed every January." \
--tags '{"team":"finance"}' | jq -r '.id')

ENG_DOC_ID=$(soat create-document \
--project-id "$PROJECT_ID" \
--path /handbook/oncall.txt \
--content "The on-call rotation changes every Monday. Page the primary before escalating." \
--tags '{"team":"eng"}' | jq -r '.id')

echo "finance: $FINANCE_DOC_ID"
echo "eng : $ENG_DOC_ID"

Step 3 — Filter the listing by tag​

Repeat ?tags= for several pairs; all must be present with exactly that value. The pair splits on the first colon, so a value may itself contain colons.

soat list-documents --project-id "$PROJECT_ID" --tags team:finance \
| jq -e --arg id "$FINANCE_DOC_ID" '(.data | length) == 1 and .data[0].id == $id'

jq -e exits non-zero when the assertion is false, so this line fails loudly rather than printing something wrong.


Step 4 — Grant access by attribute, not by ID​

One statement allows listing and searching across the whole project, then subtracts everything tagged team: finance with a StringNotEquals condition on soat:ResourceTag/team.

The condition never names $FINANCE_DOC_ID. A document tagged team: finance tomorrow is out of scope the moment it is written — no policy edit.

DANA_ID=$(soat create-user --username dana-tags --password Dana1234! | jq -r '.id')

POLICY_ID=$(soat create-policy \
--name "handbook-except-finance" \
--description "Read and search the handbook, except anything tagged team:finance" \
--document '{
"statement": [
{
"effect": "Allow",
"action": [
"documents:ListDocuments",
"memories:ListMemoryStores",
"memories:ListMemories",
"knowledge:SearchKnowledge"
],
"resource": ["srn:'"$PROJECT_ID"':*:*"],
"condition": {
"StringNotEquals": { "soat:ResourceTag/team": "finance" }
}
}
]
}' | jq -r '.id')

soat attach-user-policies --user-id "$DANA_ID" --policy-ids '["'"$POLICY_ID"'"]'

DANA_TOKEN=$(soat login-user --username dana-tags --password Dana1234! | jq -r '.token')

Step 5 — The listing narrows​

Dana asks for the whole project and gets one document. The condition compiled into the SQL query — the finance row is never read, so there is nothing to filter client-side (IAM — Tags).

SOAT_TOKEN="$DANA_TOKEN" soat list-documents --project-id "$PROJECT_ID" \
| jq -e --arg eng "$ENG_DOC_ID" --arg fin "$FINANCE_DOC_ID" \
'[.data[].id] as $ids | ($ids | index($eng)) != null and ($ids | index($fin)) == null'

Step 6 — Semantic search narrows the same way​

This is the step that matters. The query below is deliberately about both documents. As admin it retrieves both; as Dana the finance chunk is not in the candidate set at all, because the same condition compiled into the vector query (Knowledge).

Retrieval that filters after ranking leaks: the excluded document still consumes result slots and still reaches whatever ranks them. Here it is never a candidate.

QUESTION="what should I know about the on-call rotation and the payroll date"

# Admin sees both documents — the finance chunk is indexed and reachable
soat search-knowledge \
--project-id "$PROJECT_ID" \
--query "$QUESTION" \
--limit 10 \
| jq -e --arg eng "$ENG_DOC_ID" --arg fin "$FINANCE_DOC_ID" \
'[.results[].document_id] as $ids | ($ids | index($eng)) != null and ($ids | index($fin)) != null'

# Dana, same query, same project — only the eng chunk comes back
SOAT_TOKEN="$DANA_TOKEN" soat search-knowledge \
--project-id "$PROJECT_ID" \
--query "$QUESTION" \
--limit 10 \
| jq -e --arg eng "$ENG_DOC_ID" --arg fin "$FINANCE_DOC_ID" \
'[.results[].document_id] as $ids | ($ids | index($eng)) != null and ($ids | index($fin)) == null'

The two assertions are a pair: the first proves the finance document is in the index and matches the query, so its absence from the second is the policy and not the ranking.


Step 7 — The same condition, on memory stores​

Documents are one store; the other is Memories. A memory is the one resource with two tag bags — its own and its store's — and both are evaluated, so a memory is never more visible than the store holding it.

Create two memory stores mirroring the documents, plus one memory that sits in the eng store while carrying the finance tag itself. That last memory is what separates the two bags.

FINANCE_MEM_ID=$(soat create-memory-store \
--project-id "$PROJECT_ID" \
--name payroll-notes \
--tags '{"team":"finance"}' | jq -r '.id')

ENG_MEM_ID=$(soat create-memory-store \
--project-id "$PROJECT_ID" \
--name oncall-notes \
--tags '{"team":"eng"}' | jq -r '.id')

soat create-memory \
--memory-store-id "$FINANCE_MEM_ID" \
--content "Salary bands are reviewed every January."

ENG_ENTRY_ID=$(soat create-memory \
--memory-store-id "$ENG_MEM_ID" \
--content "Page the primary before escalating to the secondary." | jq -r '.id')

# In the eng memory store, but tagged finance itself
FINANCE_ENTRY_ID=$(soat create-memory \
--memory-store-id "$ENG_MEM_ID" \
--content "Contractor invoices are approved by the finance team." \
--tags '{"team":"finance"}' | jq -r '.id')

Dana sees the eng memory store and not the finance one:

SOAT_TOKEN="$DANA_TOKEN" soat list-memory-stores --project-id "$PROJECT_ID" \
| jq -e --arg eng "$ENG_MEM_ID" --arg fin "$FINANCE_MEM_ID" \
'[.data[].id] as $ids | ($ids | index($eng)) != null and ($ids | index($fin)) == null'

Inside the store she can read, the memory carrying the excluded tag is still filtered out — the memory's own bag, not its store's:

SOAT_TOKEN="$DANA_TOKEN" soat list-memories --memory-store-id "$ENG_MEM_ID" \
| jq -e --arg eng "$ENG_ENTRY_ID" --arg fin "$FINANCE_ENTRY_ID" \
'[.data[].id] as $ids | ($ids | index($eng)) != null and ($ids | index($fin)) == null'

And the finance memory store is unreachable outright, so nothing inside it can be listed:

# → expect-fail
SOAT_TOKEN="$DANA_TOKEN" soat list-memories --memory-store-id "$FINANCE_MEM_ID"

Knowledge search reads both stores through the same policy. Asking for both memory stores returns only the memories Dana may read:

SOAT_TOKEN="$DANA_TOKEN" soat search-knowledge \
--project-id "$PROJECT_ID" \
--memory-store-ids '["'"$FINANCE_MEM_ID"'","'"$ENG_MEM_ID"'"]' \
--limit 50 \
| jq -e --arg eng "$ENG_ENTRY_ID" --arg fin "$FINANCE_ENTRY_ID" \
'[.results[].memory_id] as $ids | ($ids | index($eng)) != null and ($ids | index($fin)) == null'

# Admin, same query, sees all three memories
soat search-knowledge \
--project-id "$PROJECT_ID" \
--memory-store-ids '["'"$FINANCE_MEM_ID"'","'"$ENG_MEM_ID"'"]' \
--limit 50 \
| jq -e '[.results[].memory_id] | length == 3'

Step 8 — A misspelled condition key is refused at write time​

soat:ResourceType and soat:ResourceTag/<key> are the only keys the platform supplies. A key it never supplies can never match, so a Deny carrying a typo would silently stop denying — a fail-open produced by a plural. POST /api/v1/policies rejects it with 400 VALIDATION_FAILED instead (IAM — Condition Keys).

# → expect-fail
soat create-policy \
--name "typo-condition-key" \
--description "Rejected: ResourceTags is not a condition key" \
--document '{
"statement": [
{
"effect": "Deny",
"action": ["documents:ListDocuments"],
"resource": ["srn:'"$PROJECT_ID"':*:*"],
"condition": {
"StringEquals": { "soat:ResourceTags/team": "finance" }
}
}
]
}'

What this buys you​

One tag bag, read three ways: ?tags= filtered the listing, the same pairs scoped the knowledge search, and soat:ResourceTag/team decided authorization — all reading the same column, with the same exact-match rule. Tag a resource and every surface already knows what to do with it.

The scope of a policy is then a property of the data, not a list of IDs: onboard a team by tagging its documents, and revoke by retagging.

One tag vocabulary spans both knowledge stores and every other tagged resource — actors, conversations, files and sessions read it the same way (IAM — Tags). Memories are the one place two bags meet, and the stricter of the two wins.

Next steps​