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:
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user