Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable additional identity providers for machine accounts (GitHub Actions enablement) #5385

Merged
merged 14 commits into from
Feb 3, 2025
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 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,12 @@ 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)
}
dynamicJwt := dynamic.NewDynamicValidator(ctx, cfg.Identity.Server.Audience)
jwt := merged.Validator{Validators: []jwt.Validator{staticJwt, dynamicJwt}}
evankanderson marked this conversation as resolved.
Show resolved Hide resolved

authzc, err := authz.NewAuthzClient(&cfg.Authz, l)
if err != nil {
Expand All @@ -107,7 +112,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{})
eleftherias marked this conversation as resolved.
Show resolved Hide resolved
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.

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

package auth

import "context"

type idContextKeyType struct{}

var idContextKey idContextKeyType

func WithIdentityContext(ctx context.Context, identity *Identity) context.Context {

Check failure on line 12 in internal/auth/context.go

View workflow job for this annotation

GitHub Actions / lint / Run golangci-lint

exported: exported function WithIdentityContext should have comment or be unexported (revive)
return context.WithValue(ctx, idContextKey, identity)
}

func IdentityFromContext(ctx context.Context) *Identity {

Check failure on line 16 in internal/auth/context.go

View workflow job for this annotation

GitHub Actions / lint / Run golangci-lint

exported: exported function IdentityFromContext should have comment or be unexported (revive)
id, ok := ctx.Value(idContextKey).(*Identity)
if !ok {
return nil
}
return id
}
76 changes: 76 additions & 0 deletions internal/auth/githubactions/githubactions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//
// Copyright 2024 Stacklok, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
evankanderson marked this conversation as resolved.
Show resolved Hide resolved

// 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
}
121 changes: 121 additions & 0 deletions internal/auth/githubactions/githubactions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//
// Copyright 2024 Stacklok, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// 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
Loading