User Attributes
User Attributes are tenant-scoped custom fields stored on each user in auth_user.attributes (a JSONB/JSONField). They let you extend the user profile without DB migrations, and they unlock attribute-based access control (ABAC) patterns when used with Cerbos.
What are User Attributes?
User attributes are:
- Dynamic: Each tenant can define its own attributes.
- Validated: Stored values are validated against a tenant-defined JSON Schema.
- Safe by default: Unknown keys are rejected (effectively
additionalProperties: false).
Common use cases:
- Department/team membership
- Feature flags and entitlements
- Org-specific identifiers (employee number, cost center)
- Data access scopes (region, business unit, customer tier)
Core concepts & rules
JSON Schema format
- Uses JSON Schema Draft 2020-12
- The schema must be an object schema:
"type": "object" - Attribute names must be lowercase snake_case and start with a letter
- Reserved user field names are blocked (e.g.
email,username,attributes,is_active, etc.) requiredmust be an array and each required key must exist inproperties
"Allow null"
Use union types to allow missing/nullable values:
{
"type": ["string", "null"]
}
API: Schema management
User Attributes schema is managed via the tenant settings API.
Get current schema
GET /api/settings/user-attributes/
Response:
{
"status": "success",
"data": {
"schema": { },
"has_schema": false,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
}
Update schema
POST /api/settings/user-attributes/ (requires manage_site)
Schema updates replace the entire schema. Workflow: GET current schema → modify → POST full schema.
API: Writing user attributes
PUT /api/users/{username}/
{
"attributes": {
"department": "Engineering",
"employee_id": "EMP00123"
}
}
Merge behavior: updates merge into existing attributes (keys you send overwrite; other keys stay).
User Attribute Mappings
User Attribute Mappings resolve named attributes on the user at read time. There are two mapping types:
| Type | How it resolves |
|---|---|
Foreign Key (foreign_key) | Queries a DataTable column that references auth_user |
Query (query) | Executes a templatized SQL SELECT |
The data stays in the source tables — nothing is stored on the User model.
Foreign Key Mappings
- A DataTable (e.g.
employees) has a columnmanager_idthat referencesauth_user.id - You create a mapping:
employees.manager_id → attribute_key: "managed_employees"withrelationship_type: "one_to_many" - When fetching a user, the system queries
SELECT * FROM employees WHERE manager_id = {user_id}and returns the results underattributes.managed_employees
Query Mappings
- You write a SQL template:
SELECT t.name FROM teams t JOIN team_members tm ON ... WHERE tm.user_id = %(user_id)s - You create a mapping with
mapping_type: "query"andattribute_key: "my_teams" - When fetching a user, the system executes the query with the user's params and returns the results under
attributes.my_teams
Template variables
Queries can use these placeholders (psycopg2 parameterized — safe from SQL injection):
| Variable | Type | Description |
|---|---|---|
%(user_id)s | UUID | The user's database ID |
%(user_email)s | string | The user's email address |
%(username)s | string | The user's username |
At least one must be present in every query template.
Query validation (7-step pipeline)
Query templates are validated at save time:
- Not empty, max 5000 characters
- Only allowed template variables (
user_id,user_email,username) - Must contain at least one user-scoping variable
- No semicolons outside string literals (blocks multi-statement injection)
- Must be a single
SELECTstatement (blocks INSERT, UPDATE, DELETE, DROP) - Tenant security check (blocks cross-schema access, public schema, system catalogs)
- Dry-run with
LIMIT 0in a rolled-back savepoint (catches syntax errors, missing tables/columns)
Relationship types
For FK mappings, relationship_type is required:
| Type | 0 matches | 1 match | 2+ matches |
|---|---|---|---|
one_to_one | null | object | error |
one_to_many | [] | [object] | [object, ...] |
For query mappings, relationship_type is always auto-inferred from the result:
| Rows returned | Result |
|---|---|
| 0 | null |
| 1 | single object |
| 2+ | array of objects |
API: Discovery
Discover all DataTable columns that reference auth_user. Uses both PostgreSQL catalog (pg_constraint) and DataTable schema (foreignKeys) for complete coverage. System tables are excluded.
GET /api/settings/user-attributes/references/
Query parameters:
| Param | Description |
|---|---|
app_slug | Filter by app |
datatable_name | Filter by table name |
search | Search across column names |
Response (grouped by app → tables → columns):
{
"status": "success",
"message": "Found 2 app(s) with tables referencing auth_user",
"data": [
{
"app_id": 1,
"app_slug": "hr-app",
"app_name": "HR App",
"tables": [
{
"datatable_id": 42,
"datatable_name": "employees",
"physical_table_name": "hr_app_employees",
"reference_columns": ["created_by_id", "manager_id"]
}
]
}
]
}
API: Mappings CRUD
List mappings
GET /api/settings/user-attributes/mappings/
Returns only active mappings.
Query parameters: app_slug, datatable_name, attribute_key, mapping_type, search
The mapping_type filter accepts foreign_key or query.
FK mapping response:
{
"id": 1,
"uuid": "a1b2c3d4-...",
"mapping_type": "foreign_key",
"app_slug": "hr-app",
"datatable_name": "employees",
"physical_table_name": "hr_app_employees",
"reference_column": "manager_id",
"attribute_key": "managed_employees",
"relationship_type": "one_to_many",
"is_active": true,
"populate": ["department_id"],
"created_at": "2026-03-28T10:00:00Z"
}
Query mapping response:
{
"id": 2,
"uuid": "e5f6a7b8-...",
"mapping_type": "query",
"attribute_key": "my_teams",
"relationship_type": null,
"is_active": true,
"query_template": "SELECT t.id, t.name FROM teams t JOIN ... WHERE e.user_id = %(user_id)s",
"created_at": "2026-03-28T10:00:00Z"
}
FK mappings include app_slug, datatable_name, reference_column, populate. Query mappings include query_template. These fields are exclusive to their type.
Create mappings (bulk)
POST /api/settings/user-attributes/mappings/ (requires manage_site)
Accepts an array. Fails fast on first validation error — nothing is created if any item fails. Supports mixed FK and query items in the same batch.
FK mapping:
[{
"mapping_type": "foreign_key",
"datatable_id": 42,
"reference_column": "manager_id",
"attribute_key": "managed_employees",
"relationship_type": "one_to_many",
"populate": ["department_id"]
}]
Query mapping:
[{
"mapping_type": "query",
"attribute_key": "my_teams",
"query_template": "SELECT t.id, t.name FROM teams t JOIN team_members tm ON tm.team_id = t.id JOIN employees e ON e.id = tm.employee_id WHERE e.user_id = %(user_id)s"
}]
mapping_type defaults to "foreign_key" if omitted. For query mappings, relationship_type is always auto-inferred and ignored if provided.
FK mapping validations:
datatable_idexists and is materializedreference_columnhas a FK toauth_userin that table's schemarelationship_typeis required (one_to_oneorone_to_many)(datatable, reference_column)pair doesn't already exist
Query mapping validations:
query_templateis required- Passes the 7-step validation pipeline (see above)
Shared validations:
attribute_keyis unique across tenantattribute_keydoes not conflict with any key inUserAttributesSchema.schema.properties- No duplicate
attribute_keywithin the request
Update mapping
PATCH /api/settings/user-attributes/mappings/{uuid}/ (requires manage_site)
Partial update. URL uses the mapping's uuid.
Update a query template:
{
"query_template": "SELECT t.id, t.name FROM teams t WHERE t.lead_id = %(user_id)s"
}
Switch FK → query:
{
"mapping_type": "query",
"query_template": "SELECT ... WHERE user_id = %(user_id)s"
}
This clears app, datatable, reference_column, populate, and relationship_type.
Switch query → FK:
{
"mapping_type": "foreign_key",
"datatable_id": 42,
"reference_column": "manager_id",
"relationship_type": "one_to_one"
}
This clears query_template.
Switching mapping type clears all type-specific fields. This cannot be undone.
Delete mapping
DELETE /api/settings/user-attributes/mappings/{uuid}/ (requires manage_site)
API: Query Preview
Test a query template before saving, or debug an existing saved mapping.
POST /api/settings/user-attributes/query/preview/ (requires manage_site)
Request:
{
"query_template": "SELECT t.id, t.name FROM teams t JOIN ... WHERE au.username = %(username)s",
"username": "neha",
"limit": 25
}
Or run a saved mapping:
{
"mapping_id": "a1b2c3d4-...",
"user_id": "675297f9-..."
}
| Field | Required | Default | Notes |
|---|---|---|---|
query_template | Yes (unless mapping_id) | — | Ad-hoc SQL template |
mapping_id | No | — | UUID of a saved query mapping |
user_id | No | — | Value for %(user_id)s |
user_email | No | — | Value for %(user_email)s |
username | No | — | Value for %(username)s |
limit | No | 50 | Max rows, capped at 200 |
At least one of user_id, user_email, or username must be provided. The query must only use variables that are present in the payload — missing variables return a clear error.
Response:
{
"status": "success",
"message": "Query returned 2 row(s)",
"data": {
"columns": ["id", "name"],
"rows": [
{"id": "d100...", "name": "Platform Team"},
{"id": "d200...", "name": "SRE Team"}
],
"row_count": 2,
"truncated": false,
"template_params": {
"username": "neha"
}
}
}
Safety limits: 5-second query timeout, 200-row hard cap.
User API response
Resolved mappings (both FK and query) are merged into the attributes field on single-user endpoints:
{
"id": "675297f9-...",
"username": "neha",
"email": "neha@example.com",
"attributes": {
"department": "Engineering",
"managed_employees": [
{"id": 101, "name": "Alice"},
{"id": 102, "name": "Bob"}
],
"my_teams": [
{"id": "d100...", "name": "Platform Team"}
]
}
}
GET /api/users/(list): Returns only staticuser.attributes. No reference attribute resolution — for performance.GET /api/users/{username}/(detail): Merges FK + query mapping results intoattributes.GET /api/users/me/: Same as detail — merges everything.
Caching
Mapping definitions cache
| Setting | Value |
|---|---|
| Store | Redis via TenantCache (per-tenant) |
| Key | active_mappings |
| TTL | 5 minutes |
| What's cached | List of active UserAttributeMapping objects |
Invalidated on:
- Create mapping (API)
- Update mapping (API)
- Delete mapping (API)
- DataTable schema change that deactivates FK mappings
Cerbos user attributes cache
The Cerbos authorization path caches resolved user attributes (static + FK + query merged) per user for 5 minutes via @cached_user. This is invalidated when the User model saves or deletes.
Schema Change Guard
When a DataTable schema is updated and a FK column pointing to auth_user is removed, any UserAttributeMapping referencing that column is automatically set to is_active=false and the mapping cache is invalidated. This prevents runtime errors on user API responses.
If the column is re-added later, you can re-activate the mapping via PATCH:
{ "is_active": true }
Query mappings have no automatic schema change guard. If a table or column referenced in a query template is dropped, the query fails at runtime — the error is logged and the attribute returns its default value (null). Other mappings continue to resolve normally.
Cerbos integration
Resolved attributes (both static and mapped) are merged into the Cerbos principal attributes. Policies can navigate into the resolved objects:
condition:
match:
expr: P.attr.managed_employees.exists(e, e.department_id == R.attr.department_id)
Always navigate to scalar leaf fields (e.g. P.attr.department_head.id), never compare the parent object directly against a resource attribute.
Troubleshooting
| Problem | Solution |
|---|---|
| Mapping not resolving | Check is_active is true and the DataTable is materialized |
Multiple records error on one_to_one | Change relationship_type to one_to_many or fix the data |
attribute_key conflict | The key must not exist in UserAttributesSchema.schema.properties |
reference_column validation fails | The column must have a FK to auth_user in the DataTable's schema |
Query mapping returns null unexpectedly | Test with the preview endpoint to see actual results |
| Query validation fails on save | Check the error — common causes: missing user variable, non-SELECT statement, nonexistent table |
| Preview says "parameter not provided" | Pass all variables that the query uses (user_id, user_email, or username) |
| Stale data after mapping change | Cache auto-invalidates on CRUD; wait up to 5 min for Cerbos user cache |