Security context
The security context describes the caller of a query: an email, admin flags, tags and group memberships. It travels with the request and is read by the branch’s security policies to decide which rows the statement may return and which values it may show. 0sql has no users of its own; your application builds the context from its own authentication system, per request.
The JSON
"context": {
"email": "tank@matrix.com",
"system_admin": false,
"project_admin": false,
"tags": ["region:emea"],
"groups": [
{"name": "CC-TMNT", "tags": ["call_center_id:TMNT"]},
{"name": "CC-AVCL", "tags": ["call_center_id:AVCL", "cat:men"]}
]
}
| key | type | default | read by |
|---|---|---|---|
email | string | none | policies with source: user, value_from: email |
system_admin | bool | false | bypasses policies whose bypass.system_admin is true (the default) |
project_admin | bool | false | bypasses policies whose bypass.project_admin is true |
tags | string[] of key:value | [] | policies with source: user, value_from: tag |
groups | [{"name", "tags"}] | [] | policies with source: groups; name and tags are each optional |
Every key is optional. Unknown keys, at either level, are rejected. Nothing from the context is stored.
When it is required
A branch with at least one policy refuses to plan without a context:
{"error": {"class": "ContextRequired", "message": "this branch has security policies; a context is required to plan"}}
HTTP 400. On a branch with no policies the context is optional and the security phase is skipped. POST .../explore accepts a context and ignores it. An empty object {} is a valid context: it identifies nobody, so every fired policy resolves nothing and applies its unresolved rule.
How a policy reads it
A policy (authored in security.yml, see Row-level security) fires on a node when the node projects a field (not a calculation) carrying one of the policy’s trigger tags or listed by name. Then, per policy:
-
Bypass. If
bypass.system_adminis set and the context hassystem_admin: true, orbypass.project_adminandproject_admin: true, the policy is skipped. -
Allowed values of the policy’s context dimension are resolved from the context:
sourcevalue_fromallowed values groupsnamethe names of the caller’s groups groupstagthe values of group tags whose key is the policy’s tag_keyuseremailthe caller’s email usertagthe values of the caller’s own tagswith that keyValues are deduplicated. Other combinations resolve nothing.
-
Unresolved. Nothing resolved and the policy says
unresolved: allow: skipped.unresolved: deny(the default):filter_datarendersWHERE 1 = 0andmask_datamasks every value. -
Apply.
filter_dataaddsWHERE LOWER(<context dimension>) IN ('tmnt', 'avcl')to the node.mask_datawraps the triggered field:CASE WHEN LOWER(<context dimension>) IN (...) THEN field ELSE <mask> END, where the mask ismask_value(default#######) for a string field andNULLfor a numeric one.
If the context dimension cannot be joined from the node that fired the policy, the planner refuses rather than return unprotected rows:
{"error": {"class": "Planner::SecurityPolicyError",
"message": "Security policy context dimension 'Call Center ID' is not reachable in the universe for this query. Cannot safely enforce security policy."}}
Examples
The branch has one policy: filter_data, triggered by the tag pii, context dimension Call Center ID, permissions from group tags with key call_center_id, system_admin bypass on, unresolved: allow. Country and Employees on call_center are tagged pii.
Filter
curl -s https://app.0sql.io/projects/tpcds/branches/main/sql \
-H "Authorization: Bearer zqk_..." \
-H "Content-Type: application/json" \
-d '{
"spec": {"name": "security_test", "projections": [{"field": "country", "alias": "Country"}]},
"context": {
"email": "tank@matrix.com", "system_admin": false, "project_admin": false, "tags": [],
"groups": [{"name": "CC-TMNT", "tags": ["call_center_id:TMNT"]},
{"name": "CC-AVCL", "tags": ["call_center_id:AVCL", "cat:men"]}]
}
}' cat > tank.json <<'EOF'
{"email": "tank@matrix.com", "system_admin": false, "project_admin": false, "tags": [],
"groups": [{"name": "CC-TMNT", "tags": ["call_center_id:TMNT"]},
{"name": "CC-AVCL", "tags": ["call_center_id:AVCL", "cat:men"]}]}
EOF
zsql sql --expr 'country as Country' --context tank.json // `user` comes from your own session or JWT
const context = {
email: user.email,
system_admin: user.roles.includes("admin"),
groups: user.callCenters.map((id) => ({ name: `CC-${id}`, tags: [`call_center_id:${id}`] })),
};
const res = await fetch("https://app.0sql.io/projects/tpcds/branches/main/sql", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({ spec: { projections: [{ field: "country", alias: "Country" }] }, context }),
}); SELECT
T0."cc_country" AS "Country"
FROM
call_center T0
WHERE
LOWER(T0."cc_call_center_id") IN ('tmnt', 'avcl')
The two call_center_id tags across the caller’s groups became the allowed values; the cat:men tag has a different key and is ignored.
Mask
The same context against a mask_data policy with mask_value: "#######", projecting employees (an integer):
{"spec": {"projections": [{"field": "employees", "alias": "Employees"}]},
"context": {"email": "tank@matrix.com", "groups": [
{"name": "CC-TMNT", "tags": ["call_center_id:TMNT"]},
{"name": "CC-AVCL", "tags": ["call_center_id:AVCL", "cat:men"]}]}}
SELECT
CASE WHEN LOWER(T0."cc_call_center_id") IN ('tmnt', 'avcl') THEN T0."cc_employees" ELSE NULL END AS "Employees"
FROM
call_center T0
Every call center still appears as a row; the employee count is NULL outside the caller’s centers. On a string field the ELSE would be '#######'.
Admin bypass
The first request with "system_admin": true:
SELECT
T0."cc_country" AS "Country"
FROM
call_center T0
The policy’s bypass.system_admin is true, so it is skipped and no WHERE is added.
No context
The same request with no context key answers 400 ContextRequired.
Building the context in your application
- Build it per request from your own auth system: the session’s email, the roles that map to
system_adminandproject_admin, and the tenant, region or account memberships your policies key on as groups or tags. - API keys are not users. A query key identifies your application and what it may plan; the context identifies the person the statement is for. One key serves every end user.
- Match the policy’s resolution. A policy that reads
groupsbytagwithtag_key: call_center_idneeds groups carryingcall_center_id:<value>tags; names alone will not resolve, and an unresolved policy denies by default. - The comparison is lower-cased on both sides, so
TMNTin a tag matchestmntin the warehouse. - Policies apply inside segment CTEs, aggregation CTEs and decorator CTEs alike, because they fire per node. Use Explain to see
security_filtersandsecurity_maskson each node.
Authoring policies, trigger tags, unresolved and bypass flags are covered on Row-level security.
Next steps
- Row-level security
- Explain to see what a policy added
- Errors and corrections