Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ TAG := $(shell git rev-list --tags --max-count=1)
VERSION := $(shell git describe --tags ${TAG})
.PHONY: build check fmt lint test test-race vet test-cover-html help install proto admin-app compose-up-dev
.DEFAULT_GOAL := build
PROTON_COMMIT := "b1687af73f994fa9612a023c850aa97c35735af8"
PROTON_COMMIT := "e806c3a6f5ae280a23d02480becddc2818661715"

admin-app:
@echo " > generating admin build"
Expand Down
15 changes: 15 additions & 0 deletions core/preference/preference.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ var (
ErrInvalidFilter = fmt.Errorf("invalid preference filter set")
ErrTraitNotFound = fmt.Errorf("preference trait not found, preferences can only be created with valid trait")
ErrInvalidValue = fmt.Errorf("invalid value for preference")
ErrInvalidScope = fmt.Errorf("invalid scope: trait does not support scoping or scope_type/scope_id mismatch")
)

type TraitInput string
Expand Down Expand Up @@ -70,6 +71,20 @@ type Trait struct {
InputHints string `json:"input_hints" yaml:"input_hints"`
// Default value to be used for the trait if the preference is not set (say "true" for a TraitInput of type Checkbox)
Default string `json:"default" yaml:"default"`
// AllowedScopes specifies which scope types are valid for this trait
// e.g., ["app/organization"] allows org-scoped preferences
// Empty means the trait is global only (no scoping allowed)
AllowedScopes []string `json:"allowed_scopes" yaml:"allowed_scopes"`
}

// IsValidScope checks if the given scope type is allowed for this trait
func (t Trait) IsValidScope(scopeType string) bool {
for _, allowed := range t.AllowedScopes {
if allowed == scopeType {
return true
}
}
return false
}

func (t Trait) GetValidator() PreferenceValidator {
Expand Down
84 changes: 84 additions & 0 deletions core/preference/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,25 @@ func (s *Service) Create(ctx context.Context, preference Preference) (Preference
if matchedTrait == nil {
return Preference{}, ErrTraitNotFound
}

// validate scope
hasScope := preference.ScopeType != "" || preference.ScopeID != ""
traitRequiresScope := len(matchedTrait.AllowedScopes) > 0

if traitRequiresScope {
// Trait requires scope - must provide both scope_type and scope_id
if preference.ScopeType == "" || preference.ScopeID == "" {
return Preference{}, ErrInvalidScope
}
// Scope type must be in the trait's allowed scopes
if !matchedTrait.IsValidScope(preference.ScopeType) {
return Preference{}, ErrInvalidScope
}
} else if hasScope {
// Trait is global-only but scope was provided
return Preference{}, ErrInvalidScope
}

validator := matchedTrait.GetValidator()
if !validator.Validate(preference.Value) {
return Preference{}, ErrInvalidValue
Expand All @@ -68,6 +87,71 @@ func (s *Service) Describe(ctx context.Context) []Trait {
return s.traits
}

// LoadUserPreferences loads user preferences and merges them with trait defaults.
// Always returns a complete preference set with priority:
// 1. Org-scoped DB values (if scope provided, highest priority)
// 2. Global DB values (fallback)
// 3. Trait defaults (for anything not in DB)
func (s *Service) LoadUserPreferences(ctx context.Context, filter Filter) ([]Preference, error) {
hasScope := filter.ScopeType != "" && filter.ScopeID != ""

// Fetch global preferences
globalPrefs, err := s.repo.List(ctx, Filter{
UserID: filter.UserID,
// No scope = global preferences (repo will use ScopeTypeGlobal/ScopeIDGlobal)
})
if err != nil {
return nil, err
}

// Build preference map starting with global preferences
prefMap := make(map[string]Preference)
for _, pref := range globalPrefs {
prefMap[pref.Name] = pref
}

// If scope provided, fetch scoped preferences and override globals
if hasScope {
scopedPrefs, err := s.repo.List(ctx, filter)
if err != nil {
return nil, err
}
for _, pref := range scopedPrefs {
prefMap[pref.Name] = pref
}
}

// Build result with trait ordering, filling in defaults for missing preferences
var result []Preference
for _, trait := range s.traits {
if trait.ResourceType != schema.UserPrincipal {
continue
}
if pref, exists := prefMap[trait.Name]; exists {
result = append(result, pref)
delete(prefMap, trait.Name) // mark as processed
} else if trait.Default != "" {
// Add default preference for unset trait
result = append(result, Preference{
Name: trait.Name,
Value: trait.Default,
ResourceID: filter.UserID,
ResourceType: schema.UserPrincipal,
ScopeType: filter.ScopeType,
ScopeID: filter.ScopeID,
})
}
}

// Add any remaining preferences that don't have a matching trait
// (shouldn't happen normally but handles edge cases)
for _, pref := range prefMap {
result = append(result, pref)
}

return result, nil
}

// LoadPlatformPreferences loads platform preferences from the database
// and returns a map of preference name to value
// if a preference is not set in the database, the default value is used from DefaultTraits
Expand Down
232 changes: 232 additions & 0 deletions core/preference/service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
package preference

import (
"context"
"testing"

"github.com/google/uuid"
"github.com/raystack/frontier/internal/bootstrap/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)

type MockRepository struct {
mock.Mock
}

func (m *MockRepository) Set(ctx context.Context, preference Preference) (Preference, error) {
args := m.Called(ctx, preference)
return args.Get(0).(Preference), args.Error(1)
}

func (m *MockRepository) Get(ctx context.Context, id uuid.UUID) (Preference, error) {
args := m.Called(ctx, id)
return args.Get(0).(Preference), args.Error(1)
}

func (m *MockRepository) List(ctx context.Context, filter Filter) ([]Preference, error) {
args := m.Called(ctx, filter)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]Preference), args.Error(1)
}

func TestLoadUserPreferences(t *testing.T) {
ctx := context.Background()
userID := "user-123"
orgID := "org-456"

// Define test traits with defaults
testTraits := []Trait{
{
ResourceType: schema.UserPrincipal,
Name: "theme",
Default: "light",
},
{
ResourceType: schema.UserPrincipal,
Name: "language",
Default: "en",
},
{
ResourceType: schema.UserPrincipal,
Name: "notifications",
Default: "", // No default
},
{
ResourceType: schema.OrganizationNamespace, // Should be ignored for user prefs
Name: "org_setting",
Default: "default",
},
}

t.Run("without scope returns global DB preferences plus defaults", func(t *testing.T) {
mockRepo := new(MockRepository)
svc := NewService(mockRepo, testTraits)

filter := Filter{UserID: userID}
dbPrefs := []Preference{
{ID: "1", Name: "theme", Value: "dark", ResourceType: schema.UserPrincipal, ResourceID: userID},
}
mockRepo.On("List", ctx, filter).Return(dbPrefs, nil)

result, err := svc.LoadUserPreferences(ctx, filter)

assert.NoError(t, err)
// Should have "theme" (from DB) and "language" (default)
assert.Len(t, result, 2)

resultMap := make(map[string]Preference)
for _, p := range result {
resultMap[p.Name] = p
}

assert.Equal(t, "dark", resultMap["theme"].Value)
assert.Equal(t, "1", resultMap["theme"].ID)
assert.Equal(t, "en", resultMap["language"].Value) // default
assert.Equal(t, "", resultMap["language"].ID) // no ID for default
mockRepo.AssertExpectations(t)
})

t.Run("with scope returns complete preference set with scoped, global, and defaults", func(t *testing.T) {
mockRepo := new(MockRepository)
svc := NewService(mockRepo, testTraits)

scopedFilter := Filter{
UserID: userID,
ScopeType: schema.OrganizationNamespace,
ScopeID: orgID,
}
globalFilter := Filter{
UserID: userID,
}

// Scoped: "theme" is set for this org
scopedPrefs := []Preference{
{ID: "1", Name: "theme", Value: "dark", ResourceType: schema.UserPrincipal, ResourceID: userID, ScopeType: schema.OrganizationNamespace, ScopeID: orgID},
}
// Global: "language" is set globally
globalPrefs := []Preference{
{ID: "2", Name: "language", Value: "fr", ResourceType: schema.UserPrincipal, ResourceID: userID},
}

mockRepo.On("List", ctx, scopedFilter).Return(scopedPrefs, nil)
mockRepo.On("List", ctx, globalFilter).Return(globalPrefs, nil)

result, err := svc.LoadUserPreferences(ctx, scopedFilter)

assert.NoError(t, err)
// Should have "theme" (scoped DB), "language" (global DB)
// "notifications" has no default so should not be included
assert.Len(t, result, 2)

// Build a map for easier assertions
resultMap := make(map[string]Preference)
for _, p := range result {
resultMap[p.Name] = p
}

// theme should be from scoped DB (dark)
assert.Equal(t, "dark", resultMap["theme"].Value)
assert.Equal(t, "1", resultMap["theme"].ID)

// language should be from global DB (fr)
assert.Equal(t, "fr", resultMap["language"].Value)
assert.Equal(t, "2", resultMap["language"].ID)

mockRepo.AssertExpectations(t)
})

t.Run("scoped preference takes priority over global", func(t *testing.T) {
mockRepo := new(MockRepository)
svc := NewService(mockRepo, testTraits)

scopedFilter := Filter{
UserID: userID,
ScopeType: schema.OrganizationNamespace,
ScopeID: orgID,
}
globalFilter := Filter{
UserID: userID,
}

// Both scoped and global have "theme"
scopedPrefs := []Preference{
{ID: "1", Name: "theme", Value: "dark", ResourceType: schema.UserPrincipal, ResourceID: userID, ScopeType: schema.OrganizationNamespace, ScopeID: orgID},
}
globalPrefs := []Preference{
{ID: "2", Name: "theme", Value: "light", ResourceType: schema.UserPrincipal, ResourceID: userID},
}

mockRepo.On("List", ctx, scopedFilter).Return(scopedPrefs, nil)
mockRepo.On("List", ctx, globalFilter).Return(globalPrefs, nil)

result, err := svc.LoadUserPreferences(ctx, scopedFilter)

assert.NoError(t, err)

resultMap := make(map[string]Preference)
for _, p := range result {
resultMap[p.Name] = p
}

// Scoped value should win
assert.Equal(t, "dark", resultMap["theme"].Value)
assert.Equal(t, "1", resultMap["theme"].ID)

mockRepo.AssertExpectations(t)
})

t.Run("with scope but all prefs set returns no defaults", func(t *testing.T) {
mockRepo := new(MockRepository)
svc := NewService(mockRepo, testTraits)

scopedFilter := Filter{
UserID: userID,
ScopeType: schema.OrganizationNamespace,
ScopeID: orgID,
}
globalFilter := Filter{
UserID: userID,
}

// All traits with defaults are set in scoped DB
scopedPrefs := []Preference{
{ID: "1", Name: "theme", Value: "dark", ResourceType: schema.UserPrincipal, ResourceID: userID},
{ID: "2", Name: "language", Value: "de", ResourceType: schema.UserPrincipal, ResourceID: userID},
}
mockRepo.On("List", ctx, scopedFilter).Return(scopedPrefs, nil)
mockRepo.On("List", ctx, globalFilter).Return([]Preference{}, nil)

result, err := svc.LoadUserPreferences(ctx, scopedFilter)

assert.NoError(t, err)
assert.Len(t, result, 2)

resultMap := make(map[string]Preference)
for _, p := range result {
resultMap[p.Name] = p
}

// Both should be from DB
assert.Equal(t, "dark", resultMap["theme"].Value)
assert.Equal(t, "de", resultMap["language"].Value)
mockRepo.AssertExpectations(t)
})

t.Run("repository error is propagated", func(t *testing.T) {
mockRepo := new(MockRepository)
svc := NewService(mockRepo, testTraits)

filter := Filter{UserID: userID}
mockRepo.On("List", ctx, filter).Return(nil, ErrInvalidFilter)

result, err := svc.LoadUserPreferences(ctx, filter)

assert.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, ErrInvalidFilter, err)
mockRepo.AssertExpectations(t)
})
}
1 change: 1 addition & 0 deletions internal/api/v1beta1connect/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ var (
ErrInvalidPreferenceFilter = errors.New("invalid preference filter set")
ErrTraitNotFound = errors.New("preference trait not found, preferences can only be created with valid trait")
ErrInvalidPreferenceValue = errors.New("invalid value for preference")
ErrInvalidPreferenceScope = errors.New("invalid scope: trait does not support scoping or scope_type/scope_id must be provided together")
ErrMetaschemaNotFound = errors.New("metaschema doesn't exist")
ErrSessionNotFound = errors.New("session doesn't exist")
ErrInvalidSessionID = errors.New("invalid session_id format: must be a valid UUID")
Expand Down
1 change: 1 addition & 0 deletions internal/api/v1beta1connect/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ type PreferenceService interface {
Describe(ctx context.Context) []preference.Trait
List(ctx context.Context, filter preference.Filter) ([]preference.Preference, error)
LoadPlatformPreferences(ctx context.Context) (map[string]string, error)
LoadUserPreferences(ctx context.Context, filter preference.Filter) ([]preference.Preference, error)
}

type PolicyService interface {
Expand Down
Loading
Loading