checkers: add NoOverride field support for checker options
Prevent more specific scopes from overriding option values locked at a higher scope (e.g. admin). Includes defense-in-depth stripping on Set/Add operations, merge-time preservation, and frontend filtering.
This commit is contained in:
parent
526c0f4bb7
commit
82b73fa0bd
6 changed files with 349 additions and 10 deletions
|
|
@ -126,10 +126,24 @@ func (u *CheckerOptionsUsecase) GetCheckerOptions(
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Determine which fields are NoOverride.
|
||||
var noOverrideIds map[string]bool
|
||||
if def := checkerPkg.FindChecker(checkerName); def != nil {
|
||||
noOverrideIds = getNoOverrideFieldIds(def)
|
||||
}
|
||||
|
||||
merged := make(happydns.CheckerOptions)
|
||||
// positionals are returned in order of increasing specificity.
|
||||
for _, p := range positionals {
|
||||
maps.Copy(merged, p.Options)
|
||||
for k, v := range p.Options {
|
||||
// If the key is NoOverride and already set by a less specific scope, skip it.
|
||||
if noOverrideIds[k] {
|
||||
if _, exists := merged[k]; exists {
|
||||
continue
|
||||
}
|
||||
}
|
||||
merged[k] = v
|
||||
}
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
|
@ -153,17 +167,26 @@ func (u *CheckerOptionsUsecase) SetCheckerOptions(
|
|||
serviceId *happydns.Identifier,
|
||||
opts happydns.CheckerOptions,
|
||||
) error {
|
||||
// Determine which field IDs are auto-filled for this checker.
|
||||
// Determine which field IDs are auto-filled or NoOverride for this checker.
|
||||
var autoFillIds map[string]string
|
||||
var noOverrideScopes map[string]happydns.CheckScopeType
|
||||
if def := checkerPkg.FindChecker(checkerName); def != nil {
|
||||
autoFillIds = getAutoFillFieldIds(def)
|
||||
noOverrideScopes = getNoOverrideFieldScopes(def)
|
||||
}
|
||||
|
||||
currentScope := scopeFromIdentifiers(userId, domainId, serviceId)
|
||||
|
||||
filtered := make(happydns.CheckerOptions, len(opts))
|
||||
for k, v := range opts {
|
||||
if !isEmptyValue(v) && autoFillIds[k] == "" {
|
||||
filtered[k] = v
|
||||
if isEmptyValue(v) || autoFillIds[k] != "" {
|
||||
continue
|
||||
}
|
||||
// Defense-in-depth: strip NoOverride fields at scopes below their definition.
|
||||
if defScope, ok := noOverrideScopes[k]; ok && currentScope > defScope {
|
||||
continue
|
||||
}
|
||||
filtered[k] = v
|
||||
}
|
||||
return u.store.UpdateCheckerConfiguration(checkerName, userId, domainId, serviceId, filtered)
|
||||
}
|
||||
|
|
@ -181,7 +204,19 @@ func (u *CheckerOptionsUsecase) AddCheckerOptions(
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine NoOverride scopes for defense-in-depth stripping.
|
||||
var noOverrideScopes map[string]happydns.CheckScopeType
|
||||
if def := checkerPkg.FindChecker(checkerName); def != nil {
|
||||
noOverrideScopes = getNoOverrideFieldScopes(def)
|
||||
}
|
||||
currentScope := scopeFromIdentifiers(userId, domainId, serviceId)
|
||||
|
||||
for k, v := range newOpts {
|
||||
// Defense-in-depth: skip NoOverride fields at scopes below their definition.
|
||||
if defScope, ok := noOverrideScopes[k]; ok && currentScope > defScope {
|
||||
continue
|
||||
}
|
||||
if isEmptyValue(v) {
|
||||
delete(existing, k)
|
||||
} else {
|
||||
|
|
@ -331,6 +366,17 @@ func (u *CheckerOptionsUsecase) SetCheckerOption(
|
|||
optName string,
|
||||
value any,
|
||||
) error {
|
||||
// Defense-in-depth: reject NoOverride fields at scopes below their definition.
|
||||
if def := checkerPkg.FindChecker(checkerName); def != nil {
|
||||
noOverrideScopes := getNoOverrideFieldScopes(def)
|
||||
if defScope, ok := noOverrideScopes[optName]; ok {
|
||||
currentScope := scopeFromIdentifiers(userId, domainId, serviceId)
|
||||
if currentScope > defScope {
|
||||
return fmt.Errorf("option %q cannot be overridden at this scope level", optName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
existing, err := u.getScopedOptions(checkerName, userId, domainId, serviceId)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -374,6 +420,72 @@ func getAutoFillFieldIds(def *happydns.CheckerDefinition) map[string]string {
|
|||
return result
|
||||
}
|
||||
|
||||
// collectNoOverrideFromDoc scans all option groups in a CheckerOptionsDocumentation
|
||||
// and adds any fields with NoOverride set to the result set.
|
||||
func collectNoOverrideFromDoc(doc happydns.CheckerOptionsDocumentation, result map[string]bool) {
|
||||
for _, group := range [][]happydns.Field{
|
||||
doc.AdminOpts,
|
||||
doc.UserOpts,
|
||||
doc.DomainOpts,
|
||||
doc.ServiceOpts,
|
||||
doc.RunOpts,
|
||||
} {
|
||||
for _, f := range group {
|
||||
if f.NoOverride {
|
||||
result[f.Id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getNoOverrideFieldIds returns the set of field IDs that have NoOverride set
|
||||
// for the given checker definition across all option groups and rules.
|
||||
func getNoOverrideFieldIds(def *happydns.CheckerDefinition) map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
collectNoOverrideFromDoc(def.Options, result)
|
||||
for _, rule := range def.Rules {
|
||||
if rwo, ok := rule.(happydns.CheckRuleWithOptions); ok {
|
||||
collectNoOverrideFromDoc(rwo.Options(), result)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getNoOverrideFieldScopes returns a map from field ID to the scope at which
|
||||
// the NoOverride field is defined. Used for defense-in-depth stripping.
|
||||
func getNoOverrideFieldScopes(def *happydns.CheckerDefinition) map[string]happydns.CheckScopeType {
|
||||
result := make(map[string]happydns.CheckScopeType)
|
||||
scanGroups := func(doc happydns.CheckerOptionsDocumentation) {
|
||||
for _, f := range doc.AdminOpts {
|
||||
if f.NoOverride {
|
||||
result[f.Id] = happydns.CheckScopeAdmin
|
||||
}
|
||||
}
|
||||
for _, f := range doc.UserOpts {
|
||||
if f.NoOverride {
|
||||
result[f.Id] = happydns.CheckScopeUser
|
||||
}
|
||||
}
|
||||
for _, f := range doc.DomainOpts {
|
||||
if f.NoOverride {
|
||||
result[f.Id] = happydns.CheckScopeDomain
|
||||
}
|
||||
}
|
||||
for _, f := range doc.ServiceOpts {
|
||||
if f.NoOverride {
|
||||
result[f.Id] = happydns.CheckScopeService
|
||||
}
|
||||
}
|
||||
}
|
||||
scanGroups(def.Options)
|
||||
for _, rule := range def.Rules {
|
||||
if rwo, ok := rule.(happydns.CheckRuleWithOptions); ok {
|
||||
scanGroups(rwo.Options())
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// buildAutoFillContext loads domain/zone data from storage and builds a map
|
||||
// of auto-fill key to resolved value.
|
||||
func (u *CheckerOptionsUsecase) buildAutoFillContext(
|
||||
|
|
@ -484,6 +596,15 @@ func (u *CheckerOptionsUsecase) BuildMergedCheckerOptionsWithAutoFill(
|
|||
|
||||
merged := BuildMergedCheckerOptions(storedOpts, runOpts)
|
||||
|
||||
// Restore NoOverride fields from storedOpts so that runOpts cannot override them.
|
||||
if def := checkerPkg.FindChecker(checkerName); def != nil {
|
||||
for id := range getNoOverrideFieldIds(def) {
|
||||
if v, ok := storedOpts[id]; ok {
|
||||
merged[id] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
target := happydns.CheckTarget{
|
||||
UserId: userId,
|
||||
DomainId: domainId,
|
||||
|
|
|
|||
|
|
@ -1440,3 +1440,215 @@ func TestValidateOptions_SkipsAutoFillFields(t *testing.T) {
|
|||
t.Fatalf("auto-fill required field should be skipped during validation, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- NoOverride tests ---
|
||||
|
||||
func TestGetCheckerOptions_NoOverridePreservesAdminValue(t *testing.T) {
|
||||
registerTestChecker("no_override_merge", &happydns.CheckerDefinition{
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
AdminOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "locked", Type: "boolean", NoOverride: true},
|
||||
},
|
||||
UserOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "threshold", Type: "number"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
store := newOptionsStore()
|
||||
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
|
||||
uid := idPtr()
|
||||
|
||||
// Set at admin scope.
|
||||
store.UpdateCheckerConfiguration("no_override_merge", nil, nil, nil, happydns.CheckerOptions{
|
||||
"locked": true,
|
||||
})
|
||||
// Attempt to override at user scope (should be ignored during merge).
|
||||
store.UpdateCheckerConfiguration("no_override_merge", uid, nil, nil, happydns.CheckerOptions{
|
||||
"locked": false,
|
||||
"threshold": float64(42),
|
||||
})
|
||||
|
||||
merged, err := uc.GetCheckerOptions("no_override_merge", uid, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if merged["locked"] != true {
|
||||
t.Errorf("expected locked=true (admin value preserved), got %v", merged["locked"])
|
||||
}
|
||||
if merged["threshold"] != float64(42) {
|
||||
t.Errorf("expected threshold=42 (user value applied), got %v", merged["threshold"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCheckerOptions_NoOverrideAllowsSameScope(t *testing.T) {
|
||||
registerTestChecker("no_override_same_scope", &happydns.CheckerDefinition{
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
AdminOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "locked", Type: "boolean", NoOverride: true},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
store := newOptionsStore()
|
||||
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
|
||||
// Only admin scope sets the value — no conflict.
|
||||
store.UpdateCheckerConfiguration("no_override_same_scope", nil, nil, nil, happydns.CheckerOptions{
|
||||
"locked": true,
|
||||
})
|
||||
|
||||
merged, err := uc.GetCheckerOptions("no_override_same_scope", nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if merged["locked"] != true {
|
||||
t.Errorf("expected locked=true, got %v", merged["locked"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMergedCheckerOptionsWithAutoFill_NoOverrideBlocksRunOpts(t *testing.T) {
|
||||
registerTestChecker("no_override_runopt", &happydns.CheckerDefinition{
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
AdminOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "locked", Type: "boolean", NoOverride: true},
|
||||
},
|
||||
UserOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "threshold", Type: "number"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
store := newOptionsStore()
|
||||
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
|
||||
uid := idPtr()
|
||||
|
||||
// Admin sets locked=true.
|
||||
store.UpdateCheckerConfiguration("no_override_runopt", nil, nil, nil, happydns.CheckerOptions{
|
||||
"locked": true,
|
||||
})
|
||||
// User sets threshold.
|
||||
store.UpdateCheckerConfiguration("no_override_runopt", uid, nil, nil, happydns.CheckerOptions{
|
||||
"threshold": float64(10),
|
||||
})
|
||||
|
||||
// RunOpts tries to override locked.
|
||||
merged, err := uc.BuildMergedCheckerOptionsWithAutoFill("no_override_runopt", uid, nil, nil, happydns.CheckerOptions{
|
||||
"locked": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if merged["locked"] != true {
|
||||
t.Errorf("expected locked=true (NoOverride should block runOpts), got %v", merged["locked"])
|
||||
}
|
||||
if merged["threshold"] != float64(10) {
|
||||
t.Errorf("expected threshold=10, got %v", merged["threshold"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCheckerOptions_StripsNoOverrideAtLowerScope(t *testing.T) {
|
||||
registerTestChecker("no_override_set", &happydns.CheckerDefinition{
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
AdminOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "locked", Type: "boolean", NoOverride: true},
|
||||
},
|
||||
UserOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "threshold", Type: "number"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
store := newOptionsStore()
|
||||
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
|
||||
uid := idPtr()
|
||||
|
||||
// Try to set locked at user scope — should be silently stripped.
|
||||
err := uc.SetCheckerOptions("no_override_set", uid, nil, nil, happydns.CheckerOptions{
|
||||
"locked": true,
|
||||
"threshold": float64(99),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Check what was actually stored.
|
||||
stored := store.data[posKey("no_override_set", uid, nil, nil)]
|
||||
if _, ok := stored["locked"]; ok {
|
||||
t.Error("expected locked to be stripped from user-scope storage")
|
||||
}
|
||||
if stored["threshold"] != float64(99) {
|
||||
t.Errorf("expected threshold=99 to be stored, got %v", stored["threshold"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCheckerOptions_StripsNoOverrideAtLowerScope(t *testing.T) {
|
||||
registerTestChecker("no_override_add", &happydns.CheckerDefinition{
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
AdminOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "locked", Type: "boolean", NoOverride: true},
|
||||
},
|
||||
UserOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "threshold", Type: "number"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
store := newOptionsStore()
|
||||
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
|
||||
uid := idPtr()
|
||||
|
||||
// Pre-populate user scope with threshold.
|
||||
store.UpdateCheckerConfiguration("no_override_add", uid, nil, nil, happydns.CheckerOptions{
|
||||
"threshold": float64(50),
|
||||
})
|
||||
|
||||
// Try to add locked at user scope — should be silently skipped.
|
||||
result, err := uc.AddCheckerOptions("no_override_add", uid, nil, nil, happydns.CheckerOptions{
|
||||
"locked": true,
|
||||
"threshold": float64(75),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := result["locked"]; ok {
|
||||
t.Error("expected locked to be skipped in AddCheckerOptions result")
|
||||
}
|
||||
if result["threshold"] != float64(75) {
|
||||
t.Errorf("expected threshold=75, got %v", result["threshold"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCheckerOption_RejectsNoOverrideAtLowerScope(t *testing.T) {
|
||||
registerTestChecker("no_override_set_single", &happydns.CheckerDefinition{
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
AdminOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "locked", Type: "boolean", NoOverride: true},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
store := newOptionsStore()
|
||||
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
|
||||
uid := idPtr()
|
||||
|
||||
// Setting at admin scope should work.
|
||||
err := uc.SetCheckerOption("no_override_set_single", nil, nil, nil, "locked", true)
|
||||
if err != nil {
|
||||
t.Fatalf("expected SetCheckerOption at admin scope to succeed, got: %v", err)
|
||||
}
|
||||
|
||||
// Setting at user scope should fail.
|
||||
err = uc.SetCheckerOption("no_override_set_single", uid, nil, nil, "locked", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when setting NoOverride field at lower scope")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot be overridden") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,10 @@ type Field struct {
|
|||
// AutoFill indicates that this field is automatically filled by the system
|
||||
// based on execution context (e.g. domain name, zone, service type).
|
||||
AutoFill string `json:"autoFill,omitempty"`
|
||||
|
||||
// NoOverride indicates that once this field is set at a given scope,
|
||||
// more specific scopes cannot override its value.
|
||||
NoOverride bool `json:"noOverride,omitempty"`
|
||||
}
|
||||
|
||||
type FormState struct {
|
||||
|
|
|
|||
|
|
@ -75,11 +75,13 @@
|
|||
onclean,
|
||||
}: Props = $props();
|
||||
|
||||
// Filter out auto-fill fields from editable groups (they are system-provided).
|
||||
// Filter out auto-fill and noOverride fields from editable groups.
|
||||
// Auto-fill fields are system-provided; noOverride fields can only be
|
||||
// changed at the scope where they are defined (typically admin).
|
||||
let filteredEditableGroups = $derived(
|
||||
editableGroups.map((g) => ({
|
||||
...g,
|
||||
opts: g.opts.filter((opt) => !opt.autoFill),
|
||||
opts: g.opts.filter((opt) => !opt.autoFill && !opt.noOverride),
|
||||
})),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@
|
|||
const ids = new Set<string>();
|
||||
if (!resolvedStatus) return ids;
|
||||
const addOpts = (opts: HappydnsCheckerOptionDocumentation[] | undefined) =>
|
||||
opts?.forEach((o) => o.id && ids.add(o.id));
|
||||
opts?.forEach((o) => o.id && !o.noOverride && ids.add(o.id));
|
||||
addOpts(resolvedStatus.options?.runOpts);
|
||||
addOpts(resolvedStatus.options?.adminOpts);
|
||||
addOpts(resolvedStatus.options?.userOpts);
|
||||
|
|
@ -204,7 +204,7 @@
|
|||
{@const runOpts = [
|
||||
...(status.options?.runOpts || []),
|
||||
...activeRulesForOpts.flatMap((r: any) => r?.options?.runOpts || []),
|
||||
]}
|
||||
].filter((o: any) => !o.noOverride)}
|
||||
{@const otherOpts = [
|
||||
...(status.options?.adminOpts || []),
|
||||
...(status.options?.userOpts || []),
|
||||
|
|
@ -214,7 +214,7 @@
|
|||
...(r?.options?.userOpts || []),
|
||||
...(r?.options?.domainOpts || []),
|
||||
]),
|
||||
].filter((o: any) => o.id)}
|
||||
].filter((o: any) => o.id && !o.noOverride)}
|
||||
<Form
|
||||
id="run-check-modal"
|
||||
onsubmit={(e: Event) => {
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ export function collectAllOptionDocs(
|
|||
...(r.options?.userOpts || []),
|
||||
...(r.options?.domainOpts || []),
|
||||
]),
|
||||
];
|
||||
].filter((o) => !o.noOverride);
|
||||
}
|
||||
|
||||
export function downloadBlob(content: string, filename: string, mime: string) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue