Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 148802cbb5 | |||
| 9f139da063 | |||
| c350f36df8 | |||
| 5192e038ae | |||
| 22c2034a37 |
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -300,6 +300,17 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle read-only resources that use role-based authorization.
|
||||
// These resources are not registered in the access checker, so we handle them separately.
|
||||
// This allows the frontend to access settings without requiring explicit permissions.
|
||||
if a.GetResource() == "settings" || a.GetResource() == "stats" {
|
||||
id, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "failed to find requester", err
|
||||
}
|
||||
return b.authorizeResource(ctx, a, id)
|
||||
}
|
||||
|
||||
info, ok := authlib.AuthInfoFrom(ctx)
|
||||
// when running as standalone API server, the identity type may not always match TypeAccessPolicy
|
||||
// so we allow it to use the access checker if there is any auth info available
|
||||
|
||||
@@ -143,7 +143,7 @@ describe('BootstrapStep', () => {
|
||||
it('should render correct info for GitHub repository type', async () => {
|
||||
setup();
|
||||
expect(screen.getAllByText('External storage')).toHaveLength(2);
|
||||
expect(screen.getAllByText('Empty')).toHaveLength(3); // Three elements should have the role "Empty" (2 external + 1 unmanaged)
|
||||
expect(screen.getAllByText('Empty')).toHaveLength(4); // Four elements should have the role "Empty" (2 external + 2 unmanaged - one for each sync option card)
|
||||
});
|
||||
|
||||
it('should render correct info for local file repository type', async () => {
|
||||
@@ -198,7 +198,9 @@ describe('BootstrapStep', () => {
|
||||
|
||||
setup();
|
||||
|
||||
expect(await screen.findByText('7 resources')).toBeInTheDocument();
|
||||
// The resource count string appears twice because BootstrapStepResourceCounting is rendered
|
||||
// for each enabled sync option (instance and folder), and both show the resource count
|
||||
expect(await screen.findAllByText('7 resources')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,21 +209,7 @@ describe('BootstrapStep', () => {
|
||||
setup();
|
||||
|
||||
const mockUseResourceStats = require('./hooks/useResourceStats').useResourceStats;
|
||||
expect(mockUseResourceStats).toHaveBeenCalledWith('test-repo', undefined);
|
||||
});
|
||||
|
||||
it('should use useResourceStats hook with legacy storage flag', async () => {
|
||||
setup({
|
||||
settingsData: {
|
||||
legacyStorage: true,
|
||||
allowImageRendering: true,
|
||||
items: [],
|
||||
availableRepositoryTypes: [],
|
||||
},
|
||||
});
|
||||
|
||||
const mockUseResourceStats = require('./hooks/useResourceStats').useResourceStats;
|
||||
expect(mockUseResourceStats).toHaveBeenCalledWith('test-repo', true);
|
||||
expect(mockUseResourceStats).toHaveBeenCalledWith('test-repo');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,39 +221,6 @@ describe('BootstrapStep', () => {
|
||||
expect(await screen.findByText('Sync external storage to a new Grafana folder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should only display instance option when legacy storage exists', async () => {
|
||||
(useModeOptions as jest.Mock).mockReturnValue({
|
||||
enabledOptions: [
|
||||
{
|
||||
target: 'instance',
|
||||
label: 'Sync all resources with external storage',
|
||||
description: 'Resources will be synced with external storage',
|
||||
subtitle: 'Use this option if you want to sync your entire instance',
|
||||
},
|
||||
],
|
||||
disabledOptions: [
|
||||
{
|
||||
target: 'folder',
|
||||
label: 'Sync external storage to a new Grafana folder',
|
||||
description: 'A new Grafana folder will be created',
|
||||
subtitle: 'Use this option to sync into a new folder',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
setup({
|
||||
settingsData: {
|
||||
legacyStorage: true,
|
||||
allowImageRendering: true,
|
||||
items: [],
|
||||
availableRepositoryTypes: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Sync all resources with external storage')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Sync external storage to a new Grafana folder')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should allow selecting different sync targets', async () => {
|
||||
const { user } = setup();
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const BootstrapStep = memo(function BootstrapStep({ settingsData, repoNam
|
||||
const repositoryType = watch('repository.type');
|
||||
const { enabledOptions, disabledOptions } = useModeOptions(repoName, settingsData);
|
||||
const { target } = enabledOptions?.[0];
|
||||
const { resourceCountString, fileCountString, isLoading } = useResourceStats(repoName, settingsData?.legacyStorage);
|
||||
const { resourceCountString, fileCountString, isLoading } = useResourceStats(repoName);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -103,7 +103,6 @@ export const BootstrapStep = memo(function BootstrapStep({ settingsData, repoNam
|
||||
<div className={styles.divider} />
|
||||
|
||||
<BootstrapStepResourceCounting
|
||||
target={action.target}
|
||||
fileCountString={fileCountString}
|
||||
resourceCountString={resourceCountString}
|
||||
/>
|
||||
|
||||
@@ -1,40 +1,23 @@
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { Stack, Text } from '@grafana/ui';
|
||||
|
||||
import { Target } from './types';
|
||||
|
||||
export function BootstrapStepResourceCounting({
|
||||
target,
|
||||
fileCountString,
|
||||
resourceCountString,
|
||||
}: {
|
||||
target: Target;
|
||||
fileCountString: string;
|
||||
resourceCountString: string;
|
||||
}) {
|
||||
if (target === 'instance') {
|
||||
return (
|
||||
<Stack direction="row" gap={3}>
|
||||
<Stack gap={1}>
|
||||
<Trans i18nKey="provisioning.bootstrap-step.external-storage-label">External storage</Trans>
|
||||
<Text color="primary">{fileCountString}</Text>
|
||||
</Stack>
|
||||
<Stack gap={1}>
|
||||
<Trans i18nKey="provisioning.bootstrap-step.unmanaged-resources-label">Unmanaged resources</Trans>{' '}
|
||||
<Text color="primary">{resourceCountString}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (target === 'folder') {
|
||||
return (
|
||||
return (
|
||||
<Stack direction="row" gap={3}>
|
||||
<Stack gap={1}>
|
||||
<Trans i18nKey="provisioning.bootstrap-step.external-storage-label">External storage</Trans>{' '}
|
||||
<Trans i18nKey="provisioning.bootstrap-step.external-storage-label">External storage</Trans>
|
||||
<Text color="primary">{fileCountString}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
<Stack gap={1}>
|
||||
<Trans i18nKey="provisioning.bootstrap-step.unmanaged-resources-label">Unmanaged resources</Trans>{' '}
|
||||
<Text color="primary">{resourceCountString}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user