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:
2026-03-26 17:57:14 +00:00
parent a830ae3684
commit 45448698d6
13 changed files with 664 additions and 30 deletions

View File

@@ -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"`
}

View File

@@ -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 {

View 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
}

View 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)
}
}

View File

@@ -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{}