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/

By default returns only active mappings. Use the is_active parameter to control which mappings are returned.

Query parameters: app_slug, datatable_name, attribute_key, mapping_type, search, include_inactive

ParameterTypeDescription
include_inactiveboolWhen true, returns all mappings (active + inactive). Defaults to active only when omitted.

Examples:

  • GET /api/settings/user-attributes/mappings/ — active only (default, backward-compatible)
  • GET /api/settings/user-attributes/mappings/?include_inactive=true — all mappings (active + inactive)

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_idNo—UUID of a saved query mapping
user_idNo—Value for %(user_id)s
user_emailNo—Value for %(user_email)s
usernameNo—Value 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