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:
| Surface | Reads the tags as |
|---|---|
GET /api/v1/documents?tags= | a list filter |
POST /api/v1/knowledge/search | a search filter over chunks |
A policy condition on soat:ResourceTag/<key> | an authorization rule |
Prerequisites
- SOAT running locally at
http://localhost:5047(Quick Start). - An embedding model configured (
EMBEDDING_PROVIDER,EMBEDDING_MODEL) — Step 6 runs a real semantic search. See Generating Embeddings. - Permissions in Practice for users, policies and attachment.
- IAM — Tags for the condition-key reference.
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
import { SoatClient } from '@soat/sdk';
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.
- CLI
- SDK
- curl
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"
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: login, error: loginErr } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
if (loginErr) throw new Error(JSON.stringify(loginErr));
const adminSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: login.token,
});
const { data: project, error: projectErr } =
await adminSoat.projects.createProject({ body: { name: 'Handbook ABAC' } });
if (projectErr) throw new Error(JSON.stringify(projectErr));
const PROJECT_ID = project.id; // proj_…
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
PROJECT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"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).
- CLI
- SDK
- curl
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"
const { data: financeDoc, error: financeErr } =
await adminSoat.documents.createDocument({
body: {
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' },
},
});
if (financeErr) throw new Error(JSON.stringify(financeErr));
const { data: engDoc, error: engErr } =
await adminSoat.documents.createDocument({
body: {
project_id: PROJECT_ID,
path: '/handbook/oncall.txt',
content:
'The on-call rotation changes every Monday. Page the primary before escalating.',
tags: { team: 'eng' },
},
});
if (engErr) throw new Error(JSON.stringify(engErr));
const FINANCE_DOC_ID = financeDoc.id; // doc_…
const ENG_DOC_ID = engDoc.id; // doc_…
FINANCE_DOC_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/documents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"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=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/documents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"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.
- CLI
- SDK
- curl
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.
const { data: financeOnly } = await adminSoat.documents.listDocuments({
query: { project_id: PROJECT_ID, tags: ['team:finance'] },
});
console.log(financeOnly.data.map((d) => d.id)); // [FINANCE_DOC_ID]
curl -s -G "$SOAT_BASE_URL/api/v1/documents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
--data-urlencode "project_id=$PROJECT_ID" \
--data-urlencode "tags=team:finance" \
| jq '[.data[].id]'
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.
- CLI
- SDK
- curl
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')
const { data: dana, error: danaErr } = await adminSoat.users.createUser({
body: { username: 'dana-tags', password: 'Dana1234!' },
});
if (danaErr) throw new Error(JSON.stringify(danaErr));
const { data: policy, error: policyErr } =
await adminSoat.policies.createPolicy({
body: {
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' },
},
},
],
},
},
});
if (policyErr) throw new Error(JSON.stringify(policyErr));
await adminSoat.users.attachUserPolicies({
path: { user_id: dana.id },
body: { policy_ids: [policy.id] },
});
const { data: danaLogin } = await soat.users.loginUser({
body: { username: 'dana-tags', password: 'Dana1234!' },
});
const danaSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: danaLogin.token,
});
DANA_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"username":"dana-tags","password":"Dana1234!"}' | jq -r '.id')
POLICY_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/policies" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"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')
curl -s -X PUT "$SOAT_BASE_URL/api/v1/users/$DANA_ID/policies" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"policy_ids\":[\"$POLICY_ID\"]}" | jq .
DANA_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"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).
- CLI
- SDK
- curl
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'
const { data: visible } = await danaSoat.documents.listDocuments({
query: { project_id: PROJECT_ID },
});
console.log(visible.data.map((d) => d.id)); // [ENG_DOC_ID]
curl -s -G "$SOAT_BASE_URL/api/v1/documents" \
-H "Authorization: Bearer $DANA_TOKEN" \
--data-urlencode "project_id=$PROJECT_ID" \
| jq '[.data[].id]'
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.
- CLI
- SDK
- curl
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.
const QUESTION =
'what should I know about the on-call rotation and the payroll date';
const { data: asAdmin } = await adminSoat.knowledge.searchKnowledge({
body: { project_id: PROJECT_ID, query: QUESTION, limit: 10 },
});
// Both — the finance chunk is indexed and matches the query
console.log(asAdmin.results.map((r) => r.document_id));
const { data: asDana } = await danaSoat.knowledge.searchKnowledge({
body: { project_id: PROJECT_ID, query: QUESTION, limit: 10 },
});
// Only ENG_DOC_ID — the condition compiled into the vector query
console.log(asDana.results.map((r) => r.document_id));
QUESTION="what should I know about the on-call rotation and the payroll date"
# Admin — both documents
curl -s -X POST "$SOAT_BASE_URL/api/v1/knowledge/search" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"query\":\"$QUESTION\",\"limit\":10}" \
| jq '[.results[].document_id] | unique'
# Dana — only the eng document
curl -s -X POST "$SOAT_BASE_URL/api/v1/knowledge/search" \
-H "Authorization: Bearer $DANA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"query\":\"$QUESTION\",\"limit\":10}" \
| jq '[.results[].document_id] | unique'
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.
- CLI
- SDK
- curl
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'
const { data: financeMemory } = await adminSoat.memories.createMemory({
body: {
project_id: PROJECT_ID,
name: 'payroll-notes',
tags: { team: 'finance' },
},
});
const { data: engMemory } = await adminSoat.memories.createMemory({
body: {
project_id: PROJECT_ID,
name: 'oncall-notes',
tags: { team: 'eng' },
},
});
await adminSoat.memoryEntries.createMemoryEntry({
body: {
memory_id: financeMemory.id,
content: 'Salary bands are reviewed every January.',
},
});
const { data: engEntry } = await adminSoat.memoryEntries.createMemoryEntry({
body: {
memory_id: engMemory.id,
content: 'Page the primary before escalating to the secondary.',
},
});
// In the eng memory, but tagged finance itself
const { data: financeEntry } = await adminSoat.memoryEntries.createMemoryEntry({
body: {
memory_id: engMemory.id,
content: 'Contractor invoices are approved by the finance team.',
tags: { team: 'finance' },
},
});
const { data: memories } = await danaSoat.memories.listMemories({
query: { project_id: PROJECT_ID },
});
console.log(memories.data.map((m) => m.id)); // [engMemory.id]
const { data: entries } = await danaSoat.memoryEntries.listMemoryEntries({
query: { memory_id: engMemory.id },
});
console.log(entries.data.map((e) => e.id)); // [engEntry.id]
const { error: financeEntriesErr } =
await danaSoat.memoryEntries.listMemoryEntries({
query: { memory_id: financeMemory.id },
});
console.log(financeEntriesErr?.status); // 403
const { data: search } = await danaSoat.knowledge.searchKnowledge({
body: {
project_id: PROJECT_ID,
memory_ids: [financeMemory.id, engMemory.id],
limit: 50,
},
});
// Only engEntry — financeEntry is excluded by its own tag, and the finance
// memory's entries by their container's.
console.log(search.results.map((r) => r.entry_id));
FINANCE_MEM_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/memories" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"payroll-notes\",\"tags\":{\"team\":\"finance\"}}" \
| jq -r '.id')
ENG_MEM_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/memories" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"oncall-notes\",\"tags\":{\"team\":\"eng\"}}" \
| jq -r '.id')
curl -s -X POST "$SOAT_BASE_URL/api/v1/memory-entries" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"memory_id\":\"$FINANCE_MEM_ID\",\"content\":\"Salary bands are reviewed every January.\"}" > /dev/null
ENG_ENTRY_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/memory-entries" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"memory_id\":\"$ENG_MEM_ID\",\"content\":\"Page the primary before escalating to the secondary.\"}" \
| jq -r '.id')
FINANCE_ENTRY_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/memory-entries" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"memory_id\":\"$ENG_MEM_ID\",\"content\":\"Contractor invoices are approved by the finance team.\",\"tags\":{\"team\":\"finance\"}}" \
| jq -r '.id')
# Dana's memories — only the eng one
curl -s -G "$SOAT_BASE_URL/api/v1/memories" \
-H "Authorization: Bearer $DANA_TOKEN" \
--data-urlencode "project_id=$PROJECT_ID" | jq '[.data[].id]'
# Dana's entries in the eng memory — the finance-tagged entry is absent
curl -s -G "$SOAT_BASE_URL/api/v1/memory-entries" \
-H "Authorization: Bearer $DANA_TOKEN" \
--data-urlencode "memory_id=$ENG_MEM_ID" | jq '[.data[].id]'
# The finance memory itself — 403
curl -s -o /dev/null -w "%{http_code}\n" -G "$SOAT_BASE_URL/api/v1/memory-entries" \
-H "Authorization: Bearer $DANA_TOKEN" \
--data-urlencode "memory_id=$FINANCE_MEM_ID"
# Knowledge search across both memories
curl -s -X POST "$SOAT_BASE_URL/api/v1/knowledge/search" \
-H "Authorization: Bearer $DANA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"memory_ids\":[\"$FINANCE_MEM_ID\",\"$ENG_MEM_ID\"],\"limit\":50}" \
| jq '[.results[].entry_id]'
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).
- CLI
- SDK
- curl
# → 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" }
}
}
]
}'
const { error: typoErr } = await adminSoat.policies.createPolicy({
body: {
name: 'typo-condition-key',
description: 'Rejected: ResourceTags is not a condition key',
document: {
statement: [
{
effect: 'Deny',
action: ['documents:ListDocuments'],
resource: [`srn:${PROJECT_ID}:*:*`],
// Plural — not a key the platform supplies
condition: {
StringEquals: { 'soat:ResourceTags/team': 'finance' },
},
},
],
},
},
});
console.log(typoErr?.status); // 400
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST "$SOAT_BASE_URL/api/v1/policies" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"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
- IAM — Tags — the condition keys, operators, and which resources honor them.
- Permissions in Practice — users, policies, API keys and the intersection rule.
- Knowledge — the other filters search accepts.