This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/store/settings_cascade.go
Jeffrey Smith 45448698d6 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>
2026-03-26 17:57:14 +00:00

148 lines
3.9 KiB
Go

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
}