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:ListMemories",
"memories:ListMemoryEntries",
"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 memories

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

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

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

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

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

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

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

Dana sees the eng memory and not the finance one:

SOAT_TOKEN="$DANA_TOKEN" soat list-memories --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 memory she can read, the entry carrying the excluded tag is still filtered out — the entry's own bag, not its container's:

SOAT_TOKEN="$DANA_TOKEN" soat list-memory-entries --memory-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 is unreachable outright, so nothing inside it can be listed:

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

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

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

# Admin, same query, sees all three entries
soat search-knowledge \
--project-id "$PROJECT_ID" \
--memory-ids '["'"$FINANCE_MEM_ID"'","'"$ENG_MEM_ID"'"]' \
--limit 50 \
| jq -e '[.results[].entry_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). Memory entries are the one place two bags meet, and the stricter of the two wins.

Next steps