Product documentation
Define a typed attribute schema for users and organizations
Tenant-defined, typed attribute definitions on product-api that type, validate, and render the user and organization metadata bags as opinionated forms instead of free JSON — plus a reusable validation service later stories enforce at the write path.
Attribute schema
Thoryn's metadata bags (a user's app_metadata / user_metadata, an organization's metadata)
are free JSON by default — an admin has no guidance on what a key means, who can see it, or where
it flows. The attribute schema is a definition layer over those bags: tenant-defined, typed
attribute definitions that type, validate, and render them as opinionated forms (checkbox, dropdown,
date picker) instead of raw JSON. It does not move where values live — it only describes them.
This page covers the backend: the schema model, the CRUD API (general and per-organization), the effective-schema resolution, the reusable validation service, and the curated templates an admin applies to bootstrap a schema. Console rendering is a later story.
Scope: per-environment, two entity kinds
- Per environment. Each environment (production plus each sandbox) of a workspace owns its own
schema, keyed by
environment_id. A tenant can trial attributes in a sandbox before promoting them to production. - Two entity kinds. One model spans
userattributes (fields on a person) andorganizationattributes (fields on the org entity). Auserattribute carries a bag —user(end-user editable, never a token claim) orapp(admin-only, token-eligible). Anorganizationattribute is always admin/trusted and carries no bag.
An attribute definition
| Field | Meaning |
|---|---|
entityKind | user or organization. |
key, label, help | Identity and human framing. Immutable key. |
type | text · number · boolean · enum · multi_select · date · email · url · phone · address · json — drives the form control. |
bag | user or app (user kind only). |
required, defaultValue | Presence and fallback. |
allowedValues | JSON array of permitted values — required for enum / multi_select. |
pattern, minValue, maxValue | Constraints (pattern for text-like types; min/max for number). |
userEditable, tokenClaim, sensitive | Flags. tokenClaim is only valid for an app-bag user attribute or an organization attribute. |
oidcClaim | Optional OIDC-standard claim mapping (user kind only). |
Definition validation rejects an incoherent definition with 400 (RFC 9457 problem-details): an
unknown type, an organization attribute that carries a bag, tokenClaim on a user bag,
enum / multi_select without allowedValues, oidcClaim on an organization attribute, an
uncompilable pattern, or min > max.
Endpoints
All endpoints are under /api/v1/attribute-schema (forwarded unchanged by the gateway). Reads
require the tenant:attribute-schema.read scope; mutations require tenant:attribute-schema.write.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/attribute-schema?entityKind=user|organization | List the caller-environment's definitions. |
| POST | /api/v1/attribute-schema | Create a definition (201; 409 on a duplicate key). |
| GET | /api/v1/attribute-schema/{id} | Fetch one definition. |
| PATCH | /api/v1/attribute-schema/{id} | Update mutable fields (entityKind / key are immutable). |
| DELETE | /api/v1/attribute-schema/{id} | Delete a definition (204). |
A definition is unique on (environment_id, entity_kind, key, organization_id). Isolation is by the
caller's resolved environment (which is tenant-owned): a cross-tenant or cross-environment id returns
404, never 403 — no existence leak.
Per-organization user attributes (S2)
An organization may extend the general user schema with user attributes that apply only to its
own members. These live under the organization as a sub-resource; org-scoped definitions are always
user (organizations are env-wide only). Per-org attribute values bind onto the membership's
custom_attributes — but that write-path enforcement is a later story.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/organizations/{orgId}/attribute-schema | List the org's own user attribute definitions. |
| POST | /api/v1/organizations/{orgId}/attribute-schema | Create one (201; 409 duplicate; 409 attribute_key_reserved_by_general). |
| GET | /api/v1/organizations/{orgId}/attribute-schema/{id} | Fetch one. |
| PATCH | /api/v1/organizations/{orgId}/attribute-schema/{id} | Update mutable fields. |
| DELETE | /api/v1/organizations/{orgId}/attribute-schema/{id} | Delete (204). |
The orgId must belong to the caller's tenant and environment — a cross-tenant / cross-environment
org returns 404, never a leak. These endpoints use the same tenant:attribute-schema.read /
.write scopes (org-scoping is a dimension of the schema, not a new resource).
Extend-only. An org may ADD a key, never re-type or override a general one. A create whose key
already exists as a general (organization_id IS NULL) definition for the same environment is
rejected with 409 attribute_key_reserved_by_general. There are no precedence rules — the effective
schema is a straight union with general keys immutable at the org level.
Effective user schema
The effective user schema a member sees is general ∪ per-org(each org the member belongs to).
AttributeSchemaService.effectiveUserSchema(environmentId, orgIds) resolves the union given a set of
org ids; AttributeSchemaService.validateUser(tenantId, environmentId, subject, values) resolves the
subject's active org memberships (via the existing membership read) and validates the values
against that union. An unknown key still passes through unchanged (the schema complements the bag);
a value that is valid under any effective definition (general or per-org) is accepted.
Templates (S5)
Curated, built-in templates let an admin bootstrap a schema instead of defining every attribute by hand. A template is a named, typed list of attribute definitions for one entity kind; it is opinionated reference content shipped with the product (code constants, not tenant data — there is no template table and no migration). Applying a template creates its definitions through the ordinary create path above, so every definition-rule and the extend-only guard apply — a template can never bypass validation or write rows directly.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/attribute-schema/templates?entityKind=user|organization | List the built-in templates for a kind, with the definitions each contains. |
| POST | /api/v1/attribute-schema/apply-template | Apply a template to the caller's environment (200). |
The built-in set (v1):
| Template id | Kind | Fields |
|---|---|---|
user-b2b-saas | user | department (token claim), job_title, employee_id (sensitive) — all app bag. |
user-workforce | user | manager_email, office_location, employment_type (enum, token claim), start_date — all app bag. |
org-company-profile | organization | industry, region, plan (enum, token claim), company_size (enums). |
org-billing | organization | billing_email, tax_id (sensitive), payment_terms (enum). |
Apply semantics. POST /apply-template takes { "templateId": "...", "organizationId": "..."? }:
- Each template definition is created via the S1 path. A key that already exists for this scope is
skipped, not an error — so a second apply of the same template is a no-op. The response is
{ "created": [ …definitions… ], "skipped": [ …keys… ] }. - With no
organizationId, definitions are created general (env-wide) for the template's kind. - With an
organizationId, ausertemplate is applied org-scoped for that org (the effective-schema extend-only model): a template key that collides with a general definition fails the whole apply with409 attribute_key_reserved_by_general(fail-fast — nothing is created). Applying anorganizationtemplate org-scoped is rejected400 org_scoped_requires_user. A cross-tenant / cross-environmentorganizationIdreturns 404. - An unknown
templateIdreturns404 template_not_found.
Templates use the same tenant:attribute-schema.read (list) / .write (apply) scopes — they are a
convenience over the definition surface, not a new resource.
The validation service (reusable primitive)
AttributeSchemaService.validate(entityKind, environmentId, values) is the reusable primitive later
stories consume. Against the schema for that environment and kind it:
- types/coerces each value to its canonical JSON form (string / number / boolean / enum / date / …),
- enforces
required,allowedValues,pattern, and min/max, - applies a definition's
defaultValuewhen a value is absent, - passes un-schema'd keys through unchanged — the schema complements the bag, it does not replace it,
and returns either the coerced value map or a list of per-field errors. The coercion itself is pure
(no HTTP). For a user with org memberships, validateUser(...) validates against the effective
general ∪ per-org schema (see above); S3 calls these at the metadata write path.
Configuration
# api-gateway — the route is forwarded unchanged (no StripPrefix).
spring:
cloud:
gateway:
routes:
- id: product-api-attribute-schema
uri: ${gateway.product-api.url}
predicates:
- Path=/api/v1/attribute-schema/**Troubleshooting
| Symptom | Cause / fix |
|---|---|
403 Forbidden on any call | The token is missing tenant:attribute-schema.read / .write. |
400 allowed_values_required | An enum / multi_select definition needs a non-empty allowedValues JSON array. |
400 bag_not_allowed_for_organization | An organization attribute must not carry a bag. |
409 attribute_exists | A definition with that key already exists for this environment/organization and entity kind. |
409 attribute_key_reserved_by_general | An org-scoped create used a key that already exists as a general definition — orgs extend, never override. |
404 on a known id | The id belongs to a different environment/tenant/org — this is the deliberate no-existence-leak behaviour. |
Security notes
- Definitions are per-environment and tenant-isolated by the environment claim; cross-tenant reads return 404.
sensitivemarks a PII attribute;tokenClaimgates whether an attribute may reach a token (wired in a later story) and is only permitted onapp-bag user attributes or organization attributes.