Skip to content

Commit

Permalink
Enable additional identity providers for machine accounts (GitHub Act…
Browse files Browse the repository at this point in the history
…ions enablement) (#5385)

* Commit working spike

* Relax requirement for users to be in Minder DB in two other places in authz code

* Fix stacklok/minder --> mindersec/minder

* Fix mocks in tests added after this PR started

* Clean up spike from #4317 for merge

* Fix lint and test errors, simplify interfaces

* Fix internal/authz and internal/controlplane tests

* Address feedback from eleftherias

* Fix lint errors

* Avoid using <> tag-like constructs in proto comments
  • Loading branch information
evankanderson authored Feb 3, 2025
1 parent 82ea152 commit f9b49bf
Show file tree
Hide file tree
Showing 31 changed files with 2,131 additions and 1,430 deletions.
11 changes: 9 additions & 2 deletions cmd/server/app/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import (
"github.com/spf13/viper"

"github.com/mindersec/minder/internal/auth"
"github.com/mindersec/minder/internal/auth/githubactions"
"github.com/mindersec/minder/internal/auth/jwt"
"github.com/mindersec/minder/internal/auth/jwt/dynamic"
"github.com/mindersec/minder/internal/auth/jwt/merged"
"github.com/mindersec/minder/internal/auth/keycloak"
"github.com/mindersec/minder/internal/authz"
cpmetrics "github.com/mindersec/minder/internal/controlplane/metrics"
Expand Down Expand Up @@ -89,10 +92,14 @@ var serveCmd = &cobra.Command{
if err != nil {
return fmt.Errorf("failed to create issuer URL: %w\n", err)
}
jwt, err := jwt.NewJwtValidator(ctx, jwksUrl.String(), issUrl.String(), cfg.Identity.Server.Audience)
staticJwt, err := jwt.NewJwtValidator(ctx, jwksUrl.String(), issUrl.String(), cfg.Identity.Server.Audience)
if err != nil {
return fmt.Errorf("failed to fetch and cache identity provider JWKS: %w\n", err)
}
allowedIssuers := []string{issUrl.String()}
allowedIssuers = append(allowedIssuers, cfg.Identity.AdditionalIssuers...)
dynamicJwt := dynamic.NewDynamicValidator(ctx, cfg.Identity.Server.Audience, allowedIssuers)
jwt := merged.Validator{Validators: []jwt.Validator{staticJwt, dynamicJwt}}

authzc, err := authz.NewAuthzClient(&cfg.Authz, l)
if err != nil {
Expand All @@ -107,7 +114,7 @@ var serveCmd = &cobra.Command{
if err != nil {
return fmt.Errorf("unable to create keycloak identity provider: %w", err)
}
idClient, err := auth.NewIdentityClient(kc)
idClient, err := auth.NewIdentityClient(kc, &githubactions.GitHubActions{})
if err != nil {
return fmt.Errorf("unable to create identity client: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/ref/proto.mdx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions internal/auth/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: Copyright 2025 The Minder Authors
// SPDX-License-Identifier: Apache-2.0

package auth

import "context"

type idContextKeyType struct{}

var idContextKey idContextKeyType

// WithIdentityContext stores the identity in the context.
func WithIdentityContext(ctx context.Context, identity *Identity) context.Context {
return context.WithValue(ctx, idContextKey, identity)
}

// IdentityFromContext retrieves the caller's identity from the context.
// This may return `nil` or an empty Identity if the user is not authenticated.
func IdentityFromContext(ctx context.Context) *Identity {
id, ok := ctx.Value(idContextKey).(*Identity)
if !ok {
return nil
}
return id
}
64 changes: 64 additions & 0 deletions internal/auth/githubactions/githubactions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: Copyright 2025 The Minder Authors
// SPDX-License-Identifier: Apache-2.0

// Package githubactions provides an implementation of the GitHub IdentityProvider.
package githubactions

import (
"context"
"errors"
"net/url"
"strings"

"github.com/lestrrat-go/jwx/v2/jwt"

"github.com/mindersec/minder/internal/auth"
)

// GitHubActions is an implementation of the auth.IdentityProvider interface.
type GitHubActions struct {
}

var _ auth.IdentityProvider = (*GitHubActions)(nil)
var _ auth.Resolver = (*GitHubActions)(nil)

var ghIssuerUrl = url.URL{
Scheme: "https",
Host: "token.actions.githubusercontent.com",
}

// String implements auth.IdentityProvider.
func (_ *GitHubActions) String() string {
return "githubactions"
}

// URL implements auth.IdentityProvider.
func (_ *GitHubActions) URL() url.URL {
return ghIssuerUrl
}

// Resolve implements auth.IdentityProvider.
func (gha *GitHubActions) Resolve(_ context.Context, id string) (*auth.Identity, error) {
// GitHub Actions subjects look like:
// repo:evankanderson/actions-id-token-testing:ref:refs/heads/main
// however, OpenFGA does not allow the "#" or ":" characters in the subject:
// https://github.com/openfga/openfga/blob/main/pkg/tuple/tuple.go#L34
return &auth.Identity{
UserID: strings.ReplaceAll(id, ":", "+"),
HumanName: strings.ReplaceAll(id, "+", ":"),
Provider: gha,
}, nil
}

// Validate implements auth.IdentityProvider.
func (gha *GitHubActions) Validate(_ context.Context, token jwt.Token) (*auth.Identity, error) {
expectedUrl := gha.URL()
if token.Issuer() != expectedUrl.String() {
return nil, errors.New("token issuer is not the expected issuer")
}
return &auth.Identity{
UserID: strings.ReplaceAll(token.Subject(), ":", "+"),
HumanName: token.Subject(),
Provider: gha,
}, nil
}
109 changes: 109 additions & 0 deletions internal/auth/githubactions/githubactions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// SPDX-FileCopyrightText: Copyright 2025 The Minder Authors
// SPDX-License-Identifier: Apache-2.0

// Package githubactions provides an implementation of the GitHub IdentityProvider.
package githubactions

import (
"context"
"testing"

"github.com/lestrrat-go/jwx/v2/jwt"

"github.com/mindersec/minder/internal/auth"
)

func TestGitHubActions_Resolve(t *testing.T) {
t.Parallel()
tests := []struct {
name string
identity string
want *auth.Identity
}{{
name: "Resolve from storage",
identity: "repo+evankanderson/actions-id-token-testing+ref+refs/heads/main",
want: &auth.Identity{
HumanName: "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main",
UserID: "repo+evankanderson/actions-id-token-testing+ref+refs/heads/main",
},
}, {
name: "Resolve from human input",
identity: "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main",
want: &auth.Identity{
HumanName: "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main",
UserID: "repo+evankanderson/actions-id-token-testing+ref+refs/heads/main",
},
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gha := &GitHubActions{}

got, err := gha.Resolve(context.Background(), tt.identity)
if err != nil {
t.Errorf("GitHubActions.Resolve() error = %v", err)
}

tt.want.Provider = gha
if tt.want.String() != got.String() {
t.Errorf("GitHubActions.Resolve() = %v, want %v", got.String(), tt.want.String())
}
if tt.want.Human() != got.Human() {
t.Errorf("GitHubActions.Resolve() = %v, want %v", got.Human(), tt.want.Human())
}
})
}
}

func TestGitHubActions_Validate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input func() jwt.Token
want *auth.Identity
wantErr bool
}{{
name: "Validate token",
input: func() jwt.Token {
tok := jwt.New()
_ = tok.Set("iss", "https://token.actions.githubusercontent.com")
_ = tok.Set("sub", "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main")
return tok
},
want: &auth.Identity{
HumanName: "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main",
UserID: "repo+evankanderson/actions-id-token-testing+ref+refs/heads/main",
},
}, {
name: "Validate token with invalid issuer",
input: func() jwt.Token {
tok := jwt.New()
_ = tok.Set("iss", "https://issuer.minder.com/")
_ = tok.Set("sub", "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main")
return tok
},
want: nil,
wantErr: true,
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gha := &GitHubActions{}
got, err := gha.Validate(context.Background(), tt.input())
if (err != nil) != tt.wantErr {
t.Errorf("GitHubActions.Validate() error = %v, wantErr %v", err, tt.wantErr)
return
}

if !tt.wantErr {
tt.want.Provider = gha
}
if tt.want.String() != got.String() {
t.Errorf("GitHubActions.Validate() = %v, want %v", got.String(), tt.want.String())
}
if tt.want.Human() != got.Human() {
t.Errorf("GitHubActions.Validate() = %v, want %v", got.Human(), tt.want.Human())
}
})
}
}
2 changes: 2 additions & 0 deletions internal/auth/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/puzpuzpuz/xsync/v3"
"github.com/rs/zerolog"
)

//go:generate go run go.uber.org/mock/mockgen -package mock_$GOPACKAGE -destination=./mock/$GOFILE -source=./$GOFILE
Expand Down Expand Up @@ -119,6 +120,7 @@ func NewIdentityClient(providers ...IdentityProvider) (*IdentityClient, error) {
}
for _, p := range providers {
u := p.URL() // URL's String has a pointer receiver
zerolog.Ctx(context.Background()).Debug().Str("provider", p.String()).Str("url", u.String()).Msg("Registering provider")

prev, ok := c.providers.LoadOrStore(p.String(), p)
if ok { // We had an existing value, this is a configuration error.
Expand Down
Loading

0 comments on commit f9b49bf

Please sign in to comment.