-
Notifications
You must be signed in to change notification settings - Fork 42
Description
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_typeandscope_idare NULL. These apply regardless of which org the user is in. - Org-scoped user preferences (like
unit_area):scope_type=app/organizationandscope_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:
- Existing rows unchanged: New columns (
scope_type,scope_id) default to NULL - NULL = global preference: Existing user preferences (like
newsletter) have NULL scope columns, which correctly represents "global user preference, not scoped to any org" - Unique constraint preserved: Using
NULLS NOT DISTINCTensures:- 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)
- 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.yamlExample 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: degreesTrait 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.yaml2. 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 DefaultTraitsstill work; custom traits are additive- Existing RPC behavior is preserved when
org_idis not provided
Open Questions
- Should we support other scope types beyond
app/organizationin the future (e.g.,app/project)? - Should there be an admin API to manage preferences for other users?