Skip to content

RFC: User-Scoped Organization Preferences with Configurable Traits #1347

@whoAbhishekSah

Description

@whoAbhishekSah

Summary

Add support for user preferences that can be scoped to an organization context, with trait definitions loaded from configuration files. This allows each Frontier deployment to define custom preference traits without modifying the core codebase.

Motivation

Currently, Frontier supports preferences at three levels:

  • Platform: System-wide settings (resource_type=app/platform)
  • Organization: Org-level settings shared by all members (resource_type=app/organization)
  • User: User-level settings that are global (resource_type=app/user)

However, there's no way to store user preferences that vary per organization.

Use case: A user belongs to multiple organizations and wants different settings in each. For example, a geospatial platform user might prefer metric units (sq_km) when working with one client org but imperial units (acres) with another.

Additionally, the current trait definitions are hardcoded in core/preference/preference.go. Deployments that need custom preference types must fork the codebase.

Proposal

1. Database Schema Change

Add scope_type and scope_id columns to the preferences table to support org-scoped user preferences.

Design Principle

The user is always the resource (what the preference belongs to). The organization is the optional scope/context (where the preference applies).

This design allows fetching all preferences for a user with a single query: WHERE resource_id = <user_id>

Data Model

Scenario resource_type resource_id scope_type scope_id name
Platform pref app/platform <nil UUID> NULL NULL disable_orgs_on_create
Org pref (shared) app/organization org-123 NULL NULL social_login
User pref (global) app/user user-456 NULL NULL newsletter
User pref (in org-123) app/user user-456 app/organization org-123 unit_area
User pref (in org-789) app/user user-456 app/organization org-789 unit_area

Key points:

  • Global user preferences (like newsletter): scope_type and scope_id are NULL. These apply regardless of which org the user is in.
  • Org-scoped user preferences (like unit_area): scope_type=app/organization and scope_id=<org_id>. These vary per organization.
  • Single query for all user prefs: SELECT * FROM preferences WHERE resource_type = 'app/user' AND resource_id = <user_id>

Migration SQL

-- Add scope columns for org-scoped user preferences
-- scope_type references namespaces table (like resource_type)
-- scope_id is text (like resource_id) to support different ID formats
ALTER TABLE preferences ADD COLUMN scope_type text REFERENCES namespaces (name) ON DELETE CASCADE;
ALTER TABLE preferences ADD COLUMN scope_id text;

-- Update unique constraint to include scope columns
ALTER TABLE preferences
    DROP CONSTRAINT resource_type_name_unique;

ALTER TABLE preferences
    ADD CONSTRAINT preferences_resource_scope_name_unique
    UNIQUE NULLS NOT DISTINCT (resource_type, resource_id, scope_type, scope_id, name);

Backward Compatibility

The migration is fully backward compatible with existing data:

  1. Existing rows unchanged: New columns (scope_type, scope_id) default to NULL
  2. NULL = global preference: Existing user preferences (like newsletter) have NULL scope columns, which correctly represents "global user preference, not scoped to any org"
  3. Unique constraint preserved: Using NULLS NOT DISTINCT ensures:
    • A user can have one global preference per name (NULL, NULL)
    • A user can have one preference per name per org (app/organization, org-id)
  4. No new index needed: The existing index on (resource_type, resource_id) is sufficient since queries always filter by user first, then by scope

Example - existing row after migration:

resource_type  | app/user
resource_id    | 4cc86dc7-...  (user_id)
scope_type     | NULL          ← global preference
scope_id       | NULL          ← not scoped to any org
name           | newsletter
value          | true

2. Extend Existing RPCs

Extend PreferenceRequestBody to include optional scope_type and scope_id fields. This allows each preference to specify its own context, and keeps the design generic (not hardcoded to orgs).

Proto Changes

message PreferenceRequestBody {
  string name = 1;
  string value = 2;
  string scope_type = 3;  // NEW: optional, e.g., "app/organization"
  string scope_id = 4;    // NEW: optional, e.g., org-123
}

message ListCurrentUserPreferencesRequest {
  string scope_type = 1;  // NEW: optional filter
  string scope_id = 2;    // NEW: optional filter
}

CreateCurrentUserPreferencesRequest remains unchanged - it already has repeated PreferenceRequestBody bodies.

Behavior

Create:

scope_type scope_id Result
Empty Empty Creates global user preference
app/organization org-123 Creates org-scoped user preference

List:

scope_type scope_id Result
Empty Empty Returns all user preferences from DB (global + all org-scoped)
app/organization org-123 Returns complete preference set: DB values + trait defaults for unset preferences

Default values: When listing with a scope context, the response always includes all traits with their values:

  • If set in DB (org-scoped) → return DB value
  • If set in DB (global) → return DB value
  • If not set in DB → return default from traits config

This ensures the UI can show a complete list of configurable preferences for any user, even if they haven't set any yet. Users see all available options with current values (or defaults) and can modify as needed.

Benefits:

  • Each preference body specifies its own context
  • Can mix global and org-scoped preferences in a single request
  • Generic design supports future scope types (not just orgs)
  • Backward compatible - existing requests without scope fields create global preferences

Validation:

Condition ConnectRPC Error HTTP Status
scope_type is not app/organization (unsupported) InvalidArgument 400 Bad Request
scope_id provided without scope_type InvalidArgument 400 Bad Request
scope_type not in namespaces table InvalidArgument 400 Bad Request
User is not a member of the specified org PermissionDenied 403 Forbidden

3. Configurable Traits

Allow custom traits to be loaded from a YAML configuration file at startup, merged with DefaultTraits.

Config option:

app:
  custom_traits_path: /etc/frontier/custom-traits.yaml

Example custom traits file:

traits:
  - resource_type: app/user
    name: unit_area
    title: Area Unit
    description: Preferred unit for area measurements
    heading: Units
    input: select
    input_hints: sq_km,sq_mi,hectare,acre,sq_m
    default: sq_km

  - resource_type: app/user
    name: unit_distance
    title: Distance Unit
    description: Preferred unit for distance measurements
    heading: Units
    input: select
    input_hints: km,mi,m,ft
    default: km

  - resource_type: app/user
    name: unit_angle
    title: Angle Unit
    description: Preferred unit for angle measurements
    heading: Units
    input: select
    input_hints: degrees,radians
    default: degrees

Trait loading at startup:

customTraits, err := preference.LoadTraitsFromFile(cfg.App.CustomTraitsPath)
if err != nil {
    return err
}
preferenceService := preference.NewService(repo, customTraits)

4. New Validator: SelectValidator

Add validation for select input type against input_hints:

type SelectValidator struct {
    allowedValues []string
}

func NewSelectValidator(inputHints string) *SelectValidator {
    values := strings.Split(inputHints, ",")
    for i := range values {
        values[i] = strings.TrimSpace(values[i])
    }
    return &SelectValidator{allowedValues: values}
}

func (v *SelectValidator) Validate(value string) bool {
    return slices.Contains(v.allowedValues, value)
}

5. Default Value Strategy

Preferences not explicitly set by the user return the trait's default value. No pre-population in the database.

func (s *Service) LoadUserPreferences(ctx context.Context, userID, orgID string) (map[string]string, error) {
    // Build filter
    filter := Filter{UserID: userID}
    if orgID != "" {
        filter.ScopeType = schema.OrganizationNamespace
        filter.ScopeID = orgID
    }

    // Fetch from DB
    preferences, err := s.List(ctx, filter)
    if err != nil {
        return nil, err
    }

    prefs := make(map[string]string)
    for _, pref := range preferences {
        prefs[pref.Name] = pref.Value
    }

    // Apply trait defaults for unset preferences
    for _, t := range s.traits {
        if t.ResourceType == schema.UserPrincipal && prefs[t.Name] == "" {
            prefs[t.Name] = t.Default
        }
    }
    return prefs, nil
}

Example Usage

1. Deploy Frontier with custom traits:

# frontier-config.yaml
app:
  custom_traits_path: /etc/frontier/custom-traits.yaml

2. User sets a global preference (no scope context):

curl -X POST https://frontier.example.com/raystack.frontier.v1beta1.FrontierService/CreateCurrentUserPreferences \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "bodies": [
      {"name": "newsletter", "value": "true"}
    ]
  }'

3. User sets org-scoped preferences:

curl -X POST https://frontier.example.com/raystack.frontier.v1beta1.FrontierService/CreateCurrentUserPreferences \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "bodies": [
      {
        "name": "unit_area",
        "value": "acre",
        "scope_type": "app/organization",
        "scope_id": "org-123"
      },
      {
        "name": "unit_distance",
        "value": "mi",
        "scope_type": "app/organization",
        "scope_id": "org-123"
      }
    ]
  }'

4. User sets mixed global and org-scoped preferences in one request:

curl -X POST https://frontier.example.com/raystack.frontier.v1beta1.FrontierService/CreateCurrentUserPreferences \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "bodies": [
      {"name": "newsletter", "value": "true"},
      {
        "name": "unit_area",
        "value": "sq_mi",
        "scope_type": "app/organization",
        "scope_id": "org-123"
      }
    ]
  }'

5. Client fetches preferences for an org context (new user, nothing set yet):

curl -X POST https://frontier.example.com/raystack.frontier.v1beta1.FrontierService/ListCurrentUserPreferences \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "scope_type": "app/organization",
    "scope_id": "org-123"
  }'

# Response includes ALL traits with defaults (user hasn't set anything):
{
  "preferences": [
    {"name": "unit_area", "value": "sq_km"},       // default from traits config
    {"name": "unit_distance", "value": "km"},      // default from traits config
    {"name": "unit_angle", "value": "degrees"},    // default from traits config
    {"name": "newsletter", "value": "false"}       // default from traits config (global trait)
  ]
}

6. Client fetches preferences for an org context (user has set some values):

curl -X POST https://frontier.example.com/raystack.frontier.v1beta1.FrontierService/ListCurrentUserPreferences \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "scope_type": "app/organization",
    "scope_id": "org-123"
  }'

# Response merges DB values with defaults (both org defaults and global defaults):
{
  "preferences": [
    {"name": "unit_area", "value": "acre"},        // from DB (org-scoped)
    {"name": "unit_distance", "value": "km"},      // default (not set in DB)
    {"name": "unit_angle", "value": "degrees"},    // default (not set in DB)
    {"name": "newsletter", "value": "true"}        // from DB (global)
  ]
}

7. Client fetches all preferences (no filter - returns only DB values, no defaults):

curl -X POST https://frontier.example.com/raystack.frontier.v1beta1.FrontierService/ListCurrentUserPreferences \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{}'

# Response includes only explicitly set preferences from DB (no defaults):
{
  "preferences": [
    {"name": "newsletter", "value": "true"},
    {"name": "unit_area", "value": "acre", "scope_type": "app/organization", "scope_id": "org-123"},
    {"name": "unit_area", "value": "sq_km", "scope_type": "app/organization", "scope_id": "org-456"}
  ]
}

Backward Compatibility

  • Existing preferences (platform, org, user, project) continue to work unchanged
  • The new columns (scope_type, scope_id) are nullable; existing rows remain valid with NULL values
  • DefaultTraits still work; custom traits are additive
  • Existing RPC behavior is preserved when org_id is not provided

Open Questions

  1. Should we support other scope types beyond app/organization in the future (e.g., app/project)?
  2. Should there be an admin API to manage preferences for other users?

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions