diff --git a/apps/provisioning/pkg/auth/access_checker.go b/apps/provisioning/pkg/auth/access_checker.go new file mode 100644 index 00000000000..72f34d7c92c --- /dev/null +++ b/apps/provisioning/pkg/auth/access_checker.go @@ -0,0 +1,147 @@ +package auth + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + + authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" +) + +// AccessChecker provides access control checks with mode-aware behavior. +// It encapsulates the differences between multi-tenant (MT) and single-tenant (ST) modes: +// - MT mode: uses AuthInfo from access tokens, no role-based fallback +// - ST mode: uses Requester from Grafana sessions, optional role-based fallback +type AccessChecker interface { + // Check performs an access check and returns nil if allowed, or an appropriate + // API error if denied. Behavior depends on the mode: + // - MT mode: gets identity from AuthInfoFrom(ctx), no fallback + // - ST mode: gets identity from GetRequester(ctx), applies fallback if configured + // If req.Namespace is empty, it will be filled from the identity's namespace. + Check(ctx context.Context, req authlib.CheckRequest, folder string) error + + // WithFallback returns a new AccessChecker configured with the specified fallback role. + // The fallback is only applied in ST mode. + WithFallback(role identity.RoleType) AccessChecker +} + +// accessChecker implements AccessChecker by wrapping authlib.AccessChecker. +type accessChecker struct { + inner authlib.AccessChecker + multiTenant bool + fallbackRole identity.RoleType +} + +// NewAccessChecker creates an AccessChecker with mode-aware behavior. +// +// Parameters: +// - inner: the underlying authlib.AccessChecker to delegate to +// - multiTenant: when true (MT), uses AuthInfoFrom and no fallback; +// when false (ST), uses GetRequester and applies fallback if configured +func NewAccessChecker(inner authlib.AccessChecker, multiTenant bool) AccessChecker { + return &accessChecker{ + inner: inner, + multiTenant: multiTenant, + fallbackRole: "", // no fallback by default + } +} + +// WithFallback returns a new AccessChecker with the specified fallback role. +// The fallback role is only applied in ST mode. +func (c *accessChecker) WithFallback(role identity.RoleType) AccessChecker { + return &accessChecker{ + inner: c.inner, + multiTenant: c.multiTenant, + fallbackRole: role, + } +} + +// Check performs an access check with mode-aware identity resolution and fallback. +// Returns nil if access is allowed, or an appropriate API error if denied. +func (c *accessChecker) Check(ctx context.Context, req authlib.CheckRequest, folder string) error { + // Get identity based on mode + id, err := c.getIdentity(ctx) + if err != nil { + return apierrors.NewUnauthorized(err.Error()) + } + + // AccessPolicy identities are trusted internal callers (ST->MT flow) + if authlib.IsIdentityType(id.GetIdentityType(), authlib.TypeAccessPolicy) { + return nil + } + + // Fill in namespace from identity if not provided + if req.Namespace == "" { + req.Namespace = id.GetNamespace() + } + + // Perform the access check + rsp, err := c.inner.Check(ctx, id, req, folder) + + // Build the GroupResource for error messages + gr := schema.GroupResource{Group: req.Group, Resource: req.Resource} + + // In MT mode or no fallback configured, return result directly + if c.multiTenant || c.fallbackRole == "" { + if err != nil { + return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("access check failed: %w", err)) + } + if !rsp.Allowed { + return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("permission denied")) + } + return nil + } + + // ST mode with fallback: apply fallback logic + requester, ok := id.(identity.Requester) + if !ok { + // Can't apply fallback without Requester interface + if err != nil { + return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("access check failed: %w", err)) + } + if !rsp.Allowed { + return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("permission denied")) + } + return nil + } + + if err != nil { + if requester.GetOrgRole().Includes(c.fallbackRole) { + return nil // Fallback succeeded + } + return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("access check failed: %w", err)) + } + + if rsp.Allowed { + return nil + } + + // Fall back to role for backwards compatibility + if requester.GetOrgRole().Includes(c.fallbackRole) { + return nil // Fallback succeeded + } + + return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("permission denied")) +} + +// getIdentity returns the appropriate identity based on the mode. +func (c *accessChecker) getIdentity(ctx context.Context) (authlib.AuthInfo, error) { + if c.multiTenant { + // MT mode: get identity from access token in context + info, ok := authlib.AuthInfoFrom(ctx) + if !ok { + return nil, fmt.Errorf("no auth info in context for multi-tenant mode") + } + return info, nil + } + + // ST mode: get identity from Grafana requester + id, err := identity.GetRequester(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get requester: %w", err) + } + return id, nil +} diff --git a/apps/provisioning/pkg/auth/access_checker_test.go b/apps/provisioning/pkg/auth/access_checker_test.go new file mode 100644 index 00000000000..093b77ccb71 --- /dev/null +++ b/apps/provisioning/pkg/auth/access_checker_test.go @@ -0,0 +1,346 @@ +package auth + +import ( + "context" + "errors" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + + authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/user" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockAccessChecker implements authlib.AccessChecker for testing. +type mockAccessChecker struct { + response authlib.CheckResponse + err error +} + +func (m *mockAccessChecker) Check(_ context.Context, _ authlib.AuthInfo, _ authlib.CheckRequest, _ string) (authlib.CheckResponse, error) { + return m.response, m.err +} + +func (m *mockAccessChecker) Compile(_ context.Context, _ authlib.AuthInfo, _ authlib.ListRequest) (authlib.ItemChecker, authlib.Zookie, error) { + return nil, nil, nil +} + +// mockRequester implements identity.Requester for testing. +type mockRequester struct { + identity.Requester + orgRole identity.RoleType + identityType authlib.IdentityType + namespace string +} + +func (m *mockRequester) GetOrgRole() identity.RoleType { + return m.orgRole +} + +func (m *mockRequester) GetIdentityType() authlib.IdentityType { + return m.identityType +} + +func (m *mockRequester) GetNamespace() string { + return m.namespace +} + +func TestAccessChecker_Check_SingleTenant(t *testing.T) { + ctx := context.Background() + req := authlib.CheckRequest{ + Verb: "get", + Group: "provisioning.grafana.app", + Resource: "repositories", + Name: "test-repo", + Namespace: "default", + } + + tests := []struct { + name string + fallbackRole identity.RoleType + innerResponse authlib.CheckResponse + innerErr error + requester *mockRequester + expectAllow bool + }{ + { + name: "allowed by checker", + fallbackRole: identity.RoleAdmin, + innerResponse: authlib.CheckResponse{Allowed: true}, + requester: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeUser}, + expectAllow: true, + }, + { + name: "denied by checker, fallback to admin role succeeds", + fallbackRole: identity.RoleAdmin, + innerResponse: authlib.CheckResponse{Allowed: false}, + requester: &mockRequester{orgRole: identity.RoleAdmin, identityType: authlib.TypeUser}, + expectAllow: true, + }, + { + name: "denied by checker, fallback to admin role fails for viewer", + fallbackRole: identity.RoleAdmin, + innerResponse: authlib.CheckResponse{Allowed: false}, + requester: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeUser}, + expectAllow: false, + }, + { + name: "error from checker, fallback to admin role succeeds", + fallbackRole: identity.RoleAdmin, + innerErr: errors.New("access check failed"), + requester: &mockRequester{orgRole: identity.RoleAdmin, identityType: authlib.TypeUser}, + expectAllow: true, + }, + { + name: "error from checker, fallback fails for viewer", + fallbackRole: identity.RoleAdmin, + innerErr: errors.New("access check failed"), + requester: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeUser}, + expectAllow: false, + }, + { + name: "denied, editor fallback succeeds for editor", + fallbackRole: identity.RoleEditor, + innerResponse: authlib.CheckResponse{Allowed: false}, + requester: &mockRequester{orgRole: identity.RoleEditor, identityType: authlib.TypeUser}, + expectAllow: true, + }, + { + name: "denied, editor fallback fails for viewer", + fallbackRole: identity.RoleEditor, + innerResponse: authlib.CheckResponse{Allowed: false}, + requester: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeUser}, + expectAllow: false, + }, + { + name: "no fallback configured, denied stays denied", + fallbackRole: "", // no fallback + innerResponse: authlib.CheckResponse{Allowed: false}, + requester: &mockRequester{orgRole: identity.RoleAdmin, identityType: authlib.TypeUser}, + expectAllow: false, + }, + { + name: "AccessPolicy identity is always allowed", + innerResponse: authlib.CheckResponse{Allowed: false}, + requester: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeAccessPolicy}, + expectAllow: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockAccessChecker{ + response: tt.innerResponse, + err: tt.innerErr, + } + + checker := NewAccessChecker(mock, false) // ST mode + if tt.fallbackRole != "" { + checker = checker.WithFallback(tt.fallbackRole) + } + + // Add requester to context (ST mode uses GetRequester) + testCtx := identity.WithRequester(ctx, tt.requester) + + err := checker.Check(testCtx, req, "") + + if tt.expectAllow { + require.NoError(t, err) + } else { + require.Error(t, err) + assert.True(t, apierrors.IsForbidden(err), "expected Forbidden error, got: %v", err) + } + }) + } +} + +func TestAccessChecker_Check_MultiTenant(t *testing.T) { + req := authlib.CheckRequest{ + Verb: "get", + Group: "provisioning.grafana.app", + Resource: "repositories", + Name: "test-repo", + Namespace: "default", + } + + tests := []struct { + name string + fallbackRole identity.RoleType + innerResponse authlib.CheckResponse + innerErr error + authInfo authlib.AuthInfo + expectAllow bool + }{ + { + name: "allowed by checker", + fallbackRole: identity.RoleAdmin, + innerResponse: authlib.CheckResponse{Allowed: true}, + authInfo: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeUser}, + expectAllow: true, + }, + { + name: "denied by checker, no fallback even with admin role", + fallbackRole: identity.RoleAdmin, + innerResponse: authlib.CheckResponse{Allowed: false}, + authInfo: &mockRequester{orgRole: identity.RoleAdmin, identityType: authlib.TypeUser}, + expectAllow: false, // MT mode: no fallback + }, + { + name: "error from checker, no fallback even with admin role", + fallbackRole: identity.RoleAdmin, + innerErr: errors.New("access check failed"), + authInfo: &mockRequester{orgRole: identity.RoleAdmin, identityType: authlib.TypeUser}, + expectAllow: false, // MT mode: no fallback + }, + { + name: "AccessPolicy identity is always allowed", + innerResponse: authlib.CheckResponse{Allowed: false}, + authInfo: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeAccessPolicy}, + expectAllow: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockAccessChecker{ + response: tt.innerResponse, + err: tt.innerErr, + } + + checker := NewAccessChecker(mock, true) // MT mode + if tt.fallbackRole != "" { + checker = checker.WithFallback(tt.fallbackRole) + } + + // Add auth info to context (MT mode uses AuthInfoFrom) + testCtx := authlib.WithAuthInfo(context.Background(), tt.authInfo) + + err := checker.Check(testCtx, req, "") + + if tt.expectAllow { + require.NoError(t, err) + } else { + require.Error(t, err) + assert.True(t, apierrors.IsForbidden(err), "expected Forbidden error, got: %v", err) + } + }) + } +} + +func TestAccessChecker_Check_NoIdentity(t *testing.T) { + mock := &mockAccessChecker{ + response: authlib.CheckResponse{Allowed: true}, + } + + t.Run("ST mode without requester", func(t *testing.T) { + checker := NewAccessChecker(mock, false) // ST mode + err := checker.Check(context.Background(), authlib.CheckRequest{}, "") + require.Error(t, err) + assert.True(t, apierrors.IsUnauthorized(err), "expected Unauthorized error") + }) + + t.Run("MT mode without auth info", func(t *testing.T) { + checker := NewAccessChecker(mock, true) // MT mode + err := checker.Check(context.Background(), authlib.CheckRequest{}, "") + require.Error(t, err) + assert.True(t, apierrors.IsUnauthorized(err), "expected Unauthorized error") + }) +} + +func TestAccessChecker_WithFallback_ImmutableOriginal(t *testing.T) { + mock := &mockAccessChecker{ + response: authlib.CheckResponse{Allowed: false}, + } + + original := NewAccessChecker(mock, false) // ST mode + withAdmin := original.WithFallback(identity.RoleAdmin) + withEditor := original.WithFallback(identity.RoleEditor) + + ctx := identity.WithRequester(context.Background(), &mockRequester{ + orgRole: identity.RoleEditor, + identityType: authlib.TypeUser, + }) + + req := authlib.CheckRequest{} + + // Original should deny (no fallback) + err := original.Check(ctx, req, "") + require.Error(t, err, "original should deny without fallback") + + // WithAdmin should deny for editor + err = withAdmin.Check(ctx, req, "") + require.Error(t, err, "admin fallback should deny for editor") + + // WithEditor should allow for editor + err = withEditor.Check(ctx, req, "") + require.NoError(t, err, "editor fallback should allow for editor") +} + +func TestAccessChecker_WithFallback_ChainedCalls(t *testing.T) { + mock := &mockAccessChecker{ + response: authlib.CheckResponse{Allowed: false}, + } + + // Ensure chained WithFallback calls work correctly + checker := NewAccessChecker(mock, false). // ST mode + WithFallback(identity.RoleAdmin). + WithFallback(identity.RoleEditor) // This should override admin + + ctx := identity.WithRequester(context.Background(), &mockRequester{ + orgRole: identity.RoleEditor, + identityType: authlib.TypeUser, + }) + + err := checker.Check(ctx, authlib.CheckRequest{}, "") + require.NoError(t, err, "last fallback (editor) should be used") +} + +func TestAccessChecker_RealSignedInUser(t *testing.T) { + mock := &mockAccessChecker{ + response: authlib.CheckResponse{Allowed: false}, + } + + checker := NewAccessChecker(mock, false).WithFallback(identity.RoleAdmin) // ST mode + + // Use a real SignedInUser + signedInUser := &user.SignedInUser{ + UserID: 1, + OrgID: 1, + OrgRole: identity.RoleAdmin, + } + + ctx := identity.WithRequester(context.Background(), signedInUser) + + err := checker.Check(ctx, authlib.CheckRequest{}, "") + require.NoError(t, err, "admin user should be allowed via fallback") +} + +func TestAccessChecker_Check_FillsNamespace(t *testing.T) { + mock := &mockAccessChecker{ + response: authlib.CheckResponse{Allowed: true}, + } + + checker := NewAccessChecker(mock, false) // ST mode + + ctx := identity.WithRequester(context.Background(), &mockRequester{ + orgRole: identity.RoleAdmin, + identityType: authlib.TypeUser, + namespace: "org-123", + }) + + // Request without namespace + req := authlib.CheckRequest{ + Verb: "get", + Group: "provisioning.grafana.app", + Resource: "repositories", + Name: "test-repo", + // Namespace intentionally empty + } + + err := checker.Check(ctx, req, "") + require.NoError(t, err) + // The namespace should have been filled from the identity +} diff --git a/pkg/registry/apis/provisioning/files.go b/pkg/registry/apis/provisioning/files.go index fcd4a356bd9..8d8d35be56d 100644 --- a/pkg/registry/apis/provisioning/files.go +++ b/pkg/registry/apis/provisioning/files.go @@ -13,9 +13,9 @@ import ( authlib "github.com/grafana/authlib/types" "github.com/grafana/grafana-app-sdk/logging" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/auth" "github.com/grafana/grafana/apps/provisioning/pkg/repository" "github.com/grafana/grafana/apps/provisioning/pkg/safepath" - "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" ) @@ -27,12 +27,12 @@ const ( type filesConnector struct { getter RepoGetter - access authlib.AccessChecker + access auth.AccessChecker parsers resources.ParserFactory clients resources.ClientFactory } -func NewFilesConnector(getter RepoGetter, parsers resources.ParserFactory, clients resources.ClientFactory, access authlib.AccessChecker) *filesConnector { +func NewFilesConnector(getter RepoGetter, parsers resources.ParserFactory, clients resources.ClientFactory, access auth.AccessChecker) *filesConnector { return &filesConnector{getter: getter, parsers: parsers, clients: clients, access: access} } @@ -242,45 +242,14 @@ func (c *filesConnector) Connect(ctx context.Context, name string, opts runtime. } // authorizeListFiles checks if the user has repositories:read permission for listing files. -// Falls back to admin role for backwards compatibility. +// The access checker handles AccessPolicy identities, namespace resolution, and role-based fallback internally. func (c *filesConnector) authorizeListFiles(ctx context.Context, repoName string) error { - id, err := identity.GetRequester(ctx) - if err != nil { - return apierrors.NewUnauthorized(err.Error()) - } - - // AccessPolicy identities (ST->MT flow) are trusted internal callers - if authlib.IsIdentityType(id.GetIdentityType(), authlib.TypeAccessPolicy) { - return nil - } - - rsp, err := c.access.Check(ctx, id, authlib.CheckRequest{ - Verb: utils.VerbGet, - Group: provisioning.GROUP, - Resource: provisioning.RepositoryResourceInfo.GetName(), - Name: repoName, - Namespace: id.GetNamespace(), + return c.access.Check(ctx, authlib.CheckRequest{ + Verb: utils.VerbGet, + Group: provisioning.GROUP, + Resource: provisioning.RepositoryResourceInfo.GetName(), + Name: repoName, }, "") - if err != nil { - // Fall back to admin role on error - if id.GetOrgRole().Includes(identity.RoleAdmin) { - return nil - } - return apierrors.NewForbidden(provisioning.RepositoryResourceInfo.GroupResource(), repoName, - fmt.Errorf("failed to check access: %w", err)) - } - - if rsp.Allowed { - return nil - } - - // Fall back to admin role for backwards compatibility - if id.GetOrgRole().Includes(identity.RoleAdmin) { - return nil - } - - return apierrors.NewForbidden(provisioning.RepositoryResourceInfo.GroupResource(), repoName, - fmt.Errorf("admin role is required")) } // listFolderFiles returns a list of files in a folder. diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 4b5839252d2..956abf4b175 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -29,6 +29,7 @@ import ( "github.com/grafana/grafana-app-sdk/logging" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/auth" connectionvalidation "github.com/grafana/grafana/apps/provisioning/pkg/connection" appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller" clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned" @@ -111,7 +112,9 @@ type APIBuilder struct { unified resource.ResourceClient repoFactory repository.Factory client client.ProvisioningV0alpha1Interface - access authlib.AccessChecker + access auth.AccessChecker + accessWithAdmin auth.AccessChecker + accessWithEditor auth.AccessChecker statusPatcher *appcontroller.RepositoryStatusPatcher healthChecker *controller.HealthChecker validator repository.RepositoryValidator @@ -158,6 +161,9 @@ func NewAPIBuilder( parsers := resources.NewParserFactory(clients) resourceLister := resources.NewResourceListerForMigrations(unified) + // Create access checker with fallback behavior based on mode + accessChecker := auth.NewAccessChecker(access, useExclusivelyAccessCheckerForAuthz) + b := &APIBuilder{ onlyApiServer: onlyApiServer, tracer: tracer, @@ -170,7 +176,9 @@ func NewAPIBuilder( resourceLister: resourceLister, dashboardAccess: dashboardAccess, unified: unified, - access: access, + access: accessChecker, + accessWithAdmin: accessChecker.WithFallback(identity.RoleAdmin), + accessWithEditor: accessChecker.WithFallback(identity.RoleEditor), jobHistoryConfig: jobHistoryConfig, extraWorkers: extraWorkers, restConfigGetter: restConfigGetter, @@ -308,36 +316,7 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer { return authorizeRoleBasedResource(a.GetResource(), id) } - info, ok := authlib.AuthInfoFrom(ctx) - // when running as standalone API server, the identity type may not always match TypeAccessPolicy - // so we allow it to use the access checker if there is any auth info available - if ok && (authlib.IsIdentityType(info.GetIdentityType(), authlib.TypeAccessPolicy) || b.useExclusivelyAccessCheckerForAuthz) { - res, err := b.access.Check(ctx, info, authlib.CheckRequest{ - Verb: a.GetVerb(), - Group: a.GetAPIGroup(), - Resource: a.GetResource(), - Name: a.GetName(), - Namespace: a.GetNamespace(), - Subresource: a.GetSubresource(), - Path: a.GetPath(), - }, "") - if err != nil { - return authorizer.DecisionDeny, "failed to perform authorization", err - } - - if !res.Allowed { - return authorizer.DecisionDeny, "permission denied", nil - } - - return authorizer.DecisionAllow, "", nil - } - - id, err := identity.GetRequester(ctx) - if err != nil { - return authorizer.DecisionDeny, "failed to find requester", err - } - - return b.authorizeResource(ctx, a, id) + return b.authorizeResource(ctx, a) }) } @@ -365,35 +344,63 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer { // // Stats: // - Admin role required -func (b *APIBuilder) authorizeResource(ctx context.Context, a authorizer.Attributes, id identity.Requester) (authorizer.Decision, string, error) { +func (b *APIBuilder) authorizeResource(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { switch a.GetResource() { case provisioning.RepositoryResourceInfo.GetName(): - return b.authorizeRepositorySubresource(ctx, a, id) + return b.authorizeRepositorySubresource(ctx, a) case provisioning.ConnectionResourceInfo.GetName(): - return b.authorizeConnectionSubresource(ctx, a, id) + return b.authorizeConnectionSubresource(ctx, a) case provisioning.JobResourceInfo.GetName(): - return b.checkAccessForJobs(ctx, id, a.GetVerb(), provisioning.GROUP, provisioning.JobResourceInfo.GetName(), a.GetName(), a.GetNamespace()) + return toAuthorizerDecision(b.accessWithEditor.Check(ctx, authlib.CheckRequest{ + Verb: a.GetVerb(), + Group: provisioning.GROUP, + Resource: provisioning.JobResourceInfo.GetName(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + }, "")) case provisioning.HistoricJobResourceInfo.GetName(): // Historic jobs are read-only and admin-only (not editor) - return b.checkAccess(ctx, id, apiutils.VerbGet, provisioning.GROUP, provisioning.HistoricJobResourceInfo.GetName(), a.GetName(), a.GetNamespace()) + return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{ + Verb: apiutils.VerbGet, + Group: provisioning.GROUP, + Resource: provisioning.HistoricJobResourceInfo.GetName(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + }, "")) case "settings", "stats": + id, err := identity.GetRequester(ctx) + if err != nil { + return authorizer.DecisionDeny, "failed to find requester", err + } return authorizeRoleBasedResource(a.GetResource(), id) default: - return b.authorizeDefault(id) + return b.authorizeDefault(ctx) } } // authorizeRepositorySubresource handles authorization for repository subresources. // Uses the access checker with verb-based authorization. -func (b *APIBuilder) authorizeRepositorySubresource(ctx context.Context, a authorizer.Attributes, id identity.Requester) (authorizer.Decision, string, error) { +func (b *APIBuilder) authorizeRepositorySubresource(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { switch a.GetSubresource() { // Repository CRUD - use access checker with the actual verb case "": - return b.checkAccess(ctx, id, a.GetVerb(), provisioning.GROUP, provisioning.RepositoryResourceInfo.GetName(), a.GetName(), a.GetNamespace()) + return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{ + Verb: a.GetVerb(), + Group: provisioning.GROUP, + Resource: provisioning.RepositoryResourceInfo.GetName(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + }, "")) // Test requires write permission (testing before save) case "test": - return b.checkAccess(ctx, id, apiutils.VerbUpdate, provisioning.GROUP, provisioning.RepositoryResourceInfo.GetName(), a.GetName(), a.GetNamespace()) + return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{ + Verb: apiutils.VerbUpdate, + Group: provisioning.GROUP, + Resource: provisioning.RepositoryResourceInfo.GetName(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + }, "")) // Files subresource: allow any authenticated user at route level. // Directory listing checks repositories:read in the connector. @@ -403,13 +410,28 @@ func (b *APIBuilder) authorizeRepositorySubresource(ctx context.Context, a autho // Read-only subresources: refs, resources, history, status case "refs", "resources", "history", "status": - return b.checkAccess(ctx, id, apiutils.VerbGet, provisioning.GROUP, provisioning.RepositoryResourceInfo.GetName(), a.GetName(), a.GetNamespace()) + return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{ + Verb: apiutils.VerbGet, + Group: provisioning.GROUP, + Resource: provisioning.RepositoryResourceInfo.GetName(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + }, "")) // Jobs subresource - check jobs permissions with the verb (editors can manage jobs) case "jobs": - return b.checkAccessForJobs(ctx, id, a.GetVerb(), provisioning.GROUP, provisioning.JobResourceInfo.GetName(), "", a.GetNamespace()) + return toAuthorizerDecision(b.accessWithEditor.Check(ctx, authlib.CheckRequest{ + Verb: a.GetVerb(), + Group: provisioning.GROUP, + Resource: provisioning.JobResourceInfo.GetName(), + Namespace: a.GetNamespace(), + }, "")) default: + id, err := identity.GetRequester(ctx) + if err != nil { + return authorizer.DecisionDeny, "failed to find requester", err + } if id.GetIsGrafanaAdmin() { return authorizer.DecisionAllow, "", nil } @@ -419,17 +441,33 @@ func (b *APIBuilder) authorizeRepositorySubresource(ctx context.Context, a autho // authorizeConnectionSubresource handles authorization for connection subresources. // Uses the access checker with verb-based authorization. -func (b *APIBuilder) authorizeConnectionSubresource(ctx context.Context, a authorizer.Attributes, id identity.Requester) (authorizer.Decision, string, error) { +func (b *APIBuilder) authorizeConnectionSubresource(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { switch a.GetSubresource() { // Connection CRUD - use access checker with the actual verb case "": - return b.checkAccess(ctx, id, a.GetVerb(), provisioning.GROUP, provisioning.ConnectionResourceInfo.GetName(), a.GetName(), a.GetNamespace()) + return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{ + Verb: a.GetVerb(), + Group: provisioning.GROUP, + Resource: provisioning.ConnectionResourceInfo.GetName(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + }, "")) // Status is read-only case "status": - return b.checkAccess(ctx, id, apiutils.VerbGet, provisioning.GROUP, provisioning.ConnectionResourceInfo.GetName(), a.GetName(), a.GetNamespace()) + return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{ + Verb: apiutils.VerbGet, + Group: provisioning.GROUP, + Resource: provisioning.ConnectionResourceInfo.GetName(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + }, "")) default: + id, err := identity.GetRequester(ctx) + if err != nil { + return authorizer.DecisionDeny, "failed to find requester", err + } if id.GetIsGrafanaAdmin() { return authorizer.DecisionAllow, "", nil } @@ -441,61 +479,17 @@ func (b *APIBuilder) authorizeConnectionSubresource(ctx context.Context, a autho // Authorization helpers // ---------------------------------------------------------------------------- -func isAccessPolicy(id identity.Requester) bool { - return authlib.IsIdentityType(id.GetIdentityType(), authlib.TypeAccessPolicy) -} - -// checkAccessWithFallback uses the access checker to verify permissions. -// Falls back to the specified role for backwards compatibility. -func (b *APIBuilder) checkAccessWithFallback(ctx context.Context, id identity.Requester, verb, group, resource, name, namespace string, fallbackRole identity.RoleType) (authorizer.Decision, string, error) { - // AccessPolicy identities are trusted internal callers (ST->MT flow) - if isAccessPolicy(id) { - return authorizer.DecisionAllow, "", nil - } - - // Use the access checker - res, err := b.access.Check(ctx, id, authlib.CheckRequest{ - Verb: verb, - Group: group, - Resource: resource, - Name: name, - Namespace: namespace, - }, "") - +// toAuthorizerDecision converts an access check error to an authorizer decision tuple. +func toAuthorizerDecision(err error) (authorizer.Decision, string, error) { if err != nil { - // Fall back to specified role on error - if id.GetOrgRole().Includes(fallbackRole) { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "failed to check access: " + err.Error(), nil + return authorizer.DecisionDeny, err.Error(), nil } - - if res.Allowed { - return authorizer.DecisionAllow, "", nil - } - - // Fall back to specified role for backwards compatibility - if id.GetOrgRole().Includes(fallbackRole) { - return authorizer.DecisionAllow, "", nil - } - - return authorizer.DecisionDeny, fmt.Sprintf("%s role is required", strings.ToLower(string(fallbackRole))), nil -} - -// checkAccess uses the access checker with admin role fallback. -func (b *APIBuilder) checkAccess(ctx context.Context, id identity.Requester, verb, group, resource, name, namespace string) (authorizer.Decision, string, error) { - return b.checkAccessWithFallback(ctx, id, verb, group, resource, name, namespace, identity.RoleAdmin) -} - -// checkAccessForJobs uses the access checker with editor role fallback. -// Jobs can be created/managed by editors, not just admins. -func (b *APIBuilder) checkAccessForJobs(ctx context.Context, id identity.Requester, verb, group, resource, name, namespace string) (authorizer.Decision, string, error) { - return b.checkAccessWithFallback(ctx, id, verb, group, resource, name, namespace, identity.RoleEditor) + return authorizer.DecisionAllow, "", nil } // allowForAdminsOrAccessPolicy is used for resources without fine-grained permissions. func allowForAdminsOrAccessPolicy(id identity.Requester) (authorizer.Decision, string, error) { - if isAccessPolicy(id) || id.GetOrgRole().Includes(identity.RoleAdmin) { + if authlib.IsIdentityType(id.GetIdentityType(), authlib.TypeAccessPolicy) || id.GetOrgRole().Includes(identity.RoleAdmin) { return authorizer.DecisionAllow, "", nil } return authorizer.DecisionDeny, "admin role is required", nil @@ -503,7 +497,7 @@ func allowForAdminsOrAccessPolicy(id identity.Requester) (authorizer.Decision, s // allowForViewersOrAccessPolicy allows any authenticated user with at least viewer role. func allowForViewersOrAccessPolicy(id identity.Requester) (authorizer.Decision, string, error) { - if isAccessPolicy(id) || id.GetOrgRole().Includes(identity.RoleViewer) { + if authlib.IsIdentityType(id.GetIdentityType(), authlib.TypeAccessPolicy) || id.GetOrgRole().Includes(identity.RoleViewer) { return authorizer.DecisionAllow, "", nil } return authorizer.DecisionDeny, "viewer role is required", nil @@ -530,7 +524,11 @@ func authorizeRoleBasedResource(resource string, id identity.Requester) (authori } // authorizeDefault handles authorization for unmapped resources. -func (b *APIBuilder) authorizeDefault(id identity.Requester) (authorizer.Decision, string, error) { +func (b *APIBuilder) authorizeDefault(ctx context.Context) (authorizer.Decision, string, error) { + id, err := identity.GetRequester(ctx) + if err != nil { + return authorizer.DecisionDeny, "failed to find requester", err + } // We haven't bothered with this kind yet. if id.GetIsGrafanaAdmin() { return authorizer.DecisionAllow, "", nil diff --git a/pkg/registry/apis/provisioning/resources/dualwriter.go b/pkg/registry/apis/provisioning/resources/dualwriter.go index 9180ace494d..e8a7ee83dd0 100644 --- a/pkg/registry/apis/provisioning/resources/dualwriter.go +++ b/pkg/registry/apis/provisioning/resources/dualwriter.go @@ -12,6 +12,7 @@ import ( authlib "github.com/grafana/authlib/types" "github.com/grafana/grafana-app-sdk/logging" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/auth" "github.com/grafana/grafana/apps/provisioning/pkg/repository" "github.com/grafana/grafana/apps/provisioning/pkg/safepath" "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" @@ -32,7 +33,7 @@ type DualReadWriter struct { repo repository.ReaderWriter parser Parser folders *FolderManager - access authlib.AccessChecker + access auth.AccessChecker } type DualWriteOptions struct { @@ -48,7 +49,7 @@ type DualWriteOptions struct { Branch string // Configured default branch } -func NewDualReadWriter(repo repository.ReaderWriter, parser Parser, folders *FolderManager, access authlib.AccessChecker) *DualReadWriter { +func NewDualReadWriter(repo repository.ReaderWriter, parser Parser, folders *FolderManager, access auth.AccessChecker) *DualReadWriter { return &DualReadWriter{repo: repo, parser: parser, folders: folders, access: access} } @@ -492,11 +493,6 @@ func (r *DualReadWriter) moveFile(ctx context.Context, opts DualWriteOptions) (* } func (r *DualReadWriter) authorize(ctx context.Context, parsed *ParsedResource, verb string) error { - id, err := identity.GetRequester(ctx) - if err != nil { - return apierrors.NewUnauthorized(err.Error()) - } - var name string if parsed.Existing != nil { name = parsed.Existing.GetName() @@ -504,27 +500,15 @@ func (r *DualReadWriter) authorize(ctx context.Context, parsed *ParsedResource, name = parsed.Obj.GetName() } - rsp, err := r.access.Check(ctx, id, authlib.CheckRequest{ - Group: parsed.GVR.Group, - Resource: parsed.GVR.Resource, - Namespace: id.GetNamespace(), - Name: name, - Verb: verb, + return r.access.Check(ctx, authlib.CheckRequest{ + Group: parsed.GVR.Group, + Resource: parsed.GVR.Resource, + Name: name, + Verb: verb, }, parsed.Meta.GetFolder()) - if err != nil || !rsp.Allowed { - return apierrors.NewForbidden(parsed.GVR.GroupResource(), parsed.Obj.GetName(), - fmt.Errorf("no access to perform %s on the resource", verb)) - } - - return nil } func (r *DualReadWriter) authorizeCreateFolder(ctx context.Context, path string) error { - id, err := identity.GetRequester(ctx) - if err != nil { - return apierrors.NewUnauthorized(err.Error()) - } - // Determine parent folder from path parentFolder := "" if path != "" { @@ -537,19 +521,12 @@ func (r *DualReadWriter) authorizeCreateFolder(ctx context.Context, path string) } // For folder create operations, use empty name to check parent folder permissions - rsp, err := r.access.Check(ctx, id, authlib.CheckRequest{ - Group: FolderResource.Group, - Resource: FolderResource.Resource, - Namespace: id.GetNamespace(), - Name: "", // Empty name for create operations - Verb: utils.VerbCreate, + return r.access.Check(ctx, authlib.CheckRequest{ + Group: FolderResource.Group, + Resource: FolderResource.Resource, + Name: "", // Empty name for create operations + Verb: utils.VerbCreate, }, parentFolder) - if err != nil || !rsp.Allowed { - return apierrors.NewForbidden(FolderResource.GroupResource(), path, - fmt.Errorf("no access to create folder in parent folder '%s'", parentFolder)) - } - - return nil } func (r *DualReadWriter) deleteFolder(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {