Compare commits

...

5 Commits

Author SHA1 Message Date
Roberto Jimenez Sanchez 148802cbb5 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.
2025-12-17 15:55:16 +01:00
Roberto Jimenez Sanchez 9f139da063 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)
2025-12-17 15:43:33 +01:00
Roberto Jimenez Sanchez c350f36df8 Merge remote-tracking branch 'origin/main' into feat/round-tripper-extra-audience-option 2025-12-17 14:41:05 +01:00
Roberto Jimenez Sanchez 5192e038ae 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.
2025-12-16 18:37:17 +01:00
Roberto Jimenez Sanchez 22c2034a37 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
2025-12-16 18:32:45 +01:00
7 changed files with 93 additions and 96 deletions
+46 -14
View File
@@ -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)
+1 -1
View File
@@ -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>
);
}