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:
nemunaire 2026-04-05 10:55:56 +07:00
commit 82b73fa0bd
6 changed files with 349 additions and 10 deletions

View file

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

View file

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

View file

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

View file

@ -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),
})),
);

View file

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

View file

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