Provisioning: fix multi-tenant and single-tenant authorization (#115435)
* feat(auth): add ExtraAudience option to RoundTripper Add ExtraAudience option to RoundTripper to allow operators to include additional audiences (e.g., provisioning group) when connecting to the multitenant aggregator. This ensures tokens include both the target API server's audience and the provisioning group audience, which is required to pass the enforceManagerProperties check. - Add ExtraAudience RoundTripperOption - Improve documentation and comments - Add comprehensive test coverage * fix(operators): add ExtraAudience for dashboards/folders API servers Operators connecting to dashboards and folders API servers need to include the provisioning group audience in addition to the target API server's audience to pass the enforceManagerProperties check. * provisioning: fix settings/stats authorization for AccessPolicy identities The settings and stats endpoints were returning 403 for users accessing via ST->MT because the AccessPolicy identity was routed to the access checker, which doesn't know about these resources. This fix handles 'settings' and 'stats' resources before the access checker path, routing them to the role-based authorization that allows: - settings: Viewer role (read-only, needed by frontend) - stats: Admin role (can leak information) * fix: update BootstrapStep component to remove legacy storage handling and adjust resource counting logic - Removed legacy storage flag from useResourceStats hook in BootstrapStep. - Updated BootstrapStepResourceCounting to simplify rendering logic and removed target prop. - Adjusted tests to reflect changes in resource counting and rendering behavior. * Revert "fix: update BootstrapStep component to remove legacy storage handling and adjust resource counting logic" This reverts commit148802cbb5. * provisioning: allow any authenticated user for settings/stats endpoints These are read-only endpoints needed by the frontend: - settings: returns available repository types and configuration for the wizard - stats: returns resource counts Authentication is verified before reaching authorization, so any user who reaches these endpoints is already authenticated. Requiring specific org roles failed for AccessPolicy tokens which don't carry traditional roles. * provisioning: remove redundant admin role check from listFolderFiles The admin role check in listFolderFiles was redundant (route-level auth already handles access) and broken for AccessPolicy identities which don't have org roles. File access is controlled by the AccessClient as documented in the route-level authorization comment. * provisioning: add isAdminOrAccessPolicy helper for auth checks Consolidates authorization logic for provisioning endpoints: - Adds isAdminOrAccessPolicy() helper that allows admin users OR AccessPolicy identities - AccessPolicy identities (ST->MT flow) are trusted internal callers without org roles - Regular users must have admin role (matching frontend navtree restriction) Used in: authorizeSettings, authorizeStats, authorizeJobs, listFolderFiles * provisioning: consolidate auth helpers into allowForAdminsOrAccessPolicy Simplifies authorization by: - Adding isAccessPolicy() helper for AccessPolicy identity check - Adding allowForAdminsOrAccessPolicy() that returns Decision directly - Consolidating stats/settings/jobs into single switch case - Using consistent pattern in files.go * provisioning: require admin for files subresource at route level Aligns route-level authorization with handler-level check in listFolderFiles. Both now require admin role OR AccessPolicy identity for consistency. * provisioning: restructure authorization with role-based helpers Reorganizes authorization code for clarity: Role-based helpers (all support AccessPolicy for ST->MT flow): - allowForAdminsOrAccessPolicy: admin role required - allowForEditorsOrAccessPolicy: editor role required - allowForViewersOrAccessPolicy: viewer role required Repository subresources by role: - Admin: repository CRUD, test, files - Editor: jobs, resources, sync, history - Viewer: refs, status (GET only) Connection subresources by role: - Admin: connection CRUD - Viewer: status (GET only) * provisioning: move refs to admin-only refs subresource now requires admin role (or AccessPolicy). Updated documentation comments to reflect current permissions. * provisioning: add fine-grained permissions for connections Adds connection permissions following the same pattern as repositories: - provisioning.connections:create - provisioning.connections:read - provisioning.connections:write - provisioning.connections:delete Roles: - fixed:provisioning.connections:reader (granted to Admin) - fixed:provisioning.connections:writer (granted to Admin) * provisioning: remove non-existent sync subresource from auth The sync subresource doesn't exist - syncing is done via the jobs endpoint. Removed dead code from authorization switch case. * provisioning: use access checker for fine-grained permissions Refactors authorization to use b.access.Check() with verb-based checks: Repository subresources: - CRUD: uses actual verb (get/create/update/delete) - test: uses 'update' (write permission) - files/refs/resources/history/status: uses 'get' (read permission) - jobs: uses actual verb for jobs resource Connection subresources: - CRUD: uses actual verb - status: uses 'get' (read permission) The access checker maps verbs to actions defined in accesscontrol.go. Falls back to admin role for backwards compatibility. Also removes redundant admin check from listFolderFiles since authorization is now properly handled at route level. * provisioning: use verb constants instead of string literals Uses apiutils.VerbGet, apiutils.VerbUpdate instead of "get", "update". * provisioning: use access checker for jobs and historicjobs resources Jobs resource: uses actual verb (create/read/write/delete) HistoricJobs resource: read-only (historicjobs:read) * provisioning: allow viewers to access settings endpoint Settings is read-only and needed by multiple UI pages (not just admin pages). Stats remains admin-only. * provisioning: consolidate role-based resource authorization Extract isRoleBasedResource() and authorizeRoleBasedResource() helpers to avoid duplicating settings/stats resource checks in multiple places. * provisioning: use resource name constants instead of hardcoded strings Replace 'repositories', 'connections', 'jobs', 'historicjobs' with their corresponding ResourceInfo.GetName() constants. * provisioning: delegate file authorization to connector Route level: allow any authenticated user for files subresource Connector: check repositories:read only for directory listing Individual file CRUD: handled by DualReadWriter based on actual resource * provisioning: enhance authorization for files and jobs resources Updated file authorization to fall back to admin role for listing files. Introduced checkAccessForJobs function to manage job permissions, allowing editors to create and manage jobs while maintaining admin-only access for historic jobs. Improved error messaging for permission denials. * provisioning: refactor authorization with fine-grained permissions Authorization changes: - Use access checker with role-based fallback for backwards compatibility - Repositories/Connections: admin role fallback - Jobs: editor role fallback (editors can manage jobs) - HistoricJobs: admin role fallback (read-only) - Settings: viewer role (needed by multiple UI pages) - Stats: admin role Files subresource: - Route level allows any authenticated user - Directory listing checks repositories:read in connector - Individual file CRUD delegated to DualReadWriter Refactored checkAccessWithFallback to accept fallback role parameter. * 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. * provisioning: remove legacy access checker tests and refactor access checker implementation Deleted the access_checker_test.go file to streamline the codebase and focus on the updated access checker implementation. Refactored the access checker to enhance clarity and maintainability, ensuring it supports role-based fallback behavior. Updated the access checker integration in the API builder to utilize the new fallback role configuration, improving authorization logic across resources. * refactor: split AccessChecker into TokenAccessChecker and SessionAccessChecker - Renamed NewMultiTenantAccessChecker -> NewTokenAccessChecker (uses AuthInfoFrom) - Renamed NewSingleTenantAccessChecker -> NewSessionAccessChecker (uses GetRequester) - Split into separate files with their own tests - Added mockery-generated mock for AccessChecker interface - Names now reflect identity source rather than deployment mode * fix: correct error message case and use accessWithAdmin for filesConnector - Fixed error message to use lowercase 'admin role is required' - Fixed filesConnector to use accessWithAdmin for proper role fallback - Formatted code * refactor: reduce cyclomatic complexity in filesConnector.Connect Split the Connect handler into smaller focused functions: - handleRequest: main request processing - createDualReadWriter: setup dependencies - parseRequestOptions: extract request options - handleDirectoryListing: GET directory requests - handleMethodRequest: route to method handlers - handleGet/handlePost/handlePut/handleDelete: method-specific logic - handleMove: move operation logic * security: remove blind TypeAccessPolicy bypass from access checkers Removed the code that bypassed authorization for TypeAccessPolicy identities. All identities now go through proper permission verification via the inner access checker, which will validate permissions from ServiceIdentityClaims. This addresses the security concern where TypeAccessPolicy was being trusted blindly without verifying whether the identity came from the wire or in-process. * feat: allow editors to access repository refs subresource Change refs authorization from admin to editor fallback so editors can view repository branches when pushing changes to dashboards/folders. - Split refs from other read-only subresources (resources, history, status) - refs now uses accessWithEditor instead of accessWithAdmin - Updated documentation comment to reflect authorization levels - Added integration test TestIntegrationProvisioning_RefsPermissions verifying editor access and viewer denial * tests: add authorization tests for missing provisioning API endpoints Add comprehensive authorization tests for: - Repository subresources (test, resources, history, status) - Connection status subresource - HistoricJobs resource - Settings and Stats resources All authorization paths are now covered by integration tests. * test: fix RefsPermissions test to use GitHub repository Use github-readonly.json.tmpl template instead of local folder, since refs endpoint requires a versioned repository that supports git operations. * chore: format test files * fix: make settings/stats authorization work in MT mode Update authorizeRoleBasedResource to check authlib.AuthInfoFrom(ctx) for AccessPolicy identity type in addition to identity.GetRequester(ctx). This ensures AccessPolicy identities are recognized in MT mode where identity.GetRequester may not set the identity type correctly. * fix: remove unused authorization helper functions Remove allowForAdminsOrAccessPolicy and allowForViewersOrAccessPolicy as they are no longer used after refactoring to use authorizeRoleBasedResource. * Fix AccessPolicy identity detection in ST authorizer - Add check for AccessPolicy identities via GetAuthID() in authorizeRoleBasedResource - Extended JWT may set identity type to TypeUser but AuthID is 'access-policy:...' - Forward user ID token in X-Grafana-Id header in RoundTripper for aggregator forwarding * Revert "Fix AccessPolicy identity detection in ST authorizer" This reverts commit0f4885e503. * Add fine-grained permissions for settings and stats endpoints - Add provisioning.settings:read action (granted to Viewer role) - Add provisioning.stats:read action (granted to Admin role) - Add accessWithViewer to APIBuilder for Viewer role fallback - Use access checker for settings/stats authorization - Remove role-based authorization functions (isRoleBasedResource, authorizeRoleBasedResource) This makes settings and stats consistent with other provisioning resources and works properly in both ST and MT modes via the access checker. * Remove AUTHORIZATION_COVERAGE.md * Add provisioning resources to RBAC mapper - Add connections, settings, stats to provisioning.grafana.app mappings - Required for authz service to translate K8s verbs to legacy actions - Fixes 403 errors for settings/stats in MT mode * refactor: merge access checkers with original fallthrough behavior Merge tokenAccessChecker and sessionAccessChecker into a unified access checker that implements the original fallthrough behavior: 1. First try to get identity from access token (authlib.AuthInfoFrom) 2. If token exists AND (is TypeAccessPolicy OR useExclusivelyAccessCheckerForAuthz), use the access checker with token identity 3. If no token or conditions not met, fall back to session identity (identity.GetRequester) with optional role-based fallback This fixes the issue where settings/stats/connections endpoints were failing in MT mode because the tokenAccessChecker was returning an error when there was no auth info in context, instead of falling through to session-based authorization. The unified checker now properly handles: - MT mode: tries token first, falls back to session if no token - ST mode: only uses token for AccessPolicy identities, otherwise session - Role fallback: applies when configured and access checker denies * Revert "refactor: merge access checkers with original fallthrough behavior" This reverts commit96451f948b. * Grant settings view role to all * fix: use actual request verb for settings/stats authorization Use a.GetVerb() instead of hardcoded VerbGet for settings and stats authorization. When listing resources (hitting collection endpoint), the verb is 'list' not 'get', and this mismatch could cause issues with the RBAC service. * debug: add logging to access checkers for authorization debugging Add klog debug logs (V4 level) to token and session access checkers to help diagnose why settings/stats authorization is failing while connections works. * debug: improve access checker logging with grafana-app-sdk logger - Use grafana-app-sdk logging.FromContext instead of klog - Add error wrapping with resource.group format for better context - Log more details including folder, group, and allowed status - Log error.Error() for better error message visibility * chore: use generic log messages in access checkers * Revert "Grant settings view role to all" This reverts commit3f5758cf36. * fix: use request verb for historicjobs authorization The original role-based check allowed any verb for admins. To preserve this behavior with the access checker, we should pass the actual verb from the request instead of hardcoding VerbGet. --------- Co-authored-by: Charandas Batra <charandas.batra@grafana.com>
This commit is contained in:
co-authored by
Charandas Batra
parent
ece38641ca
commit
9760eef62f
@@ -0,0 +1,22 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
)
|
||||
|
||||
//go:generate mockery --name AccessChecker --structname MockAccessChecker --inpackage --filename access_checker_mock.go --with-expecter
|
||||
|
||||
// AccessChecker provides access control checks with optional role-based fallback.
|
||||
type AccessChecker interface {
|
||||
// Check performs an access check and returns nil if allowed, or an appropriate
|
||||
// API error if denied. If req.Namespace is empty, it will be filled from the
|
||||
// identity's namespace.
|
||||
Check(ctx context.Context, req authlib.CheckRequest, folder string) error
|
||||
|
||||
// WithFallbackRole returns an AccessChecker configured with the specified fallback role.
|
||||
// Whether the fallback is actually applied depends on the implementation.
|
||||
WithFallbackRole(role identity.RoleType) AccessChecker
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Code generated by mockery v2.53.4. DO NOT EDIT.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
identity "github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
types "github.com/grafana/authlib/types"
|
||||
)
|
||||
|
||||
// MockAccessChecker is an autogenerated mock type for the AccessChecker type
|
||||
type MockAccessChecker struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockAccessChecker_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockAccessChecker) EXPECT() *MockAccessChecker_Expecter {
|
||||
return &MockAccessChecker_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Check provides a mock function with given fields: ctx, req, folder
|
||||
func (_m *MockAccessChecker) Check(ctx context.Context, req types.CheckRequest, folder string) error {
|
||||
ret := _m.Called(ctx, req, folder)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Check")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, types.CheckRequest, string) error); ok {
|
||||
r0 = rf(ctx, req, folder)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockAccessChecker_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check'
|
||||
type MockAccessChecker_Check_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Check is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - req types.CheckRequest
|
||||
// - folder string
|
||||
func (_e *MockAccessChecker_Expecter) Check(ctx interface{}, req interface{}, folder interface{}) *MockAccessChecker_Check_Call {
|
||||
return &MockAccessChecker_Check_Call{Call: _e.mock.On("Check", ctx, req, folder)}
|
||||
}
|
||||
|
||||
func (_c *MockAccessChecker_Check_Call) Run(run func(ctx context.Context, req types.CheckRequest, folder string)) *MockAccessChecker_Check_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(types.CheckRequest), args[2].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAccessChecker_Check_Call) Return(_a0 error) *MockAccessChecker_Check_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAccessChecker_Check_Call) RunAndReturn(run func(context.Context, types.CheckRequest, string) error) *MockAccessChecker_Check_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// WithFallbackRole provides a mock function with given fields: role
|
||||
func (_m *MockAccessChecker) WithFallbackRole(role identity.RoleType) AccessChecker {
|
||||
ret := _m.Called(role)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for WithFallbackRole")
|
||||
}
|
||||
|
||||
var r0 AccessChecker
|
||||
if rf, ok := ret.Get(0).(func(identity.RoleType) AccessChecker); ok {
|
||||
r0 = rf(role)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(AccessChecker)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockAccessChecker_WithFallbackRole_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WithFallbackRole'
|
||||
type MockAccessChecker_WithFallbackRole_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// WithFallbackRole is a helper method to define mock.On call
|
||||
// - role identity.RoleType
|
||||
func (_e *MockAccessChecker_Expecter) WithFallbackRole(role interface{}) *MockAccessChecker_WithFallbackRole_Call {
|
||||
return &MockAccessChecker_WithFallbackRole_Call{Call: _e.mock.On("WithFallbackRole", role)}
|
||||
}
|
||||
|
||||
func (_c *MockAccessChecker_WithFallbackRole_Call) Run(run func(role identity.RoleType)) *MockAccessChecker_WithFallbackRole_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(identity.RoleType))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAccessChecker_WithFallbackRole_Call) Return(_a0 AccessChecker) *MockAccessChecker_WithFallbackRole_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAccessChecker_WithFallbackRole_Call) RunAndReturn(run func(identity.RoleType) AccessChecker) *MockAccessChecker_WithFallbackRole_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockAccessChecker creates a new instance of MockAccessChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockAccessChecker(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockAccessChecker {
|
||||
mock := &MockAccessChecker{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package auth provides authentication utilities for the provisioning API.
|
||||
package auth
|
||||
|
||||
import (
|
||||
@@ -6,7 +7,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/authlib/authn"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
utilnet "k8s.io/apimachinery/pkg/util/net"
|
||||
)
|
||||
|
||||
@@ -15,29 +15,61 @@ type tokenExchanger interface {
|
||||
Exchange(ctx context.Context, req authn.TokenExchangeRequest) (*authn.TokenExchangeResponse, error)
|
||||
}
|
||||
|
||||
// RoundTripper injects an exchanged access token for the provisioning API into outgoing requests.
|
||||
type RoundTripper struct {
|
||||
client tokenExchanger
|
||||
transport http.RoundTripper
|
||||
audience string
|
||||
// RoundTripperOption configures optional behavior for the RoundTripper.
|
||||
type RoundTripperOption func(*RoundTripper)
|
||||
|
||||
// ExtraAudience appends an additional audience to the token exchange request.
|
||||
//
|
||||
// This is primarily used by operators connecting to the multitenant aggregator,
|
||||
// where the token must include both the target API server's audience (e.g., dashboards,
|
||||
// folders) and the provisioning group audience. The provisioning group audience is
|
||||
// required so that the token passes the enforceManagerProperties check, which prevents
|
||||
// unauthorized updates to provisioned resources.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// authrt.NewRoundTripper(client, rt, "dashboards.grafana.app", authrt.ExtraAudience("provisioning.grafana.app"))
|
||||
func ExtraAudience(audience string) RoundTripperOption {
|
||||
return func(rt *RoundTripper) {
|
||||
rt.extraAudience = audience
|
||||
}
|
||||
}
|
||||
|
||||
// NewRoundTripper constructs a RoundTripper that exchanges the provided token per request
|
||||
// and forwards the request to the provided base transport.
|
||||
func NewRoundTripper(tokenExchangeClient tokenExchanger, base http.RoundTripper, audience string) *RoundTripper {
|
||||
return &RoundTripper{
|
||||
// RoundTripper is an http.RoundTripper that performs token exchange before each request.
|
||||
// It exchanges the service's credentials for an access token scoped to the configured
|
||||
// audience(s), then injects that token into the outgoing request's X-Access-Token header.
|
||||
type RoundTripper struct {
|
||||
client tokenExchanger
|
||||
transport http.RoundTripper
|
||||
audience string
|
||||
extraAudience string
|
||||
}
|
||||
|
||||
// NewRoundTripper creates a RoundTripper that exchanges tokens for each outgoing request.
|
||||
//
|
||||
// Parameters:
|
||||
// - tokenExchangeClient: the client used to exchange credentials for access tokens
|
||||
// - base: the underlying transport to delegate requests to after token injection
|
||||
// - audience: the primary audience for the token (typically the target API server's group)
|
||||
// - opts: optional configuration (e.g., ExtraAudience to include additional audiences)
|
||||
func NewRoundTripper(tokenExchangeClient tokenExchanger, base http.RoundTripper, audience string, opts ...RoundTripperOption) *RoundTripper {
|
||||
rt := &RoundTripper{
|
||||
client: tokenExchangeClient,
|
||||
transport: base,
|
||||
audience: audience,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(rt)
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
||||
// RoundTrip exchanges credentials for an access token and injects it into the request.
|
||||
// The token is scoped to all configured audiences and the wildcard namespace ("*").
|
||||
func (t *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// when we want to write resources with the provisioning API, the audience needs to include provisioning
|
||||
// so that it passes the check in enforceManagerProperties, which prevents others from updating provisioned resources
|
||||
audiences := []string{t.audience}
|
||||
if t.audience != v0alpha1.GROUP {
|
||||
audiences = append(audiences, v0alpha1.GROUP)
|
||||
if t.extraAudience != "" && t.extraAudience != t.audience {
|
||||
audiences = append(audiences, t.extraAudience)
|
||||
}
|
||||
|
||||
tokenResponse, err := t.client.Exchange(req.Context(), authn.TokenExchangeRequest{
|
||||
|
||||
@@ -71,16 +71,29 @@ func TestRoundTripper_AudiencesAndNamespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
audience string
|
||||
extraAudience string
|
||||
wantAudiences []string
|
||||
}{
|
||||
{
|
||||
name: "adds group when custom audience",
|
||||
name: "uses only provided audience by default",
|
||||
audience: "example-audience",
|
||||
wantAudiences: []string{"example-audience"},
|
||||
},
|
||||
{
|
||||
name: "uses only group audience by default",
|
||||
audience: v0alpha1.GROUP,
|
||||
wantAudiences: []string{v0alpha1.GROUP},
|
||||
},
|
||||
{
|
||||
name: "extra audience adds provisioning group",
|
||||
audience: "example-audience",
|
||||
extraAudience: v0alpha1.GROUP,
|
||||
wantAudiences: []string{"example-audience", v0alpha1.GROUP},
|
||||
},
|
||||
{
|
||||
name: "no duplicate when group audience",
|
||||
name: "extra audience no duplicate when same as primary",
|
||||
audience: v0alpha1.GROUP,
|
||||
extraAudience: v0alpha1.GROUP,
|
||||
wantAudiences: []string{v0alpha1.GROUP},
|
||||
},
|
||||
}
|
||||
@@ -88,11 +101,15 @@ func TestRoundTripper_AudiencesAndNamespace(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fx := &fakeExchanger{resp: &authn.TokenExchangeResponse{Token: "abc123"}}
|
||||
var opts []RoundTripperOption
|
||||
if tt.extraAudience != "" {
|
||||
opts = append(opts, ExtraAudience(tt.extraAudience))
|
||||
}
|
||||
tr := NewRoundTripper(fx, roundTripperFunc(func(_ *http.Request) (*http.Response, error) {
|
||||
rr := httptest.NewRecorder()
|
||||
rr.WriteHeader(http.StatusOK)
|
||||
return rr.Result(), nil
|
||||
}), tt.audience)
|
||||
}), tt.audience, opts...)
|
||||
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example", nil)
|
||||
resp, err := tr.RoundTrip(req)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
)
|
||||
|
||||
// sessionAccessChecker implements AccessChecker using Grafana session identity.
|
||||
type sessionAccessChecker struct {
|
||||
inner authlib.AccessChecker
|
||||
fallbackRole identity.RoleType
|
||||
}
|
||||
|
||||
// NewSessionAccessChecker creates an AccessChecker that gets identity from Grafana
|
||||
// sessions via GetRequester(ctx). Supports optional role-based fallback via
|
||||
// WithFallbackRole for backwards compatibility.
|
||||
func NewSessionAccessChecker(inner authlib.AccessChecker) AccessChecker {
|
||||
return &sessionAccessChecker{
|
||||
inner: inner,
|
||||
fallbackRole: "",
|
||||
}
|
||||
}
|
||||
|
||||
// WithFallbackRole returns a new AccessChecker with the specified fallback role.
|
||||
func (c *sessionAccessChecker) WithFallbackRole(role identity.RoleType) AccessChecker {
|
||||
return &sessionAccessChecker{
|
||||
inner: c.inner,
|
||||
fallbackRole: role,
|
||||
}
|
||||
}
|
||||
|
||||
// Check performs an access check with optional role-based fallback.
|
||||
// Returns nil if access is allowed, or an appropriate API error if denied.
|
||||
func (c *sessionAccessChecker) Check(ctx context.Context, req authlib.CheckRequest, folder string) error {
|
||||
logger := logging.FromContext(ctx).With("logger", "sessionAccessChecker")
|
||||
|
||||
// Get identity from Grafana session
|
||||
requester, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
logger.Debug("failed to get requester",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"error", err.Error(),
|
||||
)
|
||||
return apierrors.NewUnauthorized(fmt.Sprintf("failed to get requester: %v", err))
|
||||
}
|
||||
|
||||
logger.Debug("checking access",
|
||||
"identityType", requester.GetIdentityType(),
|
||||
"orgRole", requester.GetOrgRole(),
|
||||
"namespace", requester.GetNamespace(),
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"group", req.Group,
|
||||
"name", req.Name,
|
||||
"folder", folder,
|
||||
"fallbackRole", c.fallbackRole,
|
||||
)
|
||||
|
||||
// Fill in namespace from identity if not provided
|
||||
if req.Namespace == "" {
|
||||
req.Namespace = requester.GetNamespace()
|
||||
}
|
||||
|
||||
// Perform the access check
|
||||
rsp, err := c.inner.Check(ctx, requester, req, folder)
|
||||
|
||||
// Build the GroupResource for error messages
|
||||
gr := schema.GroupResource{Group: req.Group, Resource: req.Resource}
|
||||
|
||||
// No fallback configured, return result directly
|
||||
if c.fallbackRole == "" {
|
||||
if err != nil {
|
||||
logger.Debug("access check error (no fallback)",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"error", err.Error(),
|
||||
)
|
||||
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("%s.%s is forbidden: %w", req.Resource, req.Group, err))
|
||||
}
|
||||
if !rsp.Allowed {
|
||||
logger.Debug("access check denied (no fallback)",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"group", req.Group,
|
||||
"allowed", rsp.Allowed,
|
||||
)
|
||||
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("permission denied"))
|
||||
}
|
||||
logger.Debug("access allowed",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback is configured - apply fallback logic
|
||||
if err != nil {
|
||||
if requester.GetOrgRole().Includes(c.fallbackRole) {
|
||||
logger.Debug("access allowed via role fallback (after error)",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"fallbackRole", c.fallbackRole,
|
||||
"orgRole", requester.GetOrgRole(),
|
||||
)
|
||||
return nil // Fallback succeeded
|
||||
}
|
||||
logger.Debug("access check error (fallback failed)",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"error", err.Error(),
|
||||
"fallbackRole", c.fallbackRole,
|
||||
"orgRole", requester.GetOrgRole(),
|
||||
)
|
||||
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("%s.%s is forbidden: %w", req.Resource, req.Group, err))
|
||||
}
|
||||
|
||||
if rsp.Allowed {
|
||||
logger.Debug("access allowed",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fall back to role for backwards compatibility
|
||||
if requester.GetOrgRole().Includes(c.fallbackRole) {
|
||||
logger.Debug("access allowed via role fallback",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"fallbackRole", c.fallbackRole,
|
||||
"orgRole", requester.GetOrgRole(),
|
||||
)
|
||||
return nil // Fallback succeeded
|
||||
}
|
||||
|
||||
logger.Debug("access denied (fallback role not met)",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"group", req.Group,
|
||||
"fallbackRole", c.fallbackRole,
|
||||
"orgRole", requester.GetOrgRole(),
|
||||
)
|
||||
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("%s role is required", strings.ToLower(string(c.fallbackRole))))
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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 TestSessionAccessChecker_Check(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,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: tt.innerResponse,
|
||||
err: tt.innerErr,
|
||||
}
|
||||
|
||||
checker := NewSessionAccessChecker(mock)
|
||||
if tt.fallbackRole != "" {
|
||||
checker = checker.WithFallbackRole(tt.fallbackRole)
|
||||
}
|
||||
|
||||
// Add requester to context
|
||||
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 TestSessionAccessChecker_NoRequester(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: authlib.CheckResponse{Allowed: true},
|
||||
}
|
||||
|
||||
checker := NewSessionAccessChecker(mock)
|
||||
err := checker.Check(context.Background(), authlib.CheckRequest{}, "")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, apierrors.IsUnauthorized(err), "expected Unauthorized error")
|
||||
}
|
||||
|
||||
func TestSessionAccessChecker_WithFallbackRole_ImmutableOriginal(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: authlib.CheckResponse{Allowed: false},
|
||||
}
|
||||
|
||||
original := NewSessionAccessChecker(mock)
|
||||
withAdmin := original.WithFallbackRole(identity.RoleAdmin)
|
||||
withEditor := original.WithFallbackRole(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 TestSessionAccessChecker_WithFallbackRole_ChainedCalls(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: authlib.CheckResponse{Allowed: false},
|
||||
}
|
||||
|
||||
// Ensure chained WithFallbackRole calls work correctly
|
||||
checker := NewSessionAccessChecker(mock).
|
||||
WithFallbackRole(identity.RoleAdmin).
|
||||
WithFallbackRole(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 TestSessionAccessChecker_RealSignedInUser(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: authlib.CheckResponse{Allowed: false},
|
||||
}
|
||||
|
||||
checker := NewSessionAccessChecker(mock).WithFallbackRole(identity.RoleAdmin)
|
||||
|
||||
// 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 TestSessionAccessChecker_FillsNamespace(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: authlib.CheckResponse{Allowed: true},
|
||||
}
|
||||
|
||||
checker := NewSessionAccessChecker(mock)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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-app-sdk/logging"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
)
|
||||
|
||||
// tokenAccessChecker implements AccessChecker using access tokens from context.
|
||||
type tokenAccessChecker struct {
|
||||
inner authlib.AccessChecker
|
||||
}
|
||||
|
||||
// NewTokenAccessChecker creates an AccessChecker that gets identity from access tokens
|
||||
// via AuthInfoFrom(ctx). Role-based fallback is not supported.
|
||||
func NewTokenAccessChecker(inner authlib.AccessChecker) AccessChecker {
|
||||
return &tokenAccessChecker{inner: inner}
|
||||
}
|
||||
|
||||
// WithFallbackRole returns the same checker since fallback is not supported.
|
||||
func (c *tokenAccessChecker) WithFallbackRole(_ identity.RoleType) AccessChecker {
|
||||
return c
|
||||
}
|
||||
|
||||
// Check performs an access check using AuthInfo from context.
|
||||
// Returns nil if access is allowed, or an appropriate API error if denied.
|
||||
func (c *tokenAccessChecker) Check(ctx context.Context, req authlib.CheckRequest, folder string) error {
|
||||
logger := logging.FromContext(ctx).With("logger", "tokenAccessChecker")
|
||||
|
||||
// Get identity from access token in context
|
||||
id, ok := authlib.AuthInfoFrom(ctx)
|
||||
if !ok {
|
||||
logger.Debug("no auth info in context",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"namespace", req.Namespace,
|
||||
)
|
||||
return apierrors.NewUnauthorized("no auth info in context")
|
||||
}
|
||||
|
||||
logger.Debug("checking access",
|
||||
"identityType", id.GetIdentityType(),
|
||||
"namespace", id.GetNamespace(),
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"group", req.Group,
|
||||
"name", req.Name,
|
||||
"folder", folder,
|
||||
)
|
||||
|
||||
// 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}
|
||||
|
||||
if err != nil {
|
||||
logger.Debug("access check error",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"error", err.Error(),
|
||||
)
|
||||
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("%s.%s is forbidden: %w", req.Resource, req.Group, err))
|
||||
}
|
||||
if !rsp.Allowed {
|
||||
logger.Debug("access check denied",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"group", req.Group,
|
||||
"identityType", id.GetIdentityType(),
|
||||
"allowed", rsp.Allowed,
|
||||
)
|
||||
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("permission denied"))
|
||||
}
|
||||
|
||||
logger.Debug("access allowed",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTokenAccessChecker_Check(t *testing.T) {
|
||||
req := authlib.CheckRequest{
|
||||
Verb: "get",
|
||||
Group: "provisioning.grafana.app",
|
||||
Resource: "repositories",
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
innerResponse authlib.CheckResponse
|
||||
innerErr error
|
||||
authInfo *identity.StaticRequester
|
||||
expectAllow bool
|
||||
}{
|
||||
{
|
||||
name: "allowed by checker",
|
||||
innerResponse: authlib.CheckResponse{Allowed: true},
|
||||
authInfo: &identity.StaticRequester{Type: authlib.TypeUser},
|
||||
expectAllow: true,
|
||||
},
|
||||
{
|
||||
name: "denied by checker",
|
||||
innerResponse: authlib.CheckResponse{Allowed: false},
|
||||
authInfo: &identity.StaticRequester{Type: authlib.TypeUser},
|
||||
expectAllow: false,
|
||||
},
|
||||
{
|
||||
name: "error from checker",
|
||||
innerErr: errors.New("access check failed"),
|
||||
authInfo: &identity.StaticRequester{Type: authlib.TypeUser},
|
||||
expectAllow: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: tt.innerResponse,
|
||||
err: tt.innerErr,
|
||||
}
|
||||
|
||||
checker := NewTokenAccessChecker(mock)
|
||||
|
||||
// Add auth info to context
|
||||
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 TestTokenAccessChecker_NoAuthInfo(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: authlib.CheckResponse{Allowed: true},
|
||||
}
|
||||
|
||||
checker := NewTokenAccessChecker(mock)
|
||||
err := checker.Check(context.Background(), authlib.CheckRequest{}, "")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, apierrors.IsUnauthorized(err), "expected Unauthorized error")
|
||||
}
|
||||
|
||||
func TestTokenAccessChecker_WithFallbackRole_IsNoOp(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: authlib.CheckResponse{Allowed: false},
|
||||
}
|
||||
|
||||
checker := NewTokenAccessChecker(mock)
|
||||
checkerWithFallback := checker.WithFallbackRole(identity.RoleAdmin)
|
||||
|
||||
// They should be the same instance
|
||||
assert.Same(t, checker, checkerWithFallback, "WithFallbackRole should return same instance")
|
||||
}
|
||||
|
||||
func TestTokenAccessChecker_FillsNamespace(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: authlib.CheckResponse{Allowed: true},
|
||||
}
|
||||
|
||||
checker := NewTokenAccessChecker(mock)
|
||||
|
||||
ctx := authlib.WithAuthInfo(context.Background(), &identity.StaticRequester{
|
||||
Type: 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)
|
||||
}
|
||||
|
||||
// mockInnerAccessChecker implements authlib.AccessChecker for testing.
|
||||
type mockInnerAccessChecker struct {
|
||||
response authlib.CheckResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockInnerAccessChecker) Check(_ context.Context, _ authlib.AuthInfo, _ authlib.CheckRequest, _ string) (authlib.CheckResponse, error) {
|
||||
return m.response, m.err
|
||||
}
|
||||
|
||||
func (m *mockInnerAccessChecker) Compile(_ context.Context, _ authlib.AuthInfo, _ authlib.ListRequest) (authlib.ItemChecker, authlib.Zookie, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
@@ -178,7 +178,7 @@ func setupFromConfig(cfg *setting.Cfg, registry prometheus.Registerer) (controll
|
||||
APIPath: "/apis",
|
||||
Host: url,
|
||||
WrapTransport: transport.WrapperFunc(func(rt http.RoundTripper) http.RoundTripper {
|
||||
return authrt.NewRoundTripper(tokenExchangeClient, rt, group)
|
||||
return authrt.NewRoundTripper(tokenExchangeClient, rt, group, authrt.ExtraAudience(provisioning.GROUP))
|
||||
}),
|
||||
Transport: &http.Transport{
|
||||
MaxConnsPerHost: 100,
|
||||
|
||||
@@ -12,6 +12,12 @@ const (
|
||||
ActionProvisioningRepositoriesRead = "provisioning.repositories:read" // GET + LIST.
|
||||
ActionProvisioningRepositoriesDelete = "provisioning.repositories:delete" // DELETE.
|
||||
|
||||
// Connections
|
||||
ActionProvisioningConnectionsCreate = "provisioning.connections:create" // CREATE.
|
||||
ActionProvisioningConnectionsWrite = "provisioning.connections:write" // UPDATE.
|
||||
ActionProvisioningConnectionsRead = "provisioning.connections:read" // GET + LIST.
|
||||
ActionProvisioningConnectionsDelete = "provisioning.connections:delete" // DELETE.
|
||||
|
||||
// Jobs
|
||||
ActionProvisioningJobsCreate = "provisioning.jobs:create" // CREATE.
|
||||
ActionProvisioningJobsWrite = "provisioning.jobs:write" // UPDATE.
|
||||
@@ -20,6 +26,12 @@ const (
|
||||
|
||||
// Historic Jobs
|
||||
ActionProvisioningHistoricJobsRead = "provisioning.historicjobs:read" // GET + LIST.
|
||||
|
||||
// Settings (read-only, needed by multiple UI pages)
|
||||
ActionProvisioningSettingsRead = "provisioning.settings:read" // GET + LIST.
|
||||
|
||||
// Stats (read-only, admin-only)
|
||||
ActionProvisioningStatsRead = "provisioning.stats:read" // GET + LIST.
|
||||
)
|
||||
|
||||
func registerAccessControlRoles(service accesscontrol.Service) error {
|
||||
@@ -63,6 +75,46 @@ func registerAccessControlRoles(service accesscontrol.Service) error {
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
// Connections
|
||||
connectionsReader := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:provisioning.connections:reader",
|
||||
DisplayName: "Connections Reader",
|
||||
Description: "Read and list provisioning connections.",
|
||||
Group: "Provisioning",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{
|
||||
Action: ActionProvisioningConnectionsRead,
|
||||
},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
connectionsWriter := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:provisioning.connections:writer",
|
||||
DisplayName: "Connections Writer",
|
||||
Description: "Create, update and delete provisioning connections.",
|
||||
Group: "Provisioning",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{
|
||||
Action: ActionProvisioningConnectionsCreate,
|
||||
},
|
||||
{
|
||||
Action: ActionProvisioningConnectionsRead,
|
||||
},
|
||||
{
|
||||
Action: ActionProvisioningConnectionsWrite,
|
||||
},
|
||||
{
|
||||
Action: ActionProvisioningConnectionsDelete,
|
||||
},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
// Jobs
|
||||
jobsReader := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
@@ -119,11 +171,47 @@ func registerAccessControlRoles(service accesscontrol.Service) error {
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
// Settings - granted to Viewer (accessible by all logged-in users)
|
||||
settingsReader := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:provisioning.settings:reader",
|
||||
DisplayName: "Settings Reader",
|
||||
Description: "Read provisioning settings.",
|
||||
Group: "Provisioning",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{
|
||||
Action: ActionProvisioningSettingsRead,
|
||||
},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleViewer)},
|
||||
}
|
||||
|
||||
// Stats - granted to Admin only
|
||||
statsReader := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:provisioning.stats:reader",
|
||||
DisplayName: "Stats Reader",
|
||||
Description: "Read provisioning stats.",
|
||||
Group: "Provisioning",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{
|
||||
Action: ActionProvisioningStatsRead,
|
||||
},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
return service.DeclareFixedRoles(
|
||||
repositoriesReader,
|
||||
repositoriesWriter,
|
||||
connectionsReader,
|
||||
connectionsWriter,
|
||||
jobsReader,
|
||||
jobsWriter,
|
||||
historicJobsReader,
|
||||
settingsReader,
|
||||
statsReader,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,9 +13,10 @@ 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"
|
||||
)
|
||||
|
||||
@@ -26,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}
|
||||
}
|
||||
|
||||
@@ -74,179 +75,233 @@ func (c *filesConnector) Connect(ctx context.Context, name string, opts runtime.
|
||||
ctx = logging.Context(ctx, logger)
|
||||
|
||||
return WithTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
repo, err := c.getRepo(ctx, r.Method, name)
|
||||
if err != nil {
|
||||
logger.Debug("failed to find repository", "error", err)
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
readWriter, ok := repo.(repository.ReaderWriter)
|
||||
if !ok {
|
||||
responder.Error(apierrors.NewBadRequest("repository does not support read-writing"))
|
||||
return
|
||||
}
|
||||
|
||||
parser, err := c.parsers.GetParser(ctx, readWriter)
|
||||
if err != nil {
|
||||
responder.Error(fmt.Errorf("failed to get parser: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
clients, err := c.clients.Clients(ctx, repo.Config().Namespace)
|
||||
if err != nil {
|
||||
responder.Error(fmt.Errorf("failed to get clients: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
folderClient, err := clients.Folder(ctx)
|
||||
if err != nil {
|
||||
responder.Error(fmt.Errorf("failed to get folder client: %w", err))
|
||||
return
|
||||
}
|
||||
folders := resources.NewFolderManager(readWriter, folderClient, resources.NewEmptyFolderTree())
|
||||
dualReadWriter := resources.NewDualReadWriter(readWriter, parser, folders, c.access)
|
||||
query := r.URL.Query()
|
||||
opts := resources.DualWriteOptions{
|
||||
Ref: query.Get("ref"),
|
||||
Message: query.Get("message"),
|
||||
SkipDryRun: query.Get("skipDryRun") == "true",
|
||||
OriginalPath: query.Get("originalPath"),
|
||||
Branch: repo.Config().Branch(),
|
||||
}
|
||||
logger := logger.With("url", r.URL.Path, "ref", opts.Ref, "message", opts.Message)
|
||||
ctx := logging.Context(r.Context(), logger)
|
||||
|
||||
opts.Path, err = pathAfterPrefix(r.URL.Path, fmt.Sprintf("/%s/files", name))
|
||||
if err != nil {
|
||||
responder.Error(apierrors.NewBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := resources.IsPathSupported(opts.Path); err != nil {
|
||||
responder.Error(apierrors.NewBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
isDir := safepath.IsDir(opts.Path)
|
||||
if r.Method == http.MethodGet && isDir {
|
||||
files, err := c.listFolderFiles(ctx, opts.Path, opts.Ref, readWriter)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
responder.Object(http.StatusOK, files)
|
||||
return
|
||||
}
|
||||
|
||||
if opts.Path == "" {
|
||||
responder.Error(apierrors.NewBadRequest("missing request path"))
|
||||
return
|
||||
}
|
||||
|
||||
var obj *provisioning.ResourceWrapper
|
||||
code := http.StatusOK
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
resource, err := dualReadWriter.Read(ctx, opts.Path, opts.Ref)
|
||||
if err != nil {
|
||||
respondWithError(responder, err)
|
||||
return
|
||||
}
|
||||
obj = resource.AsResourceWrapper()
|
||||
case http.MethodPost:
|
||||
// Check if this is a move operation first (originalPath query parameter is present)
|
||||
if opts.OriginalPath != "" {
|
||||
// For move operations, only read body for file moves (not directory moves)
|
||||
if !isDir {
|
||||
opts.Data, err = readBody(r, filesMaxBodySize)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resource, err := dualReadWriter.MoveResource(ctx, opts)
|
||||
if err != nil {
|
||||
respondWithError(responder, err)
|
||||
return
|
||||
}
|
||||
obj = resource.AsResourceWrapper()
|
||||
} else if isDir {
|
||||
obj, err = dualReadWriter.CreateFolder(ctx, opts)
|
||||
} else {
|
||||
opts.Data, err = readBody(r, filesMaxBodySize)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var resource *resources.ParsedResource
|
||||
resource, err = dualReadWriter.CreateResource(ctx, opts)
|
||||
if err != nil {
|
||||
respondWithError(responder, err)
|
||||
return
|
||||
}
|
||||
obj = resource.AsResourceWrapper()
|
||||
}
|
||||
case http.MethodPut:
|
||||
// TODO: document in API specification
|
||||
if isDir {
|
||||
err = apierrors.NewMethodNotSupported(provisioning.RepositoryResourceInfo.GroupResource(), r.Method)
|
||||
} else {
|
||||
opts.Data, err = readBody(r, filesMaxBodySize)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
resource, err := dualReadWriter.UpdateResource(ctx, opts)
|
||||
if err != nil {
|
||||
respondWithError(responder, err)
|
||||
return
|
||||
}
|
||||
obj = resource.AsResourceWrapper()
|
||||
}
|
||||
case http.MethodDelete:
|
||||
resource, err := dualReadWriter.Delete(ctx, opts)
|
||||
if err != nil {
|
||||
respondWithError(responder, err)
|
||||
return
|
||||
}
|
||||
obj = resource.AsResourceWrapper()
|
||||
default:
|
||||
err = apierrors.NewMethodNotSupported(provisioning.RepositoryResourceInfo.GroupResource(), r.Method)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Debug("got an error after processing request", "error", err)
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(obj.Errors) > 0 {
|
||||
code = http.StatusPartialContent
|
||||
}
|
||||
|
||||
logger.Debug("request resulted in valid object", "object", obj)
|
||||
responder.Object(code, obj)
|
||||
c.handleRequest(ctx, name, r, responder, logger)
|
||||
}), 30*time.Second), nil
|
||||
}
|
||||
|
||||
// listFolderFiles returns a list of files in a folder
|
||||
func (c *filesConnector) listFolderFiles(ctx context.Context, filePath string, ref string, readWriter repository.ReaderWriter) (*provisioning.FileList, error) {
|
||||
id, err := identity.GetRequester(ctx)
|
||||
// handleRequest processes the HTTP request for files operations.
|
||||
func (c *filesConnector) handleRequest(ctx context.Context, name string, r *http.Request, responder rest.Responder, logger logging.Logger) {
|
||||
repo, err := c.getRepo(ctx, r.Method, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("missing auth info in context")
|
||||
logger.Debug("failed to find repository", "error", err)
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: replace with access check on the repo itself
|
||||
if !id.GetOrgRole().Includes(identity.RoleAdmin) {
|
||||
return nil, apierrors.NewForbidden(resources.DashboardResource.GroupResource(), "",
|
||||
fmt.Errorf("requires admin role"))
|
||||
readWriter, ok := repo.(repository.ReaderWriter)
|
||||
if !ok {
|
||||
responder.Error(apierrors.NewBadRequest("repository does not support read-writing"))
|
||||
return
|
||||
}
|
||||
|
||||
dualReadWriter, err := c.createDualReadWriter(ctx, repo, readWriter)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
opts, err := c.parseRequestOptions(r, name, repo)
|
||||
if err != nil {
|
||||
responder.Error(apierrors.NewBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
logger = logger.With("url", r.URL.Path, "ref", opts.Ref, "message", opts.Message)
|
||||
ctx = logging.Context(r.Context(), logger)
|
||||
|
||||
// Handle directory listing separately
|
||||
isDir := safepath.IsDir(opts.Path)
|
||||
if r.Method == http.MethodGet && isDir {
|
||||
c.handleDirectoryListing(ctx, name, opts, readWriter, responder)
|
||||
return
|
||||
}
|
||||
|
||||
if opts.Path == "" {
|
||||
responder.Error(apierrors.NewBadRequest("missing request path"))
|
||||
return
|
||||
}
|
||||
|
||||
obj, err := c.handleMethodRequest(ctx, r, opts, isDir, dualReadWriter)
|
||||
if err != nil {
|
||||
logger.Debug("got an error after processing request", "error", err)
|
||||
respondWithError(responder, err)
|
||||
return
|
||||
}
|
||||
|
||||
code := http.StatusOK
|
||||
if len(obj.Errors) > 0 {
|
||||
code = http.StatusPartialContent
|
||||
}
|
||||
|
||||
logger.Debug("request resulted in valid object", "object", obj)
|
||||
responder.Object(code, obj)
|
||||
}
|
||||
|
||||
// createDualReadWriter sets up the dual read writer with all required dependencies.
|
||||
func (c *filesConnector) createDualReadWriter(ctx context.Context, repo repository.Repository, readWriter repository.ReaderWriter) (*resources.DualReadWriter, error) {
|
||||
parser, err := c.parsers.GetParser(ctx, readWriter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get parser: %w", err)
|
||||
}
|
||||
|
||||
clients, err := c.clients.Clients(ctx, repo.Config().Namespace)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get clients: %w", err)
|
||||
}
|
||||
|
||||
folderClient, err := clients.Folder(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get folder client: %w", err)
|
||||
}
|
||||
|
||||
folders := resources.NewFolderManager(readWriter, folderClient, resources.NewEmptyFolderTree())
|
||||
return resources.NewDualReadWriter(readWriter, parser, folders, c.access), nil
|
||||
}
|
||||
|
||||
// parseRequestOptions extracts options from the HTTP request.
|
||||
func (c *filesConnector) parseRequestOptions(r *http.Request, name string, repo repository.Repository) (resources.DualWriteOptions, error) {
|
||||
query := r.URL.Query()
|
||||
opts := resources.DualWriteOptions{
|
||||
Ref: query.Get("ref"),
|
||||
Message: query.Get("message"),
|
||||
SkipDryRun: query.Get("skipDryRun") == "true",
|
||||
OriginalPath: query.Get("originalPath"),
|
||||
Branch: repo.Config().Branch(),
|
||||
}
|
||||
|
||||
path, err := pathAfterPrefix(r.URL.Path, fmt.Sprintf("/%s/files", name))
|
||||
if err != nil {
|
||||
return opts, err
|
||||
}
|
||||
opts.Path = path
|
||||
|
||||
if err := resources.IsPathSupported(opts.Path); err != nil {
|
||||
return opts, err
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
// handleDirectoryListing handles GET requests for directory listing.
|
||||
func (c *filesConnector) handleDirectoryListing(ctx context.Context, name string, opts resources.DualWriteOptions, readWriter repository.ReaderWriter, responder rest.Responder) {
|
||||
if err := c.authorizeListFiles(ctx, name); err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
files, err := c.listFolderFiles(ctx, opts.Path, opts.Ref, readWriter)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
responder.Object(http.StatusOK, files)
|
||||
}
|
||||
|
||||
// handleMethodRequest routes the request to the appropriate handler based on HTTP method.
|
||||
func (c *filesConnector) handleMethodRequest(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
return c.handleGet(ctx, opts, dualReadWriter)
|
||||
case http.MethodPost:
|
||||
return c.handlePost(ctx, r, opts, isDir, dualReadWriter)
|
||||
case http.MethodPut:
|
||||
return c.handlePut(ctx, r, opts, isDir, dualReadWriter)
|
||||
case http.MethodDelete:
|
||||
return c.handleDelete(ctx, opts, dualReadWriter)
|
||||
default:
|
||||
return nil, apierrors.NewMethodNotSupported(provisioning.RepositoryResourceInfo.GroupResource(), r.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *filesConnector) handleGet(ctx context.Context, opts resources.DualWriteOptions, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
resource, err := dualReadWriter.Read(ctx, opts.Path, opts.Ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resource.AsResourceWrapper(), nil
|
||||
}
|
||||
|
||||
func (c *filesConnector) handlePost(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
// Check if this is a move operation (originalPath query parameter is present)
|
||||
if opts.OriginalPath != "" {
|
||||
return c.handleMove(ctx, r, opts, isDir, dualReadWriter)
|
||||
}
|
||||
|
||||
if isDir {
|
||||
return dualReadWriter.CreateFolder(ctx, opts)
|
||||
}
|
||||
|
||||
data, err := readBody(r, filesMaxBodySize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.Data = data
|
||||
|
||||
resource, err := dualReadWriter.CreateResource(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resource.AsResourceWrapper(), nil
|
||||
}
|
||||
|
||||
func (c *filesConnector) handleMove(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
// For move operations, only read body for file moves (not directory moves)
|
||||
if !isDir {
|
||||
data, err := readBody(r, filesMaxBodySize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.Data = data
|
||||
}
|
||||
|
||||
resource, err := dualReadWriter.MoveResource(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resource.AsResourceWrapper(), nil
|
||||
}
|
||||
|
||||
func (c *filesConnector) handlePut(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
if isDir {
|
||||
return nil, apierrors.NewMethodNotSupported(provisioning.RepositoryResourceInfo.GroupResource(), r.Method)
|
||||
}
|
||||
|
||||
data, err := readBody(r, filesMaxBodySize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.Data = data
|
||||
|
||||
resource, err := dualReadWriter.UpdateResource(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resource.AsResourceWrapper(), nil
|
||||
}
|
||||
|
||||
func (c *filesConnector) handleDelete(ctx context.Context, opts resources.DualWriteOptions, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
resource, err := dualReadWriter.Delete(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resource.AsResourceWrapper(), nil
|
||||
}
|
||||
|
||||
// authorizeListFiles checks if the user has repositories:read permission for listing files.
|
||||
// The access checker handles AccessPolicy identities, namespace resolution, and role-based fallback internally.
|
||||
func (c *filesConnector) authorizeListFiles(ctx context.Context, repoName string) error {
|
||||
return c.access.Check(ctx, authlib.CheckRequest{
|
||||
Verb: utils.VerbGet,
|
||||
Group: provisioning.GROUP,
|
||||
Resource: provisioning.RepositoryResourceInfo.GetName(),
|
||||
Name: repoName,
|
||||
}, "")
|
||||
}
|
||||
|
||||
// listFolderFiles returns a list of files in a folder.
|
||||
// Authorization is checked via authorizeListFiles before calling this function.
|
||||
func (c *filesConnector) listFolderFiles(ctx context.Context, filePath string, ref string, readWriter repository.ReaderWriter) (*provisioning.FileList, error) {
|
||||
// TODO: Implement folder navigation
|
||||
if len(filePath) > 0 {
|
||||
return nil, apierrors.NewBadRequest("folder navigation not yet supported")
|
||||
|
||||
@@ -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,10 @@ type APIBuilder struct {
|
||||
unified resource.ResourceClient
|
||||
repoFactory repository.Factory
|
||||
client client.ProvisioningV0alpha1Interface
|
||||
access authlib.AccessChecker
|
||||
access auth.AccessChecker
|
||||
accessWithAdmin auth.AccessChecker
|
||||
accessWithEditor auth.AccessChecker
|
||||
accessWithViewer auth.AccessChecker
|
||||
statusPatcher *appcontroller.RepositoryStatusPatcher
|
||||
healthChecker *controller.HealthChecker
|
||||
validator repository.RepositoryValidator
|
||||
@@ -158,6 +162,14 @@ func NewAPIBuilder(
|
||||
parsers := resources.NewParserFactory(clients)
|
||||
resourceLister := resources.NewResourceListerForMigrations(unified)
|
||||
|
||||
// Create access checker based on mode
|
||||
var accessChecker auth.AccessChecker
|
||||
if useExclusivelyAccessCheckerForAuthz {
|
||||
accessChecker = auth.NewTokenAccessChecker(access)
|
||||
} else {
|
||||
accessChecker = auth.NewSessionAccessChecker(access)
|
||||
}
|
||||
|
||||
b := &APIBuilder{
|
||||
onlyApiServer: onlyApiServer,
|
||||
tracer: tracer,
|
||||
@@ -170,7 +182,10 @@ func NewAPIBuilder(
|
||||
resourceLister: resourceLister,
|
||||
dashboardAccess: dashboardAccess,
|
||||
unified: unified,
|
||||
access: access,
|
||||
access: accessChecker,
|
||||
accessWithAdmin: accessChecker.WithFallbackRole(identity.RoleAdmin),
|
||||
accessWithEditor: accessChecker.WithFallbackRole(identity.RoleEditor),
|
||||
accessWithViewer: accessChecker.WithFallbackRole(identity.RoleViewer),
|
||||
jobHistoryConfig: jobHistoryConfig,
|
||||
extraWorkers: extraWorkers,
|
||||
restConfigGetter: restConfigGetter,
|
||||
@@ -298,161 +313,142 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
// authorizeResource handles authorization for different resources.
|
||||
// Different routes may need different permissions.
|
||||
// * Reading and modifying a repository's configuration requires administrator privileges.
|
||||
// * Reading a repository's limited configuration (/stats & /settings) requires viewer privileges.
|
||||
// * Reading a repository's files requires viewer privileges.
|
||||
// * Reading a repository's refs requires viewer privileges.
|
||||
// * Editing a repository's files requires editor privileges.
|
||||
// * Syncing a repository requires editor privileges.
|
||||
// * Exporting a repository requires administrator privileges.
|
||||
// * Migrating a repository requires administrator privileges.
|
||||
// * Testing a repository configuration requires administrator privileges.
|
||||
// * Viewing a repository's history requires editor privileges.
|
||||
func (b *APIBuilder) authorizeResource(ctx context.Context, a authorizer.Attributes, id identity.Requester) (authorizer.Decision, string, error) {
|
||||
// Uses fine-grained permissions defined in accesscontrol.go:
|
||||
//
|
||||
// Repositories:
|
||||
// - CRUD: repositories:create/read/write/delete
|
||||
// - Subresources: files (any auth), refs (editor), resources/history/status (admin)
|
||||
// - Test: repositories:write
|
||||
// - Jobs subresource: jobs:create/read
|
||||
//
|
||||
// Connections:
|
||||
// - CRUD: connections:create/read/write/delete
|
||||
// - Status: connections:read
|
||||
//
|
||||
// Jobs:
|
||||
// - CRUD: jobs:create/read/write/delete
|
||||
//
|
||||
// Historic Jobs:
|
||||
// - Read-only: historicjobs:read
|
||||
//
|
||||
// Settings:
|
||||
// - settings:read - granted to Viewer (all logged-in users)
|
||||
//
|
||||
// Stats:
|
||||
// - stats:read - granted to Admin only
|
||||
func (b *APIBuilder) authorizeResource(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
switch a.GetResource() {
|
||||
case provisioning.RepositoryResourceInfo.GetName():
|
||||
return b.authorizeRepositorySubresource(a, id)
|
||||
case "stats":
|
||||
return b.authorizeStats(id)
|
||||
case "settings":
|
||||
return b.authorizeSettings(id)
|
||||
case provisioning.JobResourceInfo.GetName(), provisioning.HistoricJobResourceInfo.GetName():
|
||||
return b.authorizeJobs(id)
|
||||
return b.authorizeRepositorySubresource(ctx, a)
|
||||
case provisioning.ConnectionResourceInfo.GetName():
|
||||
return b.authorizeConnectionSubresource(a, id)
|
||||
return b.authorizeConnectionSubresource(ctx, a)
|
||||
case provisioning.JobResourceInfo.GetName():
|
||||
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 toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{
|
||||
Verb: a.GetVerb(),
|
||||
Group: provisioning.GROUP,
|
||||
Resource: provisioning.HistoricJobResourceInfo.GetName(),
|
||||
Name: a.GetName(),
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
case "settings":
|
||||
// Settings are read-only and accessible by all logged-in users (Viewer role)
|
||||
return toAuthorizerDecision(b.accessWithViewer.Check(ctx, authlib.CheckRequest{
|
||||
Verb: a.GetVerb(),
|
||||
Group: provisioning.GROUP,
|
||||
Resource: "settings",
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
case "stats":
|
||||
// Stats are read-only and admin-only
|
||||
return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{
|
||||
Verb: a.GetVerb(),
|
||||
Group: provisioning.GROUP,
|
||||
Resource: "stats",
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
default:
|
||||
return b.authorizeDefault(id)
|
||||
return b.authorizeDefault(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// authorizeRepositorySubresource handles authorization for repository subresources.
|
||||
func (b *APIBuilder) authorizeRepositorySubresource(a authorizer.Attributes, id identity.Requester) (authorizer.Decision, string, error) {
|
||||
// TODO: Support more fine-grained permissions than the basic roles. Especially on Enterprise.
|
||||
switch a.GetSubresource() {
|
||||
case "", "test":
|
||||
// Doing something with the repository itself.
|
||||
if id.GetOrgRole().Includes(identity.RoleAdmin) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "admin role is required", nil
|
||||
|
||||
case "jobs":
|
||||
// Posting jobs requires editor privileges (for syncing).
|
||||
if id.GetOrgRole().Includes(identity.RoleAdmin) || id.GetOrgRole().Includes(identity.RoleEditor) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "editor role is required", nil
|
||||
|
||||
case "refs":
|
||||
// This is strictly a read operation. It is handy on the frontend for viewers.
|
||||
if id.GetOrgRole().Includes(identity.RoleViewer) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "viewer role is required", nil
|
||||
|
||||
case "files":
|
||||
// Access to files is controlled by the AccessClient
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
|
||||
case "resources", "sync", "history":
|
||||
// These are strictly read operations.
|
||||
// Sync can also be somewhat destructive, but it's expected to be fine to import changes.
|
||||
if id.GetOrgRole().Includes(identity.RoleEditor) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "editor role is required", nil
|
||||
|
||||
case "status":
|
||||
if id.GetOrgRole().Includes(identity.RoleViewer) && a.GetVerb() == apiutils.VerbGet {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "users cannot update the status of a repository", nil
|
||||
|
||||
default:
|
||||
if id.GetIsGrafanaAdmin() {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "unmapped subresource defaults to no access", nil
|
||||
}
|
||||
}
|
||||
|
||||
// authorizeStats handles authorization for stats resource.
|
||||
func (b *APIBuilder) authorizeStats(id identity.Requester) (authorizer.Decision, string, error) {
|
||||
// This can leak information one shouldn't necessarily have access to.
|
||||
if id.GetOrgRole().Includes(identity.RoleAdmin) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "admin role is required", nil
|
||||
}
|
||||
|
||||
// authorizeSettings handles authorization for settings resource.
|
||||
func (b *APIBuilder) authorizeSettings(id identity.Requester) (authorizer.Decision, string, error) {
|
||||
// This is strictly a read operation. It is handy on the frontend for viewers.
|
||||
if id.GetOrgRole().Includes(identity.RoleViewer) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "viewer role is required", nil
|
||||
}
|
||||
|
||||
// authorizeJobs handles authorization for job resources.
|
||||
func (b *APIBuilder) authorizeJobs(id identity.Requester) (authorizer.Decision, string, error) {
|
||||
// Jobs are shown on the configuration page.
|
||||
if id.GetOrgRole().Includes(identity.RoleAdmin) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "admin role is required", nil
|
||||
}
|
||||
|
||||
// authorizeRepositorySubresource handles authorization for connections subresources.
|
||||
func (b *APIBuilder) authorizeConnectionSubresource(a authorizer.Attributes, id identity.Requester) (authorizer.Decision, string, error) {
|
||||
// Uses the access checker with verb-based authorization.
|
||||
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 "":
|
||||
// Doing something with the connection itself.
|
||||
if id.GetOrgRole().Includes(identity.RoleAdmin) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "admin role is required", nil
|
||||
case "status":
|
||||
if id.GetOrgRole().Includes(identity.RoleViewer) && a.GetVerb() == apiutils.VerbGet {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "users cannot update the status of a connection", nil
|
||||
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 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.
|
||||
// Individual file operations are authorized by DualReadWriter based on the actual resource.
|
||||
case "files":
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
|
||||
// refs subresource - editors need to see branches to push changes
|
||||
case "refs":
|
||||
return toAuthorizerDecision(b.accessWithEditor.Check(ctx, authlib.CheckRequest{
|
||||
Verb: apiutils.VerbGet,
|
||||
Group: provisioning.GROUP,
|
||||
Resource: provisioning.RepositoryResourceInfo.GetName(),
|
||||
Name: a.GetName(),
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
|
||||
// Read-only subresources: resources, history, status (admin only)
|
||||
case "resources", "history", "status":
|
||||
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 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
|
||||
}
|
||||
@@ -460,8 +456,60 @@ func (b *APIBuilder) authorizeConnectionSubresource(a authorizer.Attributes, id
|
||||
}
|
||||
}
|
||||
|
||||
// authorizeConnectionSubresource handles authorization for connection subresources.
|
||||
// Uses the access checker with verb-based authorization.
|
||||
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 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 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
|
||||
}
|
||||
return authorizer.DecisionDeny, "unmapped subresource defaults to no access", nil
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Authorization helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// toAuthorizerDecision converts an access check error to an authorizer decision tuple.
|
||||
func toAuthorizerDecision(err error) (authorizer.Decision, string, error) {
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, err.Error(), nil
|
||||
}
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -558,7 +606,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
|
||||
|
||||
// TODO: Add some logic so that the connectors can registered themselves and we don't have logic all over the place
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, repository.NewRepositoryTesterWithExistingChecker(repository.NewSimpleRepositoryTester(b.validator), b.VerifyAgainstExistingRepositories))
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, b.access)
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, b.accessWithAdmin)
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("refs")] = NewRefsConnector(b)
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("resources")] = &listConnector{
|
||||
getter: b,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -279,8 +279,11 @@ func NewMapperRegistry() MapperRegistry {
|
||||
},
|
||||
"provisioning.grafana.app": {
|
||||
"repositories": newResourceTranslation("provisioning.repositories", "uid", false, skipScopeOnAllVerbs),
|
||||
"connections": newResourceTranslation("provisioning.connections", "uid", false, skipScopeOnAllVerbs),
|
||||
"jobs": newResourceTranslation("provisioning.jobs", "uid", false, skipScopeOnAllVerbs),
|
||||
"historicjobs": newResourceTranslation("provisioning.historicjobs", "uid", false, skipScopeOnAllVerbs),
|
||||
"settings": newResourceTranslation("provisioning.settings", "", false, skipScopeOnAllVerbs),
|
||||
"stats": newResourceTranslation("provisioning.stats", "", false, skipScopeOnAllVerbs),
|
||||
},
|
||||
"secret.grafana.app": {
|
||||
"securevalues": newResourceTranslation("secret.securevalues", "uid", false, nil),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
)
|
||||
|
||||
func TestIntegrationProvisioning_ConnectionStatusAuthorization(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
|
||||
|
||||
// Create a connection for testing
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
"metadata": map[string]any{
|
||||
"name": "connection-status-test",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"type": "github",
|
||||
"github": map[string]any{
|
||||
"appID": "123456",
|
||||
"installationID": "454545",
|
||||
},
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "someSecret",
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.NoError(t, err, "failed to create connection")
|
||||
|
||||
t.Run("admin can GET connection status", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.AdminREST.Get().
|
||||
Namespace("default").
|
||||
Resource("connections").
|
||||
Name("connection-status-test").
|
||||
SubResource("status").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.NoError(t, result.Error(), "admin should be able to GET connection status")
|
||||
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
||||
})
|
||||
|
||||
t.Run("editor cannot GET connection status", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.EditorREST.Get().
|
||||
Namespace("default").
|
||||
Resource("connections").
|
||||
Name("connection-status-test").
|
||||
SubResource("status").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.Error(t, result.Error(), "editor should not be able to GET connection status")
|
||||
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
||||
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
||||
})
|
||||
|
||||
t.Run("viewer cannot GET connection status", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.ViewerREST.Get().
|
||||
Namespace("default").
|
||||
Resource("connections").
|
||||
Name("connection-status-test").
|
||||
SubResource("status").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.Error(t, result.Error(), "viewer should not be able to GET connection status")
|
||||
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
||||
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
)
|
||||
|
||||
func TestIntegrationProvisioning_HistoricJobsAuthorization(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const repo = "historicjobs-auth-test"
|
||||
testRepo := TestRepo{
|
||||
Name: repo,
|
||||
Target: "folder",
|
||||
Copies: map[string]string{}, // No files needed for this test
|
||||
ExpectedDashboards: 0,
|
||||
ExpectedFolders: 1, // Repository creates a folder
|
||||
}
|
||||
helper.CreateRepo(t, testRepo)
|
||||
|
||||
// Trigger a job to create a historic job entry
|
||||
jobSpec := provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPull,
|
||||
Pull: &provisioning.SyncJobOptions{},
|
||||
}
|
||||
body := asJSON(jobSpec)
|
||||
|
||||
// Create a job as admin
|
||||
var statusCode int
|
||||
result := helper.AdminREST.Post().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("jobs").
|
||||
Body(body).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
require.NoError(t, result.Error(), "should be able to create job")
|
||||
require.Equal(t, http.StatusAccepted, statusCode)
|
||||
|
||||
// Wait for job to complete and become historic
|
||||
helper.AwaitJobs(t, repo)
|
||||
historicJob := helper.AwaitLatestHistoricJob(t, repo)
|
||||
require.NotNil(t, historicJob, "should have a historic job")
|
||||
|
||||
historicJobName := historicJob.GetName()
|
||||
|
||||
t.Run("admin can GET historic job", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.AdminREST.Get().
|
||||
Namespace("default").
|
||||
Resource("historicjobs").
|
||||
Name(historicJobName).
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.NoError(t, result.Error(), "admin should be able to GET historic job")
|
||||
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
||||
})
|
||||
|
||||
t.Run("editor cannot GET historic job", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.EditorREST.Get().
|
||||
Namespace("default").
|
||||
Resource("historicjobs").
|
||||
Name(historicJobName).
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.Error(t, result.Error(), "editor should not be able to GET historic job")
|
||||
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
||||
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
||||
})
|
||||
|
||||
t.Run("viewer cannot GET historic job", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.ViewerREST.Get().
|
||||
Namespace("default").
|
||||
Resource("historicjobs").
|
||||
Name(historicJobName).
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.Error(t, result.Error(), "viewer should not be able to GET historic job")
|
||||
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
||||
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
)
|
||||
|
||||
func TestIntegrationProvisioning_RepositorySubresourcesAuthorization(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const repo = "subresources-auth-test"
|
||||
testRepo := TestRepo{
|
||||
Name: repo,
|
||||
Target: "folder",
|
||||
Copies: map[string]string{}, // No files needed for this test
|
||||
ExpectedDashboards: 0,
|
||||
ExpectedFolders: 1, // Repository creates a folder
|
||||
}
|
||||
helper.CreateRepo(t, testRepo)
|
||||
|
||||
t.Run("test subresource", func(t *testing.T) {
|
||||
newRepoConfig := map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Repository",
|
||||
"spec": map[string]any{
|
||||
"title": "Test Configuration",
|
||||
"type": "local",
|
||||
"local": map[string]any{
|
||||
"path": helper.ProvisioningPath,
|
||||
},
|
||||
"workflows": []string{"write"},
|
||||
"sync": map[string]any{
|
||||
"enabled": true,
|
||||
"target": "folder",
|
||||
"intervalSeconds": 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
configBytes, err := json.Marshal(newRepoConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("admin can POST test", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.AdminREST.Post().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name("test-config-auth").
|
||||
SubResource("test").
|
||||
Body(configBytes).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.NoError(t, result.Error(), "admin should be able to POST test")
|
||||
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
||||
})
|
||||
|
||||
t.Run("editor cannot POST test", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.EditorREST.Post().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name("test-config-auth").
|
||||
SubResource("test").
|
||||
Body(configBytes).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.Error(t, result.Error(), "editor should not be able to POST test")
|
||||
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
||||
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
||||
})
|
||||
|
||||
t.Run("viewer cannot POST test", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.ViewerREST.Post().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name("test-config-auth").
|
||||
SubResource("test").
|
||||
Body(configBytes).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.Error(t, result.Error(), "viewer should not be able to POST test")
|
||||
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
||||
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("resources subresource", func(t *testing.T) {
|
||||
t.Run("admin can GET resources", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.AdminREST.Get().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("resources").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.NoError(t, result.Error(), "admin should be able to GET resources")
|
||||
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
||||
})
|
||||
|
||||
t.Run("editor cannot GET resources", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.EditorREST.Get().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("resources").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.Error(t, result.Error(), "editor should not be able to GET resources")
|
||||
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
||||
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
||||
})
|
||||
|
||||
t.Run("viewer cannot GET resources", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.ViewerREST.Get().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("resources").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.Error(t, result.Error(), "viewer should not be able to GET resources")
|
||||
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
||||
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("history subresource", func(t *testing.T) {
|
||||
t.Run("admin can GET history (or BadRequest if not supported)", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.AdminREST.Get().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("history").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
// Admin should pass authorization - may get BadRequest if repo doesn't support history
|
||||
// but should NOT get Forbidden (which would indicate authorization failure)
|
||||
if result.Error() != nil {
|
||||
require.False(t, apierrors.IsForbidden(result.Error()), "admin should not get Forbidden error")
|
||||
// Local repos don't support history, so BadRequest is expected
|
||||
require.True(t, apierrors.IsBadRequest(result.Error()) || statusCode == http.StatusBadRequest,
|
||||
"should get BadRequest if history not supported, not Forbidden")
|
||||
} else {
|
||||
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK if history is supported")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("editor cannot GET history", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.EditorREST.Get().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("history").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.Error(t, result.Error(), "editor should not be able to GET history")
|
||||
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
||||
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
||||
})
|
||||
|
||||
t.Run("viewer cannot GET history", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.ViewerREST.Get().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("history").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.Error(t, result.Error(), "viewer should not be able to GET history")
|
||||
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
||||
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("status subresource", func(t *testing.T) {
|
||||
t.Run("admin can GET status", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.AdminREST.Get().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("status").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.NoError(t, result.Error(), "admin should be able to GET status")
|
||||
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
||||
})
|
||||
|
||||
t.Run("editor cannot GET status", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.EditorREST.Get().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("status").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.Error(t, result.Error(), "editor should not be able to GET status")
|
||||
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
||||
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
||||
})
|
||||
|
||||
t.Run("viewer cannot GET status", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.ViewerREST.Get().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("status").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.Error(t, result.Error(), "viewer should not be able to GET status")
|
||||
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
||||
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -956,3 +956,66 @@ func TestIntegrationProvisioning_JobPermissions(t *testing.T) {
|
||||
require.Equal(t, http.StatusAccepted, statusCode, "should return 202 Accepted")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_RefsPermissions(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const repo = "refs-permissions-test"
|
||||
testRepo := TestRepo{
|
||||
Name: repo,
|
||||
Template: "testdata/github-readonly.json.tmpl",
|
||||
Target: "folder",
|
||||
ExpectedDashboards: 3,
|
||||
ExpectedFolders: 3, // Repository creates folders
|
||||
}
|
||||
helper.CreateRepo(t, testRepo)
|
||||
|
||||
t.Run("editor can GET refs", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.EditorREST.Get().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("refs").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.NoError(t, result.Error(), "editor should be able to GET refs")
|
||||
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
||||
|
||||
// Verify we can parse the refs and it contains at least main branch
|
||||
refs := &provisioning.RefList{}
|
||||
err := result.Into(refs)
|
||||
require.NoError(t, err, "should parse refs response")
|
||||
require.NotEmpty(t, refs.Items, "should have at least one ref")
|
||||
})
|
||||
|
||||
t.Run("viewer cannot GET refs", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.ViewerREST.Get().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("refs").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.Error(t, result.Error(), "viewer should not be able to GET refs")
|
||||
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
||||
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
||||
})
|
||||
|
||||
t.Run("admin can GET refs", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.AdminREST.Get().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("refs").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.NoError(t, result.Error(), "admin should be able to GET refs")
|
||||
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
)
|
||||
|
||||
func TestIntegrationProvisioning_SettingsAuthorization(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("viewer can GET settings", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.ViewerREST.Get().
|
||||
Namespace("default").
|
||||
Resource("settings").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.NoError(t, result.Error(), "viewer should be able to GET settings")
|
||||
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
||||
})
|
||||
|
||||
t.Run("editor can GET settings", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.EditorREST.Get().
|
||||
Namespace("default").
|
||||
Resource("settings").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.NoError(t, result.Error(), "editor should be able to GET settings")
|
||||
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
||||
})
|
||||
|
||||
t.Run("admin can GET settings", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.AdminREST.Get().
|
||||
Namespace("default").
|
||||
Resource("settings").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.NoError(t, result.Error(), "admin should be able to GET settings")
|
||||
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_StatsAuthorization(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a repository to ensure stats endpoint has data
|
||||
const repo = "stats-auth-test"
|
||||
helper.CreateRepo(t, TestRepo{
|
||||
Name: repo,
|
||||
Target: "folder",
|
||||
Copies: map[string]string{},
|
||||
ExpectedDashboards: 0,
|
||||
ExpectedFolders: 1,
|
||||
})
|
||||
|
||||
t.Run("admin can GET stats", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.AdminREST.Get().
|
||||
Namespace("default").
|
||||
Resource("stats").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.NoError(t, result.Error(), "admin should be able to GET stats")
|
||||
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
||||
})
|
||||
|
||||
t.Run("editor cannot GET stats", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.EditorREST.Get().
|
||||
Namespace("default").
|
||||
Resource("stats").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.Error(t, result.Error(), "editor should not be able to GET stats")
|
||||
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
||||
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
||||
})
|
||||
|
||||
t.Run("viewer cannot GET stats", func(t *testing.T) {
|
||||
var statusCode int
|
||||
result := helper.ViewerREST.Get().
|
||||
Namespace("default").
|
||||
Resource("stats").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.Error(t, result.Error(), "viewer should not be able to GET stats")
|
||||
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
||||
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user