diff --git a/pkg/registry/apis/secret/contracts/decrypt.go b/pkg/registry/apis/secret/contracts/decrypt.go new file mode 100644 index 00000000000..a636f350efd --- /dev/null +++ b/pkg/registry/apis/secret/contracts/decrypt.go @@ -0,0 +1,28 @@ +package contracts + +import ( + "context" + "errors" + + secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" +) + +var ( + ErrDecryptNotFound = errors.New("not found") + ErrDecryptNotAuthorized = errors.New("not authorized") + ErrDecryptFailed = errors.New("decryption failed") +) + +// DecryptStorage is the interface for wiring and dependency injection. +type DecryptStorage interface { + Decrypt(ctx context.Context, namespace xkube.Namespace, name string) (secretv0alpha1.ExposedSecureValue, error) +} + +// DecryptAuthorizer is the interface for authorizing decryption requests. +type DecryptAuthorizer interface { + Authorize(ctx context.Context, secureValueDecrypters []string) (identity string, allowed bool) +} + +// TEMPORARY: Needed to pass it with wire. +type DecryptAllowList map[string]struct{} diff --git a/pkg/registry/apis/secret/decrypt/allow_list.go b/pkg/registry/apis/secret/decrypt/allow_list.go new file mode 100644 index 00000000000..9f3e34ec71a --- /dev/null +++ b/pkg/registry/apis/secret/decrypt/allow_list.go @@ -0,0 +1,8 @@ +package decrypt + +import "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + +// TEMPORARY +func ProvideDecryptAllowList() contracts.DecryptAllowList { + return make(map[string]struct{}, 0) +} diff --git a/pkg/registry/apis/secret/decrypt/authorizer.go b/pkg/registry/apis/secret/decrypt/authorizer.go new file mode 100644 index 00000000000..9e12552bba1 --- /dev/null +++ b/pkg/registry/apis/secret/decrypt/authorizer.go @@ -0,0 +1,91 @@ +package decrypt + +import ( + "context" + "strings" + + claims "github.com/grafana/authlib/types" + + secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" +) + +// decryptAuthorizer is the authorizer implementation for decrypt operations. +type decryptAuthorizer struct { + allowList contracts.DecryptAllowList +} + +func ProvideDecryptAuthorizer(allowList contracts.DecryptAllowList) contracts.DecryptAuthorizer { + return &decryptAuthorizer{ + allowList: allowList, + } +} + +// authorize checks whether the auth info token has the right permissions to decrypt the secure value. +func (a *decryptAuthorizer) Authorize(ctx context.Context, secureValueDecrypters []string) (string, bool) { + authInfo, ok := claims.AuthInfoFrom(ctx) + if !ok { + return "", false + } + + tokenPermissions := authInfo.GetTokenPermissions() + + tokenActors := make(map[string]struct{}, 0) + for _, permission := range tokenPermissions { + // Will look like `secret.grafana.app/securevalues/:decrypt` for now. + gr, verb, found := strings.Cut(permission, ":") + if !found { + continue + } + + // If it isn't decrypt, then we don't care to check. + if verb != "decrypt" { + continue + } + + parts := strings.Split(gr, "/") + if len(parts) != 3 { + continue + } + + group, resource, actor := parts[0], parts[1], parts[2] + if group != secretv0alpha1.GROUP || resource != secretv0alpha1.SecureValuesResourceInfo.GetName() || actor == "" { + continue + } + + // TEMPORARY: while we can't onboard every app into secrets, we can block them from decrypting + // securevalues preemptively here before even reaching out to the database. + // This check can be removed once we open the gates for any service to use secrets. + if _, exists := a.allowList[actor]; !exists { + continue + } + + tokenActors[actor] = struct{}{} + } + + // If we arrived here and the token actors is empty, it means the permissions either have an invalid format, + // or it didn't pass the allow list, meaning no allowed decryptor. + if len(tokenActors) == 0 { + return "", false + } + + // TEMPORARY: while we still need to mix permission and identity, we can use this + // to decide whether the SecureValue can be decrypted or not. + // Once we have an `actor` field in the JWT claims, we can have a properly formatted permission, + // like `secret.grafana.app/securevalues{/}:decrypt` and do regular access control eval, + // and for the `decrypters` part here, we can just check it against the `actor` field, which at + // that point will have a different format, depending on how the `actor` will be formatted. + // Check whether at least one of declared token actors matches the allowed decrypters from the SecureValue. + allowed := false + + var identity string + for _, decrypter := range secureValueDecrypters { + if _, exists := tokenActors[decrypter]; exists { + allowed = true + identity = decrypter + break + } + } + + return identity, allowed +} diff --git a/pkg/registry/apis/secret/decrypt/authorizer_test.go b/pkg/registry/apis/secret/decrypt/authorizer_test.go new file mode 100644 index 00000000000..9859f7a64c9 --- /dev/null +++ b/pkg/registry/apis/secret/decrypt/authorizer_test.go @@ -0,0 +1,142 @@ +package decrypt + +import ( + "context" + "testing" + + "github.com/grafana/authlib/authn" + "github.com/grafana/authlib/types" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/apimachinery/identity" +) + +func TestDecryptAuthorizer(t *testing.T) { + t.Run("when no auth info is present, it returns false", func(t *testing.T) { + ctx := context.Background() + authorizer := ProvideDecryptAuthorizer(nil) + + identity, allowed := authorizer.Authorize(ctx, nil) + require.Empty(t, identity) + require.False(t, allowed) + }) + + t.Run("when token permissions are empty, it returns false", func(t *testing.T) { + ctx := createAuthContext(context.Background(), []string{}) + authorizer := ProvideDecryptAuthorizer(nil) + + identity, allowed := authorizer.Authorize(ctx, nil) + require.Empty(t, identity) + require.False(t, allowed) + }) + + t.Run("when permission format is malformed (missing verb), it returns false", func(t *testing.T) { + ctx := createAuthContext(context.Background(), []string{"secret.grafana.app/securevalues/group1"}) + authorizer := ProvideDecryptAuthorizer(nil) + + identity, allowed := authorizer.Authorize(ctx, nil) + require.Empty(t, identity) + require.False(t, allowed) + }) + + t.Run("when permission verb is not exactly `decrypt`, it returns false", func(t *testing.T) { + ctx := createAuthContext(context.Background(), []string{"secret.grafana.app/securevalues/group1:something"}) + authorizer := ProvideDecryptAuthorizer(nil) + + identity, allowed := authorizer.Authorize(ctx, nil) + require.Empty(t, identity) + require.False(t, allowed) + }) + + t.Run("when permission does not have 3 parts, it returns false", func(t *testing.T) { + ctx := createAuthContext(context.Background(), []string{"secret.grafana.app/securevalues:decrypt"}) + authorizer := ProvideDecryptAuthorizer(nil) + + identity, allowed := authorizer.Authorize(ctx, nil) + require.Empty(t, identity) + require.False(t, allowed) + }) + + t.Run("when permission has group that is not `secret.grafana.app`, it returns false", func(t *testing.T) { + ctx := createAuthContext(context.Background(), []string{"wrong.group/securevalues/invalid:decrypt"}) + authorizer := ProvideDecryptAuthorizer(nil) + + identity, allowed := authorizer.Authorize(ctx, nil) + require.Empty(t, identity) + require.False(t, allowed) + }) + + t.Run("when permission has resource that is not `securevalues`, it returns false", func(t *testing.T) { + ctx := createAuthContext(context.Background(), []string{"secret.grafana.app/invalid-resource/invalid:decrypt"}) + authorizer := ProvideDecryptAuthorizer(nil) + + identity, allowed := authorizer.Authorize(ctx, nil) + require.Empty(t, identity) + require.False(t, allowed) + }) + + t.Run("when the actor is not in the allow list, it returns false", func(t *testing.T) { + ctx := createAuthContext(context.Background(), []string{"secret.grafana.app/securevalues/allowed2:decrypt"}) + authorizer := ProvideDecryptAuthorizer(map[string]struct{}{"allowed1": {}}) + + identity, allowed := authorizer.Authorize(ctx, nil) + require.Empty(t, identity) + require.False(t, allowed) + }) + + t.Run("when the actor doesn't match any allowed decrypters, it returns false", func(t *testing.T) { + ctx := createAuthContext(context.Background(), []string{"secret.grafana.app/securevalues/group1:decrypt"}) + authorizer := ProvideDecryptAuthorizer(map[string]struct{}{"group1": {}}) + + identity, allowed := authorizer.Authorize(ctx, []string{"group2"}) + require.Empty(t, identity) + require.False(t, allowed) + }) + + t.Run("when the actor matches an allowed decrypter, it returns true", func(t *testing.T) { + ctx := createAuthContext(context.Background(), []string{"secret.grafana.app/securevalues/group1:decrypt"}) + authorizer := ProvideDecryptAuthorizer(map[string]struct{}{"group1": {}}) + + identity, allowed := authorizer.Authorize(ctx, []string{"group1"}) + require.True(t, allowed) + require.Equal(t, "group1", identity) + }) + + t.Run("when there are multiple permissions, some invalid, only valid ones are considered", func(t *testing.T) { + ctx := createAuthContext(context.Background(), []string{ + "secret.grafana.app/securevalues/group1:decrypt", + "secret.grafana.app/securevalues/invalid:read", + "wrong.group/securevalues/group2:decrypt", + "secret.grafana.app/securevalues/group2:decrypt", + }) + authorizer := ProvideDecryptAuthorizer(map[string]struct{}{"group1": {}, "group2": {}}) + + identity, allowed := authorizer.Authorize(ctx, []string{"group2", "group3"}) + require.True(t, allowed) + require.Equal(t, "group2", identity) + }) + + t.Run("when multiple valid actors match decrypters, the first match already returns true", func(t *testing.T) { + ctx := createAuthContext(context.Background(), []string{ + "secret.grafana.app/securevalues/group1:decrypt", + "secret.grafana.app/securevalues/group2:decrypt", + }) + authorizer := ProvideDecryptAuthorizer(map[string]struct{}{"group1": {}, "group2": {}}) + + identity, allowed := authorizer.Authorize(ctx, []string{"group2", "group1"}) + require.True(t, allowed) + require.Equal(t, "group2", identity) + }) +} + +func createAuthContext(ctx context.Context, permissions []string) context.Context { + requester := &identity.StaticRequester{ + AccessTokenClaims: &authn.Claims[authn.AccessTokenClaims]{ + Rest: authn.AccessTokenClaims{ + Permissions: permissions, + }, + }, + } + + return types.WithAuthInfo(ctx, requester) +} diff --git a/pkg/registry/apis/secret/decrypt/noop_authorizer.go b/pkg/registry/apis/secret/decrypt/noop_authorizer.go new file mode 100644 index 00000000000..d62af2f78b8 --- /dev/null +++ b/pkg/registry/apis/secret/decrypt/noop_authorizer.go @@ -0,0 +1,16 @@ +package decrypt + +import ( + "context" + + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" +) + +// NoopAlwaysAllowedAuthorizer is a no-op implementation of the DecryptAuthorizer which always returns `allowed=true`. +type NoopAlwaysAllowedAuthorizer struct{} + +var _ contracts.DecryptAuthorizer = &NoopAlwaysAllowedAuthorizer{} + +func (a *NoopAlwaysAllowedAuthorizer) Authorize(ctx context.Context, secureValueDecrypters []string) (identity string, allowed bool) { + return "", true +} diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 5dd28d8a829..dea276a323d 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -41,6 +41,7 @@ import ( apiregistry "github.com/grafana/grafana/pkg/registry/apis" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github" + secretdecrypt "github.com/grafana/grafana/pkg/registry/apis/secret/decrypt" appregistry "github.com/grafana/grafana/pkg/registry/apps" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" @@ -418,6 +419,8 @@ var wireBasicSet = wire.NewSet( // Secrets Manager secretmetadata.ProvideSecureValueMetadataStorage, secretmetadata.ProvideKeeperMetadataStorage, + secretdecrypt.ProvideDecryptAuthorizer, + secretdecrypt.ProvideDecryptAllowList, // Unified storage resource.ProvideStorageMetrics, resource.ProvideIndexMetrics,