Skip to content

Commit f9b49bf

Browse files
Enable additional identity providers for machine accounts (GitHub Actions 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
1 parent 82ea152 commit f9b49bf

31 files changed

+2131
-1430
lines changed

cmd/server/app/serve.go

+9-2
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import (
1616
"github.com/spf13/viper"
1717

1818
"github.com/mindersec/minder/internal/auth"
19+
"github.com/mindersec/minder/internal/auth/githubactions"
1920
"github.com/mindersec/minder/internal/auth/jwt"
21+
"github.com/mindersec/minder/internal/auth/jwt/dynamic"
22+
"github.com/mindersec/minder/internal/auth/jwt/merged"
2023
"github.com/mindersec/minder/internal/auth/keycloak"
2124
"github.com/mindersec/minder/internal/authz"
2225
cpmetrics "github.com/mindersec/minder/internal/controlplane/metrics"
@@ -89,10 +92,14 @@ var serveCmd = &cobra.Command{
8992
if err != nil {
9093
return fmt.Errorf("failed to create issuer URL: %w\n", err)
9194
}
92-
jwt, err := jwt.NewJwtValidator(ctx, jwksUrl.String(), issUrl.String(), cfg.Identity.Server.Audience)
95+
staticJwt, err := jwt.NewJwtValidator(ctx, jwksUrl.String(), issUrl.String(), cfg.Identity.Server.Audience)
9396
if err != nil {
9497
return fmt.Errorf("failed to fetch and cache identity provider JWKS: %w\n", err)
9598
}
99+
allowedIssuers := []string{issUrl.String()}
100+
allowedIssuers = append(allowedIssuers, cfg.Identity.AdditionalIssuers...)
101+
dynamicJwt := dynamic.NewDynamicValidator(ctx, cfg.Identity.Server.Audience, allowedIssuers)
102+
jwt := merged.Validator{Validators: []jwt.Validator{staticJwt, dynamicJwt}}
96103

97104
authzc, err := authz.NewAuthzClient(&cfg.Authz, l)
98105
if err != nil {
@@ -107,7 +114,7 @@ var serveCmd = &cobra.Command{
107114
if err != nil {
108115
return fmt.Errorf("unable to create keycloak identity provider: %w", err)
109116
}
110-
idClient, err := auth.NewIdentityClient(kc)
117+
idClient, err := auth.NewIdentityClient(kc, &githubactions.GitHubActions{})
111118
if err != nil {
112119
return fmt.Errorf("unable to create identity client: %w", err)
113120
}

docs/docs/ref/proto.mdx

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/auth/context.go

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 The Minder Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package auth
5+
6+
import "context"
7+
8+
type idContextKeyType struct{}
9+
10+
var idContextKey idContextKeyType
11+
12+
// WithIdentityContext stores the identity in the context.
13+
func WithIdentityContext(ctx context.Context, identity *Identity) context.Context {
14+
return context.WithValue(ctx, idContextKey, identity)
15+
}
16+
17+
// IdentityFromContext retrieves the caller's identity from the context.
18+
// This may return `nil` or an empty Identity if the user is not authenticated.
19+
func IdentityFromContext(ctx context.Context) *Identity {
20+
id, ok := ctx.Value(idContextKey).(*Identity)
21+
if !ok {
22+
return nil
23+
}
24+
return id
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 The Minder Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Package githubactions provides an implementation of the GitHub IdentityProvider.
5+
package githubactions
6+
7+
import (
8+
"context"
9+
"errors"
10+
"net/url"
11+
"strings"
12+
13+
"github.com/lestrrat-go/jwx/v2/jwt"
14+
15+
"github.com/mindersec/minder/internal/auth"
16+
)
17+
18+
// GitHubActions is an implementation of the auth.IdentityProvider interface.
19+
type GitHubActions struct {
20+
}
21+
22+
var _ auth.IdentityProvider = (*GitHubActions)(nil)
23+
var _ auth.Resolver = (*GitHubActions)(nil)
24+
25+
var ghIssuerUrl = url.URL{
26+
Scheme: "https",
27+
Host: "token.actions.githubusercontent.com",
28+
}
29+
30+
// String implements auth.IdentityProvider.
31+
func (_ *GitHubActions) String() string {
32+
return "githubactions"
33+
}
34+
35+
// URL implements auth.IdentityProvider.
36+
func (_ *GitHubActions) URL() url.URL {
37+
return ghIssuerUrl
38+
}
39+
40+
// Resolve implements auth.IdentityProvider.
41+
func (gha *GitHubActions) Resolve(_ context.Context, id string) (*auth.Identity, error) {
42+
// GitHub Actions subjects look like:
43+
// repo:evankanderson/actions-id-token-testing:ref:refs/heads/main
44+
// however, OpenFGA does not allow the "#" or ":" characters in the subject:
45+
// https://github.com/openfga/openfga/blob/main/pkg/tuple/tuple.go#L34
46+
return &auth.Identity{
47+
UserID: strings.ReplaceAll(id, ":", "+"),
48+
HumanName: strings.ReplaceAll(id, "+", ":"),
49+
Provider: gha,
50+
}, nil
51+
}
52+
53+
// Validate implements auth.IdentityProvider.
54+
func (gha *GitHubActions) Validate(_ context.Context, token jwt.Token) (*auth.Identity, error) {
55+
expectedUrl := gha.URL()
56+
if token.Issuer() != expectedUrl.String() {
57+
return nil, errors.New("token issuer is not the expected issuer")
58+
}
59+
return &auth.Identity{
60+
UserID: strings.ReplaceAll(token.Subject(), ":", "+"),
61+
HumanName: token.Subject(),
62+
Provider: gha,
63+
}, nil
64+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 The Minder Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Package githubactions provides an implementation of the GitHub IdentityProvider.
5+
package githubactions
6+
7+
import (
8+
"context"
9+
"testing"
10+
11+
"github.com/lestrrat-go/jwx/v2/jwt"
12+
13+
"github.com/mindersec/minder/internal/auth"
14+
)
15+
16+
func TestGitHubActions_Resolve(t *testing.T) {
17+
t.Parallel()
18+
tests := []struct {
19+
name string
20+
identity string
21+
want *auth.Identity
22+
}{{
23+
name: "Resolve from storage",
24+
identity: "repo+evankanderson/actions-id-token-testing+ref+refs/heads/main",
25+
want: &auth.Identity{
26+
HumanName: "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main",
27+
UserID: "repo+evankanderson/actions-id-token-testing+ref+refs/heads/main",
28+
},
29+
}, {
30+
name: "Resolve from human input",
31+
identity: "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main",
32+
want: &auth.Identity{
33+
HumanName: "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main",
34+
UserID: "repo+evankanderson/actions-id-token-testing+ref+refs/heads/main",
35+
},
36+
}}
37+
for _, tt := range tests {
38+
t.Run(tt.name, func(t *testing.T) {
39+
t.Parallel()
40+
gha := &GitHubActions{}
41+
42+
got, err := gha.Resolve(context.Background(), tt.identity)
43+
if err != nil {
44+
t.Errorf("GitHubActions.Resolve() error = %v", err)
45+
}
46+
47+
tt.want.Provider = gha
48+
if tt.want.String() != got.String() {
49+
t.Errorf("GitHubActions.Resolve() = %v, want %v", got.String(), tt.want.String())
50+
}
51+
if tt.want.Human() != got.Human() {
52+
t.Errorf("GitHubActions.Resolve() = %v, want %v", got.Human(), tt.want.Human())
53+
}
54+
})
55+
}
56+
}
57+
58+
func TestGitHubActions_Validate(t *testing.T) {
59+
t.Parallel()
60+
tests := []struct {
61+
name string
62+
input func() jwt.Token
63+
want *auth.Identity
64+
wantErr bool
65+
}{{
66+
name: "Validate token",
67+
input: func() jwt.Token {
68+
tok := jwt.New()
69+
_ = tok.Set("iss", "https://token.actions.githubusercontent.com")
70+
_ = tok.Set("sub", "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main")
71+
return tok
72+
},
73+
want: &auth.Identity{
74+
HumanName: "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main",
75+
UserID: "repo+evankanderson/actions-id-token-testing+ref+refs/heads/main",
76+
},
77+
}, {
78+
name: "Validate token with invalid issuer",
79+
input: func() jwt.Token {
80+
tok := jwt.New()
81+
_ = tok.Set("iss", "https://issuer.minder.com/")
82+
_ = tok.Set("sub", "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main")
83+
return tok
84+
},
85+
want: nil,
86+
wantErr: true,
87+
}}
88+
for _, tt := range tests {
89+
t.Run(tt.name, func(t *testing.T) {
90+
t.Parallel()
91+
gha := &GitHubActions{}
92+
got, err := gha.Validate(context.Background(), tt.input())
93+
if (err != nil) != tt.wantErr {
94+
t.Errorf("GitHubActions.Validate() error = %v, wantErr %v", err, tt.wantErr)
95+
return
96+
}
97+
98+
if !tt.wantErr {
99+
tt.want.Provider = gha
100+
}
101+
if tt.want.String() != got.String() {
102+
t.Errorf("GitHubActions.Validate() = %v, want %v", got.String(), tt.want.String())
103+
}
104+
if tt.want.Human() != got.Human() {
105+
t.Errorf("GitHubActions.Validate() = %v, want %v", got.Human(), tt.want.Human())
106+
}
107+
})
108+
}
109+
}

internal/auth/interface.go

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/lestrrat-go/jwx/v2/jwt"
1414
"github.com/puzpuzpuz/xsync/v3"
15+
"github.com/rs/zerolog"
1516
)
1617

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

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

0 commit comments

Comments
 (0)