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:
@@ -83,6 +83,17 @@ type PackageStore interface {
|
||||
|
||||
// SetPackageSettings stores admin-configured package-level settings.
|
||||
SetPackageSettings(ctx context.Context, id string, settings json.RawMessage) error
|
||||
|
||||
// ── Team-level settings (v0.2.0) ─────────────────
|
||||
|
||||
// GetTeamSettings returns team-scoped overrides for a package.
|
||||
GetTeamSettings(ctx context.Context, pkgID, teamID string) (json.RawMessage, error)
|
||||
|
||||
// SetTeamSettings upserts team-scoped overrides for a package.
|
||||
SetTeamSettings(ctx context.Context, pkgID, teamID string, settings json.RawMessage) error
|
||||
|
||||
// DeleteTeamSettings removes team-scoped overrides, reverting to global defaults.
|
||||
DeleteTeamSettings(ctx context.Context, pkgID, teamID string) error
|
||||
}
|
||||
|
||||
// PackageRegistration is a row from the packages table.
|
||||
@@ -123,3 +134,11 @@ type PackageUserSettings struct {
|
||||
Settings json.RawMessage `json:"settings" db:"settings"`
|
||||
IsEnabled bool `json:"is_enabled" db:"is_enabled"`
|
||||
}
|
||||
|
||||
// PackageTeamSettings stores team-scoped overrides for a package (v0.2.0).
|
||||
type PackageTeamSettings struct {
|
||||
PackageID string `json:"package_id" db:"package_id"`
|
||||
TeamID string `json:"team_id" db:"team_id"`
|
||||
Settings json.RawMessage `json:"settings" db:"settings"`
|
||||
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -367,6 +367,36 @@ func (s *PackageStore) SetPackageSettings(ctx context.Context, id string, settin
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Team-level settings (v0.2.0) ─────────────────
|
||||
|
||||
func (s *PackageStore) GetTeamSettings(ctx context.Context, pkgID, teamID string) (json.RawMessage, error) {
|
||||
var settings []byte
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT settings FROM package_team_settings WHERE package_id = $1 AND team_id = $2`,
|
||||
pkgID, teamID).Scan(&settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.RawMessage(settings), nil
|
||||
}
|
||||
|
||||
func (s *PackageStore) SetTeamSettings(ctx context.Context, pkgID, teamID string, settings json.RawMessage) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO package_team_settings (package_id, team_id, settings, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (package_id, team_id)
|
||||
DO UPDATE SET settings = EXCLUDED.settings, updated_at = NOW()`,
|
||||
pkgID, teamID, []byte(settings))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PackageStore) DeleteTeamSettings(ctx context.Context, pkgID, teamID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM package_team_settings WHERE package_id = $1 AND team_id = $2`,
|
||||
pkgID, teamID)
|
||||
return err
|
||||
}
|
||||
|
||||
// nullStrPtr converts *string to sql.NullString for nullable FK columns.
|
||||
func nullStrPtr(s *string) sql.NullString {
|
||||
if s == nil {
|
||||
|
||||
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
|
||||
}
|
||||
200
server/store/settings_cascade_test.go
Normal file
200
server/store/settings_cascade_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
func TestResolveSettings_GlobalOnly(t *testing.T) {
|
||||
global := json.RawMessage(`{"font_size": 14, "theme": "dark"}`)
|
||||
schema := []SettingSchema{
|
||||
{Key: "font_size", UserOverridable: boolPtr(true)},
|
||||
{Key: "theme", UserOverridable: boolPtr(true)},
|
||||
}
|
||||
|
||||
merged := ResolveSettings(global, nil, nil, schema)
|
||||
|
||||
if merged["font_size"] != float64(14) {
|
||||
t.Errorf("font_size: got %v, want 14", merged["font_size"])
|
||||
}
|
||||
if merged["theme"] != "dark" {
|
||||
t.Errorf("theme: got %v, want dark", merged["theme"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSettings_UserOverridesGlobal(t *testing.T) {
|
||||
global := json.RawMessage(`{"font_size": 14, "theme": "dark"}`)
|
||||
user := json.RawMessage(`{"font_size": 18}`)
|
||||
schema := []SettingSchema{
|
||||
{Key: "font_size", UserOverridable: boolPtr(true)},
|
||||
{Key: "theme", UserOverridable: boolPtr(true)},
|
||||
}
|
||||
|
||||
merged := ResolveSettings(global, nil, user, schema)
|
||||
|
||||
if merged["font_size"] != float64(18) {
|
||||
t.Errorf("font_size: got %v, want 18", merged["font_size"])
|
||||
}
|
||||
if merged["theme"] != "dark" {
|
||||
t.Errorf("theme: got %v, want dark", merged["theme"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSettings_LockedKeyBlocksUserAndTeam(t *testing.T) {
|
||||
global := json.RawMessage(`{"api_url": "https://prod.example.com", "font_size": 14}`)
|
||||
team := json.RawMessage(`{"api_url": "https://team.example.com", "font_size": 16}`)
|
||||
user := json.RawMessage(`{"api_url": "https://user.example.com", "font_size": 20}`)
|
||||
schema := []SettingSchema{
|
||||
{Key: "api_url", UserOverridable: boolPtr(false)},
|
||||
{Key: "font_size", UserOverridable: boolPtr(true)},
|
||||
}
|
||||
|
||||
merged := ResolveSettings(global, team, user, schema)
|
||||
|
||||
if merged["api_url"] != "https://prod.example.com" {
|
||||
t.Errorf("api_url: got %v, want https://prod.example.com (locked)", merged["api_url"])
|
||||
}
|
||||
if merged["font_size"] != float64(20) {
|
||||
t.Errorf("font_size: got %v, want 20 (user wins)", merged["font_size"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSettings_TeamOverridesGlobal(t *testing.T) {
|
||||
global := json.RawMessage(`{"font_size": 14}`)
|
||||
team := json.RawMessage(`{"font_size": 16}`)
|
||||
schema := []SettingSchema{
|
||||
{Key: "font_size", UserOverridable: boolPtr(true)},
|
||||
}
|
||||
|
||||
merged := ResolveSettings(global, team, nil, schema)
|
||||
|
||||
if merged["font_size"] != float64(16) {
|
||||
t.Errorf("font_size: got %v, want 16", merged["font_size"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSettings_UserOverridesTeam(t *testing.T) {
|
||||
global := json.RawMessage(`{"font_size": 14}`)
|
||||
team := json.RawMessage(`{"font_size": 16}`)
|
||||
user := json.RawMessage(`{"font_size": 20}`)
|
||||
schema := []SettingSchema{
|
||||
{Key: "font_size", UserOverridable: boolPtr(true)},
|
||||
}
|
||||
|
||||
merged := ResolveSettings(global, team, user, schema)
|
||||
|
||||
if merged["font_size"] != float64(20) {
|
||||
t.Errorf("font_size: got %v, want 20", merged["font_size"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSettings_AbsentFlagDefaultsTrue(t *testing.T) {
|
||||
global := json.RawMessage(`{"font_size": 14}`)
|
||||
user := json.RawMessage(`{"font_size": 20}`)
|
||||
// UserOverridable is nil — should default to true
|
||||
schema := []SettingSchema{
|
||||
{Key: "font_size"},
|
||||
}
|
||||
|
||||
merged := ResolveSettings(global, nil, user, schema)
|
||||
|
||||
if merged["font_size"] != float64(20) {
|
||||
t.Errorf("font_size: got %v, want 20 (nil flag = overridable)", merged["font_size"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSettings_UnknownKeyDefaultsOverridable(t *testing.T) {
|
||||
global := json.RawMessage(`{"known": "a", "unknown": "b"}`)
|
||||
user := json.RawMessage(`{"unknown": "c"}`)
|
||||
schema := []SettingSchema{
|
||||
{Key: "known", UserOverridable: boolPtr(false)},
|
||||
// "unknown" is not in schema
|
||||
}
|
||||
|
||||
merged := ResolveSettings(global, nil, user, schema)
|
||||
|
||||
if merged["unknown"] != "c" {
|
||||
t.Errorf("unknown: got %v, want c (undeclared keys are overridable)", merged["unknown"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSettings_NilLayers(t *testing.T) {
|
||||
merged := ResolveSettings(nil, nil, nil, nil)
|
||||
if merged == nil {
|
||||
t.Error("expected non-nil empty map")
|
||||
}
|
||||
if len(merged) != 0 {
|
||||
t.Errorf("expected empty map, got %v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSettings_EmptyJSON(t *testing.T) {
|
||||
merged := ResolveSettings(json.RawMessage(`{}`), json.RawMessage(`{}`), json.RawMessage(`{}`), nil)
|
||||
if len(merged) != 0 {
|
||||
t.Errorf("expected empty map, got %v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSettingsSchema(t *testing.T) {
|
||||
manifest := map[string]any{
|
||||
"settings": []any{
|
||||
map[string]any{"key": "font_size", "type": "number", "default": 14, "user_overridable": true},
|
||||
map[string]any{"key": "api_url", "type": "string", "user_overridable": false},
|
||||
map[string]any{"key": "theme", "type": "string"},
|
||||
map[string]any{"no_key": true}, // should be skipped
|
||||
},
|
||||
}
|
||||
|
||||
schema := ParseSettingsSchema(manifest)
|
||||
|
||||
if len(schema) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d", len(schema))
|
||||
}
|
||||
|
||||
if schema[0].Key != "font_size" || !schema[0].IsUserOverridable() {
|
||||
t.Errorf("font_size: expected overridable=true, got %v", schema[0].UserOverridable)
|
||||
}
|
||||
if schema[1].Key != "api_url" || schema[1].IsUserOverridable() {
|
||||
t.Errorf("api_url: expected overridable=false, got %v", schema[1].UserOverridable)
|
||||
}
|
||||
if schema[2].Key != "theme" || !schema[2].IsUserOverridable() {
|
||||
t.Errorf("theme: nil flag should default to overridable=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSettingsSchema_NoSettings(t *testing.T) {
|
||||
schema := ParseSettingsSchema(map[string]any{})
|
||||
if schema != nil {
|
||||
t.Errorf("expected nil, got %v", schema)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterOverridableKeys(t *testing.T) {
|
||||
settings := map[string]any{
|
||||
"font_size": 20,
|
||||
"api_url": "https://hack.example.com",
|
||||
"theme": "light",
|
||||
}
|
||||
schema := []SettingSchema{
|
||||
{Key: "font_size", UserOverridable: boolPtr(true)},
|
||||
{Key: "api_url", UserOverridable: boolPtr(false)},
|
||||
{Key: "theme"}, // nil = overridable
|
||||
}
|
||||
|
||||
filtered, rejected := FilterOverridableKeys(settings, schema)
|
||||
|
||||
if _, ok := filtered["api_url"]; ok {
|
||||
t.Error("api_url should have been stripped")
|
||||
}
|
||||
if filtered["font_size"] != 20 {
|
||||
t.Errorf("font_size: got %v, want 20", filtered["font_size"])
|
||||
}
|
||||
if filtered["theme"] != "light" {
|
||||
t.Errorf("theme: got %v, want light", filtered["theme"])
|
||||
}
|
||||
if len(rejected) != 1 || rejected[0] != "api_url" {
|
||||
t.Errorf("rejected: got %v, want [api_url]", rejected)
|
||||
}
|
||||
}
|
||||
@@ -388,6 +388,36 @@ func (s *PackageStore) SetPackageSettings(ctx context.Context, id string, settin
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Team-level settings (v0.2.0) ─────────────────
|
||||
|
||||
func (s *PackageStore) GetTeamSettings(ctx context.Context, pkgID, teamID string) (json.RawMessage, error) {
|
||||
var settings string
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT settings FROM package_team_settings WHERE package_id = ? AND team_id = ?`,
|
||||
pkgID, teamID).Scan(&settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.RawMessage(settings), nil
|
||||
}
|
||||
|
||||
func (s *PackageStore) SetTeamSettings(ctx context.Context, pkgID, teamID string, settings json.RawMessage) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO package_team_settings (package_id, team_id, settings, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT (package_id, team_id)
|
||||
DO UPDATE SET settings = EXCLUDED.settings, updated_at = datetime('now')`,
|
||||
pkgID, teamID, string(settings))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PackageStore) DeleteTeamSettings(ctx context.Context, pkgID, teamID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM package_team_settings WHERE package_id = ? AND team_id = ?`,
|
||||
pkgID, teamID)
|
||||
return err
|
||||
}
|
||||
|
||||
func nullStrPtr(s *string) sql.NullString {
|
||||
if s == nil {
|
||||
return sql.NullString{}
|
||||
|
||||
Reference in New Issue
Block a user