Skip to main content

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.)
  • required must be an array and each required key must exist in properties

"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)

Full replacement

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:

TypeHow 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

  1. A DataTable (e.g. employees) has a column manager_id that references auth_user.id
  2. You create a mapping: employees.manager_id → attribute_key: "managed_employees" with relationship_type: "one_to_many"
  3. When fetching a user, the system queries SELECT * FROM employees WHERE manager_id = {user_id} and returns the results under attributes.managed_employees

Query Mappings

  1. You write a SQL template: SELECT t.name FROM teams t JOIN team_members tm ON ... WHERE tm.user_id = %(user_id)s
  2. You create a mapping with mapping_type: "query" and attribute_key: "my_teams"
  3. 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):

VariableTypeDescription
%(user_id)sUUIDThe user's database ID
%(user_email)sstringThe user's email address
%(username)sstringThe 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:

  1. Not empty, max 5000 characters
  2. Only allowed template variables (user_id, user_email, username)
  3. Must contain at least one user-scoping variable
  4. No semicolons outside string literals (blocks multi-statement injection)
  5. Must be a single SELECT statement (blocks INSERT, UPDATE, DELETE, DROP)
  6. Tenant security check (blocks cross-schema access, public schema, system catalogs)
  7. Dry-run with LIMIT 0 in a rolled-back savepoint (catches syntax errors, missing tables/columns)

Relationship types

For FK mappings, relationship_type is required:

Type0 matches1 match2+ matches
one_to_onenullobjecterror
one_to_many[][object][object, ...]

For query mappings, relationship_type is always auto-inferred from the result:

Rows returnedResult
0null
1single 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:

ParamDescription
app_slugFilter by app
datatable_nameFilter by table name
searchSearch 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"
}
Response shape

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"
}]
Defaults

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_id exists and is materialized
  • reference_column has a FK to auth_user in that table's schema
  • relationship_type is required (one_to_one or one_to_many)
  • (datatable, reference_column) pair doesn't already exist

Query mapping validations:

  • query_template is required
  • Passes the 7-step validation pipeline (see above)

Shared validations:

  • attribute_key is unique across tenant
  • attribute_key does not conflict with any key in UserAttributesSchema.schema.properties
  • No duplicate attribute_key within 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.

Type switching

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-..."
}
FieldRequiredDefaultNotes
query_templateYes (unless mapping_id)Ad-hoc SQL template
mapping_idNoUUID of a saved query mapping
user_idNoValue for %(user_id)s
user_emailNoValue for %(user_email)s
usernameNoValue for %(username)s
limitNo50Max rows, capped at 200
Required params

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"}
]
}
}
List vs Detail
  • GET /api/users/ (list): Returns only static user.attributes. No reference attribute resolution — for performance.
  • GET /api/users/{username}/ (detail): Merges FK + query mapping results into attributes.
  • GET /api/users/me/: Same as detail — merges everything.

Caching

Mapping definitions cache

SettingValue
StoreRedis via TenantCache (per-tenant)
Keyactive_mappings
TTL5 minutes
What's cachedList 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.

Re-activating

If the column is re-added later, you can re-activate the mapping via PATCH:

{ "is_active": true }
Query mappings

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)
Policy authoring

Always navigate to scalar leaf fields (e.g. P.attr.department_head.id), never compare the parent object directly against a resource attribute.


Troubleshooting

ProblemSolution
Mapping not resolvingCheck is_active is true and the DataTable is materialized
Multiple records error on one_to_oneChange relationship_type to one_to_many or fix the data
attribute_key conflictThe key must not exist in UserAttributesSchema.schema.properties
reference_column validation failsThe column must have a FK to auth_user in the DataTable's schema
Query mapping returns null unexpectedlyTest with the preview endpoint to see actual results
Query validation fails on saveCheck 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 changeCache auto-invalidates on CRUD; wait up to 5 min for Cerbos user cache