Compare commits

...

2 Commits

Author SHA1 Message Date
Matheus Macabu
faf366b2fa checkpoint: reuse logger infra 2025-12-11 13:01:00 +01:00
Matheus Macabu
6bce5f2d73 poc audit logging api 2025-12-11 10:32:01 +01:00
12 changed files with 313 additions and 12 deletions

View File

@@ -0,0 +1,89 @@
package auditing
import (
"encoding/json"
"time"
)
type Event struct {
// The namespace the action was performed in.
Namespace string `json:"namespace"`
// When it happened.
ObservedAt time.Time `json:"observedAt"`
// Who/what performed the action.
SubjectName string `json:"subjectName"`
SubjectUID string `json:"subjectUID"`
// What was performed.
Verb string `json:"verb"`
// The object the action was performed on. For verbs like "list" this will be empty.
Object string `json:"object,omitempty"`
// API information.
APIGroup string `json:"apiGroup,omitempty"`
APIVersion string `json:"apiVersion,omitempty"`
Kind string `json:"kind,omitempty"`
// Outcome of the action.
Outcome EventOutcome `json:"outcome"`
// Extra fields to add more context to the event.
Extra map[string]string `json:"extra,omitempty"`
}
var _ Sinkable = &Event{}
func (e Event) Time() time.Time {
return e.ObservedAt
}
func (e Event) MarshalJSON() ([]byte, error) {
type Alias Event
return json.Marshal(&struct {
FormattedTimestamp string `json:"timestamp"`
Alias
}{
FormattedTimestamp: e.ObservedAt.UTC().Format(time.RFC3339Nano),
Alias: (Alias)(e),
})
}
func (e Event) KVPairs() []any {
args := []any{
"audit", true,
"namespace", e.Namespace,
"observedAt", e.ObservedAt.UTC().Format(time.RFC3339Nano),
"subjectName", e.SubjectName,
"subjectUID", e.SubjectUID,
"verb", e.Verb,
"object", e.Object,
"apiGroup", e.APIGroup,
"apiVersion", e.APIVersion,
"kind", e.Kind,
"outcome", e.Outcome,
}
if len(e.Extra) > 0 {
extraArgs := make([]any, 0, len(e.Extra)*2)
for k, v := range e.Extra {
extraArgs = append(extraArgs, "extra_"+k, v)
}
args = append(args, extraArgs...)
}
return args
}
type EventOutcome string
const (
EventOutcomeUnknown EventOutcome = "unknown"
EventOutcomeSuccess EventOutcome = "success"
EventOutcomeFailureUnauthorized EventOutcome = "failure_unauthorized"
EventOutcomeFailureNotFound EventOutcome = "failure_not_found"
EventOutcomeFailureGeneric EventOutcome = "failure_generic"
)

View File

@@ -0,0 +1,45 @@
package auditing
import (
"context"
"encoding/json"
"time"
)
type loggerContextKey struct{}
var (
DefaultLogger Logger
contextKey = loggerContextKey{}
)
type Logger interface {
Log(event Sinkable) error
Type() string
Close() error
}
type Sinkable interface {
json.Marshaler
KVPairs() []any
Time() time.Time
}
func FromContext(ctx context.Context) Logger {
if l := ctx.Value(contextKey); l != nil {
if logger, ok := l.(Logger); ok {
return logger
}
}
if DefaultLogger != nil {
return DefaultLogger
}
return &NoopLogger{}
}
func Context(ctx context.Context, logger Logger) context.Context {
return context.WithValue(ctx, contextKey, logger)
}

View File

@@ -0,0 +1,18 @@
package auditing
import (
auditinternal "k8s.io/apiserver/pkg/apis/audit"
"k8s.io/apiserver/pkg/audit"
)
type NoopBackend struct{}
func ProvideNoopBackend() audit.Backend { return &NoopBackend{} }
func (b *NoopBackend) ProcessEvents(k8sEvents ...*auditinternal.Event) bool { return false }
func (NoopBackend) Run(stopCh <-chan struct{}) error { return nil }
func (NoopBackend) Shutdown() {}
func (NoopBackend) String() string { return "" }

View File

@@ -0,0 +1,9 @@
package auditing
type NoopLogger struct{}
func (*NoopLogger) Log(Sinkable) error { return nil }
func (*NoopLogger) Type() string { return "noop" }
func (*NoopLogger) Close() error { return nil }

View File

@@ -0,0 +1,99 @@
package auditing
import (
"slices"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"k8s.io/apimachinery/pkg/runtime/schema"
auditinternal "k8s.io/apiserver/pkg/apis/audit"
"k8s.io/apiserver/pkg/audit"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
)
// PolicyRuleEvaluator alias for easier imports.
type PolicyRuleEvaluator = audit.PolicyRuleEvaluator
// UnionPolicyRuleEvaluator dispatches to the specific PolicyRuleEvaluator depending on the API group+version in the request.
type UnionPolicyRuleEvaluator struct {
evaluators map[schema.GroupVersion]PolicyRuleEvaluator
}
var _ PolicyRuleEvaluator = &UnionPolicyRuleEvaluator{}
func NewUnionPolicyRuleEvaluator(builders []builder.APIGroupBuilder) *UnionPolicyRuleEvaluator {
policyRuleEvaluators := make(map[schema.GroupVersion]audit.PolicyRuleEvaluator, 0)
for _, b := range builders {
auditor, ok := b.(builder.APIGroupAuditor)
if !ok {
continue
}
policyRuleEvaluator := auditor.GetPolicyRuleEvaluator()
if policyRuleEvaluator == nil {
continue
}
for _, gv := range builder.GetGroupVersions(b) {
if gv.Empty() {
continue
}
policyRuleEvaluators[gv] = policyRuleEvaluator
}
}
return &UnionPolicyRuleEvaluator{policyRuleEvaluators}
}
func (e *UnionPolicyRuleEvaluator) EvaluatePolicyRule(attrs authorizer.Attributes) audit.RequestAuditConfig {
evaluator, ok := e.evaluators[schema.GroupVersion{Group: attrs.GetAPIGroup(), Version: attrs.GetAPIVersion()}]
if !ok {
return audit.RequestAuditConfig{
Level: auditinternal.LevelNone,
}
}
return evaluator.EvaluatePolicyRule(attrs)
}
// DefaultGrafanaPolicyRuleEvaluator provides a sane default configuration for audit logging for API group+versions.
// It logs all resource requests (at the `ResponseComplete` stage) except for watch requests.
type defaultGrafanaPolicyRuleEvaluator struct{}
var _ PolicyRuleEvaluator = &defaultGrafanaPolicyRuleEvaluator{}
func NewDefaultGrafanaPolicyRuleEvaluator() defaultGrafanaPolicyRuleEvaluator {
return defaultGrafanaPolicyRuleEvaluator{}
}
func (defaultGrafanaPolicyRuleEvaluator) EvaluatePolicyRule(attrs authorizer.Attributes) audit.RequestAuditConfig {
// Skip non-resource and watch requests otherwise it is too noisy.
if !attrs.IsResourceRequest() || attrs.GetVerb() == utils.VerbWatch {
return audit.RequestAuditConfig{
Level: auditinternal.LevelNone,
}
}
// Skip auditing if the user is part of the privileged group.
// The loopback client uses this group, so requests initiated in `/api/` would be duplicated.
if u := attrs.GetUser(); u != nil && slices.Contains(u.GetGroups(), user.SystemPrivilegedGroup) {
return audit.RequestAuditConfig{
Level: auditinternal.LevelNone,
}
}
return audit.RequestAuditConfig{
Level: auditinternal.LevelMetadata,
OmitStages: []auditinternal.Stage{
// Only log on StageResponseComplete
auditinternal.StageRequestReceived,
auditinternal.StageResponseStarted,
auditinternal.StagePanic,
},
// Keep this not because we use it but because it avoids extra copying/unmarshalling.
OmitManagedFields: false,
}
}

View File

@@ -37,6 +37,7 @@ import (
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apiserver/auditing"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/tracing"
@@ -1011,3 +1012,7 @@ func (b *DashboardsAPIBuilder) verifyFolderAccessPermissions(ctx context.Context
return nil
}
func (b *DashboardsAPIBuilder) GetPolicyRuleEvaluator() auditing.PolicyRuleEvaluator {
return auditing.NewDefaultGrafanaPolicyRuleEvaluator()
}

View File

@@ -25,6 +25,7 @@ import (
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
"github.com/grafana/grafana/apps/iam/pkg/reconcilers"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apiserver/auditing"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
@@ -43,6 +44,7 @@ import (
var _ builder.APIGroupBuilder = (*FolderAPIBuilder)(nil)
var _ builder.APIGroupValidation = (*FolderAPIBuilder)(nil)
var _ builder.APIGroupAuditor = (*FolderAPIBuilder)(nil)
var resourceInfo = folders.FolderResourceInfo
@@ -361,3 +363,7 @@ func (b *FolderAPIBuilder) Validate(ctx context.Context, a admission.Attributes,
return nil
}
}
func (b *FolderAPIBuilder) GetPolicyRuleEvaluator() auditing.PolicyRuleEvaluator {
return auditing.NewDefaultGrafanaPolicyRuleEvaluator()
}

View File

@@ -3,6 +3,7 @@ package apiregistry
import (
"github.com/google/wire"
"github.com/grafana/grafana/pkg/apiserver/auditing"
"github.com/grafana/grafana/pkg/registry/apis/collections"
dashboardinternal "github.com/grafana/grafana/pkg/registry/apis/dashboard"
"github.com/grafana/grafana/pkg/registry/apis/datasource"
@@ -33,6 +34,8 @@ var WireSetExts = wire.NewSet(
externalgroupmapping.ProvideNoopTeamGroupsREST,
wire.Bind(new(externalgroupmapping.TeamGroupsHandler), new(*externalgroupmapping.NoopTeamGroupsREST)),
auditing.ProvideNoopBackend,
)
var provisioningExtras = wire.NewSet(

View File

@@ -14,6 +14,7 @@ import (
"github.com/grafana/grafana/pkg/api"
"github.com/grafana/grafana/pkg/api/avatar"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/apiserver/auditing"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/configprovider"
"github.com/grafana/grafana/pkg/expr"
@@ -831,7 +832,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
}
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller, quotasAppInstaller)
builderMetrics := builder.ProvideBuilderMetrics(registerer)
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics)
backend := auditing.ProvideNoopBackend()
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics, backend)
if err != nil {
return nil, err
}
@@ -1489,7 +1491,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
}
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller, quotasAppInstaller)
builderMetrics := builder.ProvideBuilderMetrics(registerer)
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics)
backend := auditing.ProvideNoopBackend()
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics, backend)
if err != nil {
return nil, err
}

View File

@@ -9,6 +9,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/audit"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/generic"
genericapiserver "k8s.io/apiserver/pkg/server"
@@ -59,6 +60,10 @@ type APIGroupAuthorizer interface {
GetAuthorizer() authorizer.Authorizer
}
type APIGroupAuditor interface {
GetPolicyRuleEvaluator() audit.PolicyRuleEvaluator
}
type APIGroupMutation interface {
// Mutate allows the builder to make changes to the object before it is persisted.
// Context is used only for timeout/deadline/cancellation and tracing information.

View File

@@ -12,6 +12,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apiserver/pkg/audit"
genericapifilters "k8s.io/apiserver/pkg/endpoints/filters"
"k8s.io/apiserver/pkg/endpoints/responsewriter"
genericapiserver "k8s.io/apiserver/pkg/server"
@@ -27,6 +28,7 @@ import (
dataplaneaggregator "github.com/grafana/grafana/pkg/aggregator/apiserver"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/apimachinery/identity"
grafanaauditing "github.com/grafana/grafana/pkg/apiserver/auditing"
grafanaresponsewriter "github.com/grafana/grafana/pkg/apiserver/endpoints/responsewriter"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/db"
@@ -113,6 +115,7 @@ type service struct {
appInstallers []appsdkapiserver.AppInstaller
builderMetrics *builder.BuilderMetrics
dualWriterMetrics *grafanarest.DualWriterMetrics
auditBackend audit.Backend
}
func ProvideService(
@@ -137,6 +140,7 @@ func ProvideService(
aggregatorRunner aggregatorrunner.AggregatorRunner,
appInstallers []appsdkapiserver.AppInstaller,
builderMetrics *builder.BuilderMetrics,
auditBackend audit.Backend,
) (*service, error) {
scheme := builder.ProvideScheme()
codecs := builder.ProvideCodecFactory(scheme)
@@ -167,6 +171,7 @@ func ProvideService(
appInstallers: appInstallers,
builderMetrics: builderMetrics,
dualWriterMetrics: grafanarest.NewDualWriterMetrics(reg),
auditBackend: auditBackend,
}
// This will be used when running as a dskit service
s.NamedService = services.NewBasicService(s.start, s.running, nil).WithName(modules.GrafanaAPIServer)
@@ -355,6 +360,11 @@ func (s *service) start(ctx context.Context) error {
appinstaller.BuildOpenAPIDefGetter(s.appInstallers),
}
if s.auditBackend != nil {
serverConfig.AuditBackend = s.auditBackend
serverConfig.AuditPolicyRuleEvaluator = grafanaauditing.NewUnionPolicyRuleEvaluator(builders)
}
// Add OpenAPI specs for each group+version (existing builders)
err = builder.SetupConfig(
s.scheme,

View File

@@ -16,9 +16,9 @@ import (
"github.com/grafana/authlib/authn"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana-app-sdk/logging"
secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1"
"github.com/grafana/grafana/pkg/apiserver/auditing"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
"github.com/grafana/grafana/pkg/storage/secret/metadata/metrics"
@@ -79,11 +79,7 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace,
span.SetAttributes(attribute.String("decrypter.identity", decrypterIdentity))
args := []any{
"namespace", namespace.String(),
"secret_name", name,
"decrypter_identity", decrypterIdentity,
}
auditExtra := make(map[string]string)
// The service identity used for decryption is always what is from the signed token, but if the request is
// coming from grafana, the service identity will be grafana, but the request metadata will contain
@@ -91,23 +87,36 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace,
// we do this for auditing purposes.
if md, ok := metadata.FromIncomingContext(ctx); ok {
if svcIdentities := md.Get(contracts.HeaderGrafanaServiceIdentityName); len(svcIdentities) > 0 {
args = append(args, "grafana_decrypter_identity", svcIdentities[0])
auditExtra["grafana_decrypter_identity"] = svcIdentities[0]
span.SetAttributes(attribute.String("grafana_decrypter.identity", svcIdentities[0]))
}
}
decryptResultLabel := metrics.DecryptResultLabel(decryptErr)
eventOutcome := auditing.EventOutcomeSuccess
if decryptErr == nil {
span.SetStatus(codes.Ok, "Decrypt succeeded")
args = append(args, "operation", "decrypt_secret_success")
} else {
eventOutcome = auditing.EventOutcomeFailureGeneric
auditExtra["decrypt_result"] = decryptResultLabel
span.SetStatus(codes.Error, "Decrypt failed")
span.RecordError(decryptErr)
args = append(args, "operation", "decrypt_secret_error", "error", decryptErr.Error(), "result", decryptResultLabel)
}
logging.FromContext(ctx).Info("Secrets Audit Log", args...)
auditing.FromContext(ctx).Log(auditing.Event{
Namespace: namespace.String(),
ObservedAt: time.Now(),
SubjectName: decrypterIdentity,
SubjectUID: decrypterIdentity,
Verb: "decrypt",
Object: name,
APIGroup: secretv1beta1.APIGroup,
APIVersion: secretv1beta1.APIVersion,
Kind: secretv1beta1.SecureValueKind().Kind(),
Outcome: eventOutcome,
Extra: auditExtra,
})
s.metrics.DecryptDuration.WithLabelValues(decryptResultLabel, cmp.Or(decrypterIdentity, "unknown")).Observe(time.Since(start).Seconds())