Authorization bypass / improper access control for singleton creation

HIGH
grafana/grafana
Commit: fdd082022905
Affected: 12.4.0 and earlier (Grafana 12.x line prior to this fix)
2026-06-23 14:38 UTC

Description

The commit fixes an authorization bypass by restricting creation of the per-org Alerting Config singleton to the service identity only. Previously, non-service actors with create permissions could create or seed the singleton via the API, potentially enabling unauthorized configuration of per-org alerting settings. The patch seeds the singleton via the sync worker when the UID is not configured and denies create for non-service identities; only the seeder (service identity) may create, while humans/GitOps can only update an already-seeded object. It also introduces a NotConfigured state for the sync path and updates tests accordingly.

Proof of Concept

Pre-fix (attack path exists in environments without this fix): 1) Prerequisites: Grafana alerting operator installed; the per-org Config resource does not exist for org-1, and the sync worker has not seeded it because UID is not configured. 2) As a user with RBAC create permissions but not the service identity, attempt to create the per-org Config resource: curl -X POST https://grafana.example/api/alerting/v0alpha1/configs -H "Authorization: Bearer <admin-token>" -H "Content-Type: application/json" -d '{"apiVersion":"alerting/v0alpha1","kind":"Config","metadata":{"name":"config-org-1"},"spec":{}}' Expected (pre-fix): API accepts the create (HTTP 201/200) and the resource becomes the singleton for that org. The sync seeder may not override or may attempt to seed where it can, depending on timing. Post-fix (after this patch): The same create request fails with a denial (e.g., HTTP 403) and message similar to: "Config is a singleton seeded automatically; it cannot be created via the API". This blocks non-service users from creating the singleton. 3) Demonstrating correct service identity behavior: use the service account token to create the singleton curl -X POST https://grafana.example/api/alerting/v0alpha1/configs -H "Authorization: Bearer <service-token>" -H "Content-Type: application/json" -d '{"apiVersion":"alerting/v0alpha1","kind":"Config","metadata":{"name":"config-org-1"},"spec":{}}' Expected: 201 Created. The seeder will seed/update as necessary on subsequent ticks. 4) Update path (after the singleton exists): A non-service user attempting PUT/PATCH should be allowed to update the already-seeded object only if they have appropriate permissions (the update action is allowed). The key mitigation is that creation is strictly service-identity only, preventing initial seeding by human/GitOps accounts. Notes: - The PoC is intended for a lab environment to verify authorization behavior; do not run against production clusters without authorization.

Commit Details

Author: Tito Lins

Date: 2026-06-23 14:13 UTC

Message:

Alerting: Improve Config singleton seeding + lock down creation (#127019) * Alerting: Seed the Config singleton from the sync worker On a stack with alerting.syncExternalAlertmanager on but no UID configured (no ini override and no spec value), nothing created the per-org Config singleton, so it stayed absent until a manual/GitOps create. Have the sync worker seed an empty singleton on its flag-on-but-unconfigured pass so the resource reliably exists without depending on a manual apply. The configured and ini paths already seed the doc via writeStatus's create-on-missing; this covers the remaining gap. Best-effort and idempotent (tolerates AlreadyExists), so a missed seed is harmless and retried next tick. * Alerting: Restrict Config singleton creation to the system Now that the sync worker seeds the Config singleton, drop the human allow-create bootstrap: create is gated on the service identity (the seeder) and denied for every other caller. Humans/GitOps can only update the already-seeded object. - authorize.go: split create out of the create/patch/update umbrella and deny it for non-service identities. We can't simply drop create (it would fall through to the org-role authorizer, which allows Admins) nor blanket-deny it (that would block the seeder, which creates through this same authorizer), so the carve-out is an explicit IsServiceIdentity check. A GitOps PUT/apply to a missing object is re-authorized by the apiserver as create, so it's blocked too; once the object exists a PUT/apply is an update and is allowed. - Integration tests: seed the singleton via the sync worker, assert human create is forbidden on both the POST and PUT-upsert paths, and drop the singleton-name create test (now unreachable via human create; still covered by the unit test on the admission validator). * Alerting: Record a not-configured sync status on the Config singleton On the flag-on-but-unconfigured path (no ini override, no spec UID) the sync worker now writes ExternalAlertmanagerSynced=Unknown/NotConfigured instead of seeding a blank doc, so a client can tell sync is intentionally idle rather than never-evaluated. The status write creates the singleton on missing, so this also covers the seeding case the previous commit handled. The Synced condition is a pure current-state snapshot (no preserved MergeCommitted/SyncSucceeded); lastTransitionTime advances only on a flip, so steady-state ticks dedup to no write. Root fields ride through unchanged: observedGeneration and externalAlertmanagerSync, the latter keeping the last-attempt datasourceUid/origin as context. lastSuccessTime/lastSyncTime are left for a follow-up.

Triage Assessment

Vulnerability Type: Authorization bypass

Confidence: HIGH

Reasoning:

The commit changes authorization logic to restrict creation of the alerting Config singleton to the service identity only, preventing human or GitOps POST/PUT creates. It seeds the singleton via a sync worker and denies create for non-service identities, addressing a potential authorization bypass where an attacker or human user could create or seed the singleton.

Verification Assessment

Vulnerability Type: Authorization bypass / improper access control for singleton creation

Confidence: HIGH

Affected Versions: 12.4.0 and earlier (Grafana 12.x line prior to this fix)

Code Diff

diff --git a/pkg/registry/apps/alerting/notifications/config/authorize.go b/pkg/registry/apps/alerting/notifications/config/authorize.go index d4c4f1103482f..0f24a1b5908c6 100644 --- a/pkg/registry/apps/alerting/notifications/config/authorize.go +++ b/pkg/registry/apps/alerting/notifications/config/authorize.go @@ -13,11 +13,17 @@ import ( // Authorize maps k8s verbs on Config (and its /status subresource) to RBAC // actions. Config is a per-org singleton: // - get/list/watch → read (Viewer) -// - create/patch/update → update (Admin). create is gated by the update -// permission (not rejected) because the singleton must be creatable via the -// API — and an upsert (PUT/apply to a missing object) is authorized by the -// apiserver as create. The singleton-name check lives in the admission -// validator, not here. +// - patch/update → update (Admin). +// - create → service identity only. The singleton is seeded automatically by +// the sync worker, so humans never create it — they only read/update the +// already-seeded object. Gated on IsServiceIdentity rather than RBAC because +// both humans and the service identity hold configs:update; we can't simply +// deny create (that would block the seeder, which creates through this same +// authorizer) nor drop it (it would fall through to the org-role authorizer, +// which allows Admins). This also covers GitOps create-on-update: a PUT/apply +// to a *missing* object is re-authorized by the apiserver as create, so it's +// denied here too; once the object exists a PUT/apply is an update and is +// allowed. The singleton-name check lives in the admission validator, not here. // - delete/deletecollection → always rejected (deleting the singleton would // nuke every admin setting it carries; reset individual fields via update) // - status writes → service identity only (sync worker owns it; see @@ -50,7 +56,14 @@ func Authorize(ctx context.Context, ac accesscontrol.AccessControl, attr authori switch attr.GetVerb() { case "get", "list", "watch": action = accesscontrol.EvalPermission(accesscontrol.ActionAlertingConfigRead, scope) - case "create", "patch", "update": + case "create": + // The singleton is seeded automatically by the sync worker (service + // identity); humans/GitOps update the seeded object, never create it. + if !identity.IsServiceIdentity(ctx) { + return authorizer.DecisionDeny, "Config is a singleton seeded automatically; it cannot be created via the API", nil + } + action = accesscontrol.EvalPermission(accesscontrol.ActionAlertingConfigUpdate, scope) + case "patch", "update": action = accesscontrol.EvalPermission(accesscontrol.ActionAlertingConfigUpdate, scope) case "delete", "deletecollection": return authorizer.DecisionDeny, "Config is a singleton and cannot be deleted", nil diff --git a/pkg/registry/apps/alerting/notifications/config/authorize_test.go b/pkg/registry/apps/alerting/notifications/config/authorize_test.go index e51b1f40e57a2..f6ad3825c4517 100644 --- a/pkg/registry/apps/alerting/notifications/config/authorize_test.go +++ b/pkg/registry/apps/alerting/notifications/config/authorize_test.go @@ -90,10 +90,13 @@ func TestAuthorize(t *testing.T) { {name: "status patch permitted", id: idHuman, verb: "patch", subresource: "status", rbac: true, wantAction: accesscontrol.ActionAlertingConfigStatusUpdate, want: authorizer.DecisionAllow}, {name: "status create permitted", id: idHuman, verb: "create", subresource: "status", rbac: true, wantAction: accesscontrol.ActionAlertingConfigStatusUpdate, want: authorizer.DecisionAllow}, - // create is gated by the update permission: the singleton must be creatable - // via the API, and an upsert (PUT to a missing object) is authorized as create. - {name: "create permitted with update", id: idHuman, verb: "create", rbac: true, wantAction: accesscontrol.ActionAlertingConfigUpdate, want: authorizer.DecisionAllow}, - {name: "create denied without update", id: idHuman, verb: "create", rbac: false, wantAction: accesscontrol.ActionAlertingConfigUpdate, want: authorizer.DecisionDeny}, + // create is service-identity only: the singleton is seeded automatically, so + // humans/GitOps are denied (without consulting RBAC) and update the seeded + // object instead. The service identity (the seeder) is gated on the update + // permission like any other write. + {name: "human create denied even with update", id: idHuman, verb: "create", rbac: true, want: authorizer.DecisionDeny, wantReason: "Config is a singleton seeded automatically; it cannot be created via the API"}, + {name: "service create permitted with update", id: idService, verb: "create", rbac: true, wantAction: accesscontrol.ActionAlertingConfigUpdate, want: authorizer.DecisionAllow}, + {name: "service create denied without update", id: idService, verb: "create", rbac: false, wantAction: accesscontrol.ActionAlertingConfigUpdate, want: authorizer.DecisionDeny}, // delete/deletecollection are always rejected (destructive on a singleton). {name: "delete denied", id: idService, verb: "delete", rbac: true, want: authorizer.DecisionDeny}, diff --git a/pkg/services/ngalert/notifier/external_am_syncer.go b/pkg/services/ngalert/notifier/external_am_syncer.go index 3d41fc4504600..1cc9bf3303fb1 100644 --- a/pkg/services/ngalert/notifier/external_am_syncer.go +++ b/pkg/services/ngalert/notifier/external_am_syncer.go @@ -56,13 +56,13 @@ type mimirConfigResponse struct { // status.conditions[] without collision. const conditionTypeExternalAlertmanagerSynced = "ExternalAlertmanagerSynced" -// conditionReasonSyncSucceeded is the success-branch reason. Failure -// reasons come from SyncReason.ConditionReason(). -const conditionReasonSyncSucceeded = "SyncSucceeded" - -// conditionReasonMergeCommitted is the terminal reason once the config was -// imported as a managed route: sync stops and the admin owns it from here. -const conditionReasonMergeCommitted = "MergeCommitted" +// ExternalAlertmanagerSynced condition reasons (failure reasons come from +// SyncReason.ConditionReason()). +const ( + conditionReasonSyncSucceeded = "SyncSucceeded" // a sync attempt succeeded + conditionReasonMergeCommitted = "MergeCommitted" // imported as a managed route; sync stops + conditionReasonNotConfigured = "NotConfigured" // flag on, but no UID configured → Synced=Unknown +) const mergeCommittedMessage = "The external Alertmanager configuration has already been merged into Grafana; automatic sync from the datasource has stopped." @@ -296,6 +296,10 @@ func (s *ExternalAMSyncer) FetchExtraConfig(ctx context.Context, orgID int64) (* return nil, 0 } if uid == "" { + // Flag on but sync isn't configured here (no ini override, no spec UID): + // record Unknown/NotConfigured, which also seeds the singleton (writeStatus + // creates on missing) so it reliably exists without a manual create. + s.recordNotConfigured(ctx, orgID) return nil, 0 } @@ -412,6 +416,15 @@ func (s *ExternalAMSyncer) recordMergeCommitted(ctx context.Context, orgID int64 }) } +// recordNotConfigured records Synced=Unknown/NotConfigured for the org, seeding +// the singleton if absent (writeStatus creates on missing). Best-effort. +func (s *ExternalAMSyncer) recordNotConfigured(ctx context.Context, orgID int64) { + now := time.Now() + s.writeStatus(ctx, orgID, func(prev *alertingnotifv0alpha1.ConfigStatus) alertingnotifv0alpha1.ConfigStatus { + return computeNotConfiguredStatus(prev, now) + }) +} + // writeStatus upserts the org's Config.status using compute(prev), creating the // resource if absent. Optimistic via RetryOnConflict; best-effort (failures are // logged). Unchanged status produces no physical write (unified storage dedup). @@ -473,6 +486,40 @@ func computeCommittedStatus(prev *alertingnotifv0alpha1.ConfigStatus, uid string return buildSyncStatus(prev, uid, origin, alertingnotifv0alpha1.ConfigConditionStatusTrue, conditionReasonMergeCommitted, mergeCommittedMessage, now) } +// computeNotConfiguredStatus returns prev with only the ExternalAlertmanagerSynced +// condition updated to Unknown/NotConfigured (used when the flag is on but no UID +// is configured). Everything else rides through unchanged: observedGeneration, +// externalAlertmanagerSync (kept as the last-attempt context, its documented +// meaning) and any sibling conditions. The Synced condition is a current-state +// snapshot — no preserved MergeCommitted/SyncSucceeded — and its lastTransitionTime +// advances only on a flip to Unknown, so consecutive not-configured ticks produce +// an identical status that dedups to no write. +func computeNotConfiguredStatus(prev *alertingnotifv0alpha1.ConfigStatus, now time.Time) alertingnotifv0alpha1.ConfigStatus { + st := alertingnotifv0alpha1.ConfigStatus{} + if prev != nil { + st = *prev + st.Conditions = append([]alertingnotifv0alpha1.ConfigCondition(nil), prev.Conditions...) + } + + synced := alertingnotifv0alpha1.ConfigCondition{ + Type: conditionTypeExternalAlertmanagerSynced, + Status: alertingnotifv0alpha1.ConfigConditionStatusUnknown, + LastTransitionTime: now.UTC().Format(time.RFC3339), + Reason: conditionReasonNotConfigured, + } + for i, c := range st.Conditions { + if c.Type == conditionTypeExternalAlertmanagerSynced { + if c.Status == alertingnotifv0alpha1.ConfigConditionStatusUnknown { + synced.LastTransitionTime = c.LastTransitionTime // no flip → keep the timestamp + } + st.Conditions[i] = synced + return st + } + } + st.Conditions = append(st.Conditions, synced) + return st +} + // buildSyncStatus folds an ExternalAlertmanagerSynced condition into prev. k8s // condition FSM: lastTransitionTime advances only on status flip. Preserves // other condition types so future controllers aren't clobbered. diff --git a/pkg/services/ngalert/notifier/external_am_syncer_status_test.go b/pkg/services/ngalert/notifier/external_am_syncer_status_test.go index dcfb20d4cf946..8be9ee6be9a7e 100644 --- a/pkg/services/ngalert/notifier/external_am_syncer_status_test.go +++ b/pkg/services/ngalert/notifier/external_am_syncer_status_test.go @@ -307,3 +307,134 @@ func TestComputeCommittedStatus(t *testing.T) { assert.Equal(t, nowRFC, c.LastTransitionTime, "flip False->True advances the timestamp") }) } + +func TestComputeNotConfiguredStatus(t *testing.T) { + now := time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC) + earlier := time.Date(2026, 5, 19, 9, 0, 0, 0, time.UTC) + nowRFC := now.UTC().Format(time.RFC3339) + earlRFC := earlier.UTC().Format(time.RFC3339) + + findSynced := func(t *testing.T, st alertingnotifv0alpha1.ConfigStatus) alertingnotifv0alpha1.ConfigCondition { + t.Helper() + for _, c := range st.Conditions { + if c.Type == conditionTypeExternalAlertmanagerSynced { + return c + } + } + t.Fatalf("expected Synced condition, got: %+v", st.Conditions) + return alertingnotifv0alpha1.ConfigCondition{} + } + + t.Run("from clean state emits Synced=Unknown/NotConfigured with current timestamp", func(t *testing.T) { + got := computeNotConfiguredStatus(nil, now) + + synced := findSynced(t, got) + assert.Equal(t, alertingnotifv0alpha1.ConfigConditionStatusUnknown, synced.Status) + assert.Equal(t, conditionReasonNotConfigured, synced.Reason) + assert.Equal(t, nowRFC, synced.LastTransitionTime) + assert.Nil(t, synced.Message) + assert.Nil(t, got.ExternalAlertmanagerSync, "no datasource context when unconfigured") + }) + + t.Run("after a prior success flips to Unknown and advances lastTransitionTime", func(t *testing.T) { + prev := &alertingnotifv0alpha1.ConfigStatus{ + Conditions: []alertingnotifv0alpha1.ConfigCondition{{ + Type: conditionTypeExternalAlertmanagerSynced, + Status: alertingnotifv0alpha1.ConfigConditionStatusTrue, + LastTransitionTime: earlRFC, + Reason: conditionReasonSyncSucceeded, + }}, + } + + synced := findSynced(t, computeNotConfiguredStatus(prev, now)) + assert.Equal(t, alertingnotifv0alpha1.ConfigConditionStatusUnknown, synced.Status) + assert.Equal(t, conditionReasonNotConfigured, synced.Reason) + assert.Equal(t, nowRFC, synced.LastTransitionTime, "flip True->Unknown advances the timestamp") + }) + + t.Run("consecutive not-configured ticks preserve lastTransitionTime", func(t *testing.T) { + prev := &alertingnotifv0alpha1.ConfigStatus{ + Conditions: []alertingnotifv0alpha1.ConfigCondition{{ + Type: conditionTypeExternalAlertmanagerSynced, + Status: alertingnotifv0alpha1.ConfigConditionStatusUnknown, + LastTransitionTime: earlRFC, + Reason: conditionReasonNotConfigured, + }}, + } + + synced := findSynced(t, computeNotConfiguredStatus(prev, now)) + assert.Equal(t, earlRFC, synced.LastTransitionTime, "status stayed Unknown, so the timestamp is preserved") + }) + + t.Run("a prior MergeCommitted flips to Unknown when the UID is removed", func(t *testing.T) { + // No special-case: when sync is no longer configured the Synced condition is + // a current-state snapshot, so even a terminal MergeCommitted flips. The merge + // artifact still lives in the AM config; this condition only tracks sync. + prev := &alertingnotifv0alpha1.ConfigStatus{ + Conditions: []alertingnotifv0alpha1.ConfigCondition{{ + Type: conditionTypeExternalAlertmanagerSynced, + Status: alertingnotifv0alpha1.ConfigConditionStatusTrue, + LastTransitionTime: earlRFC, + Reason: conditionReasonMergeCommitted, + }}, + } + + synced := findSynced(t, computeNotConfiguredStatus(prev, now)) + assert.Equal(t, alertingnotifv0alpha1.ConfigConditionStatusUnknown, synced.Status) + assert.Equal(t, conditionReasonNotConfigured, synced.Reason) + assert.Equal(t, nowRFC, synced.LastTransitionTime, "flip True->Unknown advances the timestamp") + }) + + t.Run("root fields ride through unchanged", func(t *testing.T) { + gen := int64(7) + uidLast := "uid-last" + originLast := originAPI + prev := &alertingnotifv0alpha1.ConfigStatus{ + ObservedGeneration: &gen, + ExternalAlertmanagerSync: &alertingnotifv0alpha1.ConfigV0alpha1StatusExternalAlertmanagerSync{ + DatasourceUid: &uidLast, + Origin: &originLast, + }, + Conditions: []alertingnotifv0alpha1.ConfigCondition{{ + Type: conditionTypeExternalAlertmanagerSynced, + Status: alertingnotifv0alpha1.ConfigConditionStatusTrue, + LastTransitionTime: earlRFC, + Reason: conditionReasonSyncSucceeded, + }}, + } + + got := computeNotConfiguredStatus(prev, now) + // observedGeneration and the last-attempt externalAlertmanagerSync context are + // preserved; only the Synced condition flips. + require.NotNil(t, got.ObservedGeneration) + assert.Equal(t, gen, *got.ObservedGeneration) + require.NotNil(t, got.ExternalAlertma ... [truncated]
← Back to Alerts View on GitHub →