Feat settings cascade with user_overridable flag
Three-tier settings resolution (global → team → user) with per-key user_overridable flag in package manifests. Admins can lock settings that team admins and users cannot override. - Add package_team_settings table (migration 010) - Add GetTeamSettings/SetTeamSettings/DeleteTeamSettings to PackageStore - Add ResolveSettings, ParseSettingsSchema, FilterOverridableKeys - Rewrite Starlark settings.get() to use cascade resolver - Add TeamID to RunContext for team-aware resolution - Enforce user_overridable on user settings save - Add team package settings API (GET/PUT/DELETE) - 12 unit tests for cascade resolution Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
147
server/store/settings_cascade.go
Normal file
147
server/store/settings_cascade.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package store
|
||||
|
||||
// settings_cascade.go — v0.2.0
|
||||
//
|
||||
// Pure functions for three-tier settings resolution:
|
||||
// global (admin) → team → user
|
||||
//
|
||||
// Two orthogonal axes:
|
||||
// - RBAC controls who can write at each scope
|
||||
// - user_overridable (per setting key) controls whether lower scopes
|
||||
// can override higher scope values
|
||||
//
|
||||
// The kernel enforces the cascade without knowing setting semantics.
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// SettingSchema represents one entry from the manifest "settings" array.
|
||||
type SettingSchema struct {
|
||||
Key string `json:"key"`
|
||||
UserOverridable *bool `json:"user_overridable,omitempty"`
|
||||
}
|
||||
|
||||
// IsUserOverridable returns whether a setting key can be overridden at
|
||||
// lower scopes (team, user). Defaults to true when the field is absent
|
||||
// for backward compatibility with existing manifests.
|
||||
func (s SettingSchema) IsUserOverridable() bool {
|
||||
return s.UserOverridable == nil || *s.UserOverridable
|
||||
}
|
||||
|
||||
// ParseSettingsSchema extracts []SettingSchema from a package manifest.
|
||||
func ParseSettingsSchema(manifest map[string]any) []SettingSchema {
|
||||
arr, ok := manifest["settings"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := make([]SettingSchema, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s := SettingSchema{}
|
||||
s.Key, _ = m["key"].(string)
|
||||
if s.Key == "" {
|
||||
continue
|
||||
}
|
||||
if uo, ok := m["user_overridable"].(bool); ok {
|
||||
s.UserOverridable = &uo
|
||||
}
|
||||
result = append(result, s)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ResolveSettings implements the settings cascade:
|
||||
//
|
||||
// user override → team override → global default
|
||||
//
|
||||
// For each key declared in the schema with user_overridable: false,
|
||||
// team and user values are ignored — only the global value is used.
|
||||
// Keys not declared in schema default to overridable.
|
||||
//
|
||||
// Parameters:
|
||||
// - globalSettings: admin-set values from packages.package_settings
|
||||
// - teamSettings: team-scoped overrides (nil if no team context)
|
||||
// - userSettings: per-user overrides (nil if no user context)
|
||||
// - schema: parsed manifest settings array
|
||||
func ResolveSettings(
|
||||
globalSettings json.RawMessage,
|
||||
teamSettings json.RawMessage,
|
||||
userSettings json.RawMessage,
|
||||
schema []SettingSchema,
|
||||
) map[string]any {
|
||||
global := jsonToMap(globalSettings)
|
||||
team := jsonToMap(teamSettings)
|
||||
user := jsonToMap(userSettings)
|
||||
|
||||
// Build overridable lookup from schema
|
||||
overridable := make(map[string]bool, len(schema))
|
||||
for _, s := range schema {
|
||||
overridable[s.Key] = s.IsUserOverridable()
|
||||
}
|
||||
|
||||
// Start with global as base
|
||||
merged := make(map[string]any, len(global))
|
||||
for k, v := range global {
|
||||
merged[k] = v
|
||||
}
|
||||
|
||||
// Overlay team values (respecting user_overridable)
|
||||
for k, v := range team {
|
||||
canOverride, declared := overridable[k]
|
||||
if !declared {
|
||||
canOverride = true // unknown keys default to overridable
|
||||
}
|
||||
if canOverride {
|
||||
merged[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay user values (respecting user_overridable)
|
||||
for k, v := range user {
|
||||
canOverride, declared := overridable[k]
|
||||
if !declared {
|
||||
canOverride = true
|
||||
}
|
||||
if canOverride {
|
||||
merged[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// FilterOverridableKeys strips non-overridable keys from a settings map.
|
||||
// Used by handlers to enforce user_overridable before saving.
|
||||
// Returns the filtered map and the list of rejected keys.
|
||||
func FilterOverridableKeys(settings map[string]any, schema []SettingSchema) (map[string]any, []string) {
|
||||
overridable := make(map[string]bool, len(schema))
|
||||
for _, s := range schema {
|
||||
overridable[s.Key] = s.IsUserOverridable()
|
||||
}
|
||||
|
||||
filtered := make(map[string]any, len(settings))
|
||||
var rejected []string
|
||||
for k, v := range settings {
|
||||
canOverride, declared := overridable[k]
|
||||
if !declared {
|
||||
canOverride = true
|
||||
}
|
||||
if canOverride {
|
||||
filtered[k] = v
|
||||
} else {
|
||||
rejected = append(rejected, k)
|
||||
}
|
||||
}
|
||||
return filtered, rejected
|
||||
}
|
||||
|
||||
func jsonToMap(raw json.RawMessage) map[string]any {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
json.Unmarshal(raw, &m)
|
||||
return m
|
||||
}
|
||||
Reference in New Issue
Block a user