Provisioning: Reintroduce unauthenticated webhooks (#100336)

* Zanzana: Add Provisioning client

* Zanzana: Provisioning client should come late

* APIServer: Auth should let access policies through, falling back to API

* Zanzana: Use an Anonymous user to authenticate

* APIServer: Return earlier on anonymous users

* Zanzana: Align test with reality
This commit is contained in:
Mariell Hoversholm authored and GitHub committed 2025-02-10 12:37:06 +01:00
1 parent ea38dfcbdc
commit f4fa66ae9c
8 files changed
+160 -12

No files matched your search

+8 -1
View File
@@ -50,7 +50,14 @@ import (
const repoControllerWorkers = 1
var _ builder.APIGroupBuilder = (*APIBuilder)(nil)
var (
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
_ builder.APIGroupMutation = (*APIBuilder)(nil)
_ builder.APIGroupValidation = (*APIBuilder)(nil)
_ builder.APIGroupRouteProvider = (*APIBuilder)(nil)
_ builder.APIGroupPostStartHookProvider = (*APIBuilder)(nil)
_ builder.OpenAPIPostProcessor = (*APIBuilder)(nil)
)
type APIBuilder struct {
urlProvider func(namespace string) string
@@ -60,6 +60,11 @@ func (auth orgIDAuthorizer) Authorize(ctx context.Context, a authorizer.Attribut
return authorizer.DecisionNoOpinion, "", nil
}
// If we have an anonymous user, let the next authorizers decide.
if signedInUser.GetIdentityType() == claims.TypeAnonymous {
return authorizer.DecisionNoOpinion, "", nil
}
// Check if the user has access to the specified org
// nolint:staticcheck
userId, err := signedInUser.GetInternalID()
@@ -37,6 +37,11 @@ func (auth stackIDAuthorizer) Authorize(ctx context.Context, a authorizer.Attrib
return authorizer.DecisionDeny, fmt.Sprintf("error getting signed in user: %v", err), nil
}
// If we have an anonymous user, let the next authorizers decide.
if signedInUser.GetIdentityType() == claims.TypeAnonymous {
return authorizer.DecisionNoOpinion, "", nil
}
info, err := claims.ParseNamespace(a.GetNamespace())
if err != nil {
return authorizer.DecisionDeny, fmt.Sprintf("error reading namespace: %v", err), nil
+1
View File
@@ -31,6 +31,7 @@ const (
ClientSAML = "auth.client.saml"
ClientPasswordless = "auth.client.passwordless"
ClientLDAP = "ldap"
ClientProvisioning = "auth.client.apiserver.provisioning"
)
const (
@@ -124,6 +124,10 @@ func ProvideRegistration(
authnSvc.RegisterClient(clients.ProvideOAuth(clientName, cfg, oauthTokenService, socialService, settingsProviderService, features))
}
if features.IsEnabledGlobally(featuremgmt.FlagProvisioning) {
authnSvc.RegisterClient(clients.ProvideProvisioning())
}
// FIXME (jguer): move to User package
userSync := sync.ProvideUserSync(userService, userProtectionService, authInfoService, quotaService, tracer, features)
orgSync := sync.ProvideOrgSync(userService, orgService, accessControlService, cfg, tracer)
@@ -0,0 +1,62 @@
package clients
import (
"context"
"fmt"
"regexp"
"time"
claims "github.com/grafana/authlib/types"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/services/authn"
)
var (
_ authn.ContextAwareClient = (*Provisioning)(nil)
_ fmt.Stringer = (*Provisioning)(nil) // for debugging
)
type Provisioning struct {
webhookRegexp *regexp.Regexp
}
func ProvideProvisioning() *Provisioning {
// It's fine to compile a regexp here. The function is only called once per instance of APIBuilder, of which there should only ever be 1.
// Assumption: APIVERSION has no leading or trailing slashes.
webhookRegexp := regexp.MustCompile("^/apis/" + regexp.QuoteMeta(provisioning.APIVERSION) + "/namespaces/[^/]+/repositories/[^/]+/webhook$")
return &Provisioning{webhookRegexp}
}
func (p *Provisioning) String() string {
return p.Name()
}
func (*Provisioning) Name() string {
return authn.ClientProvisioning
}
func (p *Provisioning) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
return &authn.Identity{
Type: claims.TypeAnonymous,
Name: p.Name(),
UID: p.Name(),
Login: p.Name(),
AuthID: p.Name(),
OrgID: r.OrgID,
AuthenticatedBy: authn.ClientProvisioning,
LastSeenAt: time.Now(),
}, nil
}
func (*Provisioning) IsEnabled() bool {
return true
}
func (p *Provisioning) Test(ctx context.Context, r *authn.Request) bool {
path := r.HTTPRequest.URL.Path
return p.webhookRegexp.MatchString(path)
}
func (*Provisioning) Priority() uint {
return 5 // let most other clients go first
}
@@ -0,0 +1,72 @@
package clients
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
"github.com/grafana/authlib/types"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProvisioning_Test(t *testing.T) {
t.Parallel()
ctx := context.Background()
for _, tt := range []struct {
Name, URL string
Valid bool
}{
{"Root path is invalid", "https://grafana.localhost/", false},
{"Non-provisioning path", "https://grafana.localhost/hello/world", false},
{"Provisioning path that isn't webhook", "https://grafana.localhost/apis/provisioning.grafana.app/v0alpha1/namespaces/x/repositories/y/unittest", false},
{"Webhook path", "https://grafana.localhost/apis/provisioning.grafana.app/v0alpha1/namespaces/x/repositories/y/webhook", true},
{"Webhook path with subpath", "https://grafana.localhost/apis/provisioning.grafana.app/v0alpha1/namespaces/x/repositories/y/webhook/unittest", false}, // this'll have to change if we ever want subpaths
} {
t.Run(tt.Name, func(t *testing.T) {
t.Parallel()
svc := ProvideProvisioning()
url, err := url.Parse(tt.URL)
require.NoError(t, err, "couldn't parse input URL")
req := &authn.Request{HTTPRequest: &http.Request{
URL: url,
}}
assert.Equal(t, tt.Valid, svc.Test(ctx, req))
})
}
}
func TestProvisioning_Authenticate(t *testing.T) {
t.Parallel()
ctx := context.Background()
url, err := url.Parse("https://grafana.localhost/apis/provisioning.grafana.app/v0alpha1/namespaces/x/repositories/y/webhook")
require.NoError(t, err, "couldn't parse URL known to be good?")
req := &authn.Request{HTTPRequest: &http.Request{
URL: url,
}}
svc := ProvideProvisioning()
identity, err := svc.Authenticate(ctx, req)
require.NoError(t, err, "Authenticate shouldn't err")
assert.Equal(t, types.TypeAnonymous, identity.GetIdentityType(), "IdentityType")
assert.Equal(t, "auth.client.apiserver.provisioning", identity.UID, "UID")
assert.Equal(t, "auth.client.apiserver.provisioning", identity.Login, "Login")
assert.Equal(t, "auth.client.apiserver.provisioning", identity.AuthenticatedBy, "AuthenticatedBy")
assert.Equal(t, false, identity.GetIsGrafanaAdmin(), "IsAdmin")
}
func TestProvisioning_Assumptions(t *testing.T) {
t.Run("APIVERSION constant has no leading or trailing slashes", func(t *testing.T) {
assert.False(t, strings.HasPrefix(provisioning.APIVERSION, "/"), "found leading / in APIVERSION: %s", provisioning.APIVERSION)
assert.False(t, strings.HasSuffix(provisioning.APIVERSION, "/"), "found trailing / in APIVERSION: %s", provisioning.APIVERSION)
})
}
@@ -33,10 +33,7 @@ dualWriterMode = 5
# For Github webhook support, you will need something like:
[server]
root_url = https://supreme-exact-beetle.ngrok-free.app
[auth.anonymous]
enabled = true`;
root_url = https://supreme-exact-beetle.ngrok-free.app`;
const ngrok_example = `ngrok http 3000
@@ -61,10 +58,7 @@ HTTP Requests
const webhook_ini = `...
[server]
root_url = https://d60d-83-33-235-27.ngrok-free.app
[auth.anonymous]
enabled = true`;
root_url = https://d60d-83-33-235-27.ngrok-free.app`;
export function SetupWarnings() {
const [isCustomIniOpen, setCustomIniOpen] = useLocalStorage('collapse_custom_ini', true);
@@ -112,9 +106,7 @@ export function SetupWarnings() {
<Collapse isOpen={isWebhookOpen} label="Webhook Support" onToggle={handleWebhookToggle} collapsible>
<Alert severity="info" title="">
<Text element="h5">
Webhook support requires the server to run on a public URL — and (for now) anonymous access
</Text>
<Text element="h5">Webhook support requires the server to run on a public URL.</Text>
<pre>
<code>{webhook_ini}</code>
</pre>