provisioning: refactor access checker integration for improved authorization

Updated the authorization logic to utilize the new access checker across various resources, including files and jobs. This change simplifies the permission checks by removing redundant identity retrieval and enhances error handling. The access checker now supports role-based fallbacks for admin and editor roles, ensuring backward compatibility while streamlining the authorization process for repository and connection subresources.
This commit is contained in:
Roberto Jimenez Sanchez
2025-12-17 20:08:01 +01:00
parent 4317d56a12
commit a7bc1506be
5 changed files with 610 additions and 173 deletions
@@ -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
}
@@ -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
}
+9 -40
View File
@@ -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.
+95 -97
View File
@@ -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
@@ -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) {