Skip to content

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 user attributes (fields on a person) and organization attributes (fields on the org entity). A user attribute carries a baguser (end-user editable, never a token claim) or app (admin-only, token-eligible). An organization attribute is always admin/trusted and carries no bag.

An attribute definition

FieldMeaning
entityKinduser or organization.
key, label, helpIdentity and human framing. Immutable key.
typetext · number · boolean · enum · multi_select · date · email · url · phone · address · json — drives the form control.
baguser or app (user kind only).
required, defaultValuePresence and fallback.
allowedValuesJSON array of permitted values — required for enum / multi_select.
pattern, minValue, maxValueConstraints (pattern for text-like types; min/max for number).
userEditable, tokenClaim, sensitiveFlags. tokenClaim is only valid for an app-bag user attribute or an organization attribute.
oidcClaimOptional 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.

MethodPathDescription
GET/api/v1/attribute-schema?entityKind=user|organizationList the caller-environment's definitions.
POST/api/v1/attribute-schemaCreate 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.

MethodPathDescription
GET/api/v1/organizations/{orgId}/attribute-schemaList the org's own user attribute definitions.
POST/api/v1/organizations/{orgId}/attribute-schemaCreate 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.

MethodPathDescription
GET/api/v1/attribute-schema/templates?entityKind=user|organizationList the built-in templates for a kind, with the definitions each contains.
POST/api/v1/attribute-schema/apply-templateApply a template to the caller's environment (200).

The built-in set (v1):

Template idKindFields
user-b2b-saasuserdepartment (token claim), job_title, employee_id (sensitive) — all app bag.
user-workforceusermanager_email, office_location, employment_type (enum, token claim), start_date — all app bag.
org-company-profileorganizationindustry, region, plan (enum, token claim), company_size (enums).
org-billingorganizationbilling_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, a user template 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 with 409 attribute_key_reserved_by_general (fail-fast — nothing is created). Applying an organization template org-scoped is rejected 400 org_scoped_requires_user. A cross-tenant / cross-environment organizationId returns 404.
  • An unknown templateId returns 404 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 defaultValue when 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

SymptomCause / fix
403 Forbidden on any callThe token is missing tenant:attribute-schema.read / .write.
400 allowed_values_requiredAn enum / multi_select definition needs a non-empty allowedValues JSON array.
400 bag_not_allowed_for_organizationAn organization attribute must not carry a bag.
409 attribute_existsA definition with that key already exists for this environment/organization and entity kind.
409 attribute_key_reserved_by_generalAn org-scoped create used a key that already exists as a general definition — orgs extend, never override.
404 on a known idThe 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.
  • sensitive marks a PII attribute; tokenClaim gates whether an attribute may reach a token (wired in a later story) and is only permitted on app-bag user attributes or organization attributes.