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:
1 parent
ea38dfcbdc
commit
f4fa66ae9c
8 files changed
+160
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user