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

[DDO-3305] Expose GHA OIDC claims throughout Sherlock #361

Merged
merged 6 commits into from
Nov 14, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions sherlock/.mockery.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ packages:
IRoutes:
config:
dir: ./internal/api/gin_mocks
github.com/broadinstitute/sherlock/sherlock/internal/authentication/gha_oidc:
interfaces:
mockableVerifier:
github.com/broadinstitute/sherlock/sherlock/internal/github:
interfaces:
mockableActionsClient:
Expand Down
24 changes: 24 additions & 0 deletions sherlock/internal/api/sherlock/ci_runs_v3_upsert.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"github.com/broadinstitute/sherlock/go-shared/pkg/utils"
"github.com/broadinstitute/sherlock/sherlock/internal/authentication"
"github.com/broadinstitute/sherlock/sherlock/internal/authentication/gha_oidc/gha_oidc_claims"
"github.com/broadinstitute/sherlock/sherlock/internal/config"
"github.com/broadinstitute/sherlock/sherlock/internal/deployhooks"
"github.com/broadinstitute/sherlock/sherlock/internal/deprecated_models/v2models"
Expand Down Expand Up @@ -67,6 +68,29 @@ func ciRunsV3Upsert(ctx *gin.Context) {
return
}

// Opportunistically fill empty fields with information passed in the GHA OIDC JWT
if body.Platform == "" || body.Platform == "github-actions" {
var claims *gha_oidc_claims.Claims
if claims, err = authentication.ShouldUseGithubClaims(ctx); err == nil {
body.Platform = "github-actions"
if body.GithubActionsOwner == "" {
body.GithubActionsOwner = claims.RepositoryOwner
}
if body.GithubActionsRepo == "" {
body.GithubActionsRepo = claims.TrimmedRepositoryName()
}
if body.GithubActionsRunID == 0 {
body.GithubActionsRunID, _ = utils.ParseUint(claims.RunID)
}
if body.GithubActionsAttemptNumber == 0 {
body.GithubActionsAttemptNumber, _ = utils.ParseUint(claims.RunAttempt)
}
if body.GithubActionsWorkflowPath == "" {
body.GithubActionsWorkflowPath = claims.TrimmedWorkflowPath()
}
}
}

// We want to handle the "spreading" mechanic that some of the fields have. To do that, we'll literally just re-assemble
// the body we got into one post-spread. Then we'll handle that body and de-dupe the resulting CiIdentifiers before
// adding to the database (the SQL gets messed up if there's duplicates in what we give to Gorm).
Expand Down
34 changes: 34 additions & 0 deletions sherlock/internal/api/sherlock/ci_runs_v3_upsert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package sherlock
import (
"fmt"
"github.com/broadinstitute/sherlock/go-shared/pkg/utils"
"github.com/broadinstitute/sherlock/sherlock/internal/authentication/gha_oidc"
"github.com/broadinstitute/sherlock/sherlock/internal/authentication/gha_oidc/gha_oidc_claims"
"github.com/broadinstitute/sherlock/sherlock/internal/authentication/gha_oidc/gha_oidc_mocks"
"github.com/broadinstitute/sherlock/sherlock/internal/deployhooks"
"github.com/broadinstitute/sherlock/sherlock/internal/deprecated_controllers/v2controllers"
"github.com/broadinstitute/sherlock/sherlock/internal/deprecated_models/v2models"
Expand Down Expand Up @@ -811,3 +814,34 @@ func (s *handlerSuite) TestCiRunsV3Upsert_makeSlackMessageText() {
},
}))
}

func (s *handlerSuite) TestCiRunsV3Upsert_GithubActionsClaimDefaults() {
// Note that the request body is empty!
// Normally this would result in an error due to missing fields, but suppose a GHA OIDC JWT was passed...
request := s.NewRequest(http.MethodPut, "/api/ci-runs/v3", CiRunV3Upsert{})
request.Header.Set(gha_oidc.Header, "some GHA OIDC token")

var got CiRunV3
var code int
gha_oidc.UseMockedVerifier(s.T(), func(v *gha_oidc_mocks.MockMockableVerifier) {
v.EXPECT().VerifyAndParseClaims(mock.AnythingOfType("*gin.Context"), "some GHA OIDC token").
Return(gha_oidc_claims.Claims{
RepositoryOwner: "broadinstitute",
Repository: "broadinstitute/terra-github-workflows",
WorkflowRef: "broadinstitute/terra-github-workflows/.github/workflows/bee-create.yaml@refs/heads/main",
RunID: "123456",
RunAttempt: "1",
}, nil)
}, func() {
code = s.HandleRequest(request, &got)
})

s.Equal(http.StatusCreated, code)
s.NotZero(got.ID)
s.Equal(got.Platform, "github-actions")
s.Equal(got.GithubActionsOwner, "broadinstitute")
s.Equal(got.GithubActionsRepo, "terra-github-workflows")
s.Equal(got.GithubActionsRunID, uint(123456))
s.Equal(got.GithubActionsAttemptNumber, uint(1))
s.Equal(got.GithubActionsWorkflowPath, ".github/workflows/bee-create.yaml")
}
2 changes: 1 addition & 1 deletion sherlock/internal/api/sherlock/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func (s *handlerSuite) SetupSuite() {
func (s *handlerSuite) SetupTest() {
s.TestSuiteHelper.SetupTest()
s.internalRouter = gin.New()
apiRouter := s.internalRouter.Group("api", authentication.UserMiddleware(s.DB), authentication.DbMiddleware(s.DB))
apiRouter := s.internalRouter.Group("api", authentication.Middleware(s.DB)...)
ConfigureRoutes(apiRouter)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ import (

const ctxDbFieldName = "SherlockDB"

// DbMiddleware must strictly come after UserMiddleware, its call to MustUseUser will fail otherwise.
func DbMiddleware(db *gorm.DB) gin.HandlerFunc {
func setDatabaseWithUser(db *gorm.DB) gin.HandlerFunc {
return func(ctx *gin.Context) {
user, err := MustUseUser(ctx)
if err != nil {
Expand All @@ -22,21 +21,24 @@ func DbMiddleware(db *gorm.DB) gin.HandlerFunc {
}
}

// ShouldUseDB returns a non-nil *gorm.DB with the calling user accessible via models.GetCurrentUserForDB, or an error
// if that isn't possible.
func ShouldUseDB(ctx *gin.Context) (*gorm.DB, error) {
dbValue, exists := ctx.Get(ctxDbFieldName)
if !exists {
return nil, fmt.Errorf("(%s) database authentication middleware not present", errors.InternalServerError)
return nil, fmt.Errorf("(%s) database reference not present; database authentication middleware likely not present", errors.InternalServerError)
}
db, ok := dbValue.(*gorm.DB)
if !ok {
return nil, fmt.Errorf("(%s) database authentication middleware misconfigured: db represented as %T", errors.InternalServerError, dbValue)
return nil, fmt.Errorf("(%s) database authentication middleware likely misconfigured: represented as %T", errors.InternalServerError, dbValue)
}
if db == nil {
return nil, fmt.Errorf("(%s) database reference was nil", errors.InternalServerError)
return nil, fmt.Errorf("(%s) database authentication middleware likely misconfigured: database reference was nil", errors.InternalServerError)
}
return db, nil
}

// MustUseDB is like ShouldUseDB except it calls errors.AbortRequest if there was an error so the caller doesn't have to.
func MustUseDB(ctx *gin.Context) (*gorm.DB, error) {
db, err := ShouldUseDB(ctx)
if err != nil {
Expand Down
58 changes: 58 additions & 0 deletions sherlock/internal/authentication/gha_oidc/boot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package gha_oidc

import (
"context"
"fmt"
"github.com/broadinstitute/sherlock/sherlock/internal/authentication/gha_oidc/gha_oidc_claims"
"github.com/broadinstitute/sherlock/sherlock/internal/config"
"github.com/broadinstitute/sherlock/sherlock/internal/errors"
"github.com/coreos/go-oidc"
)

// realVerifierImplementation wraps *oidc.IDTokenVerifier so we can make an actually
// mockable *oidc.IDTokenVerifier.Verify method (that method returns a non-mockable
// struct).
type realVerifierImplementation struct {
*oidc.IDTokenVerifier
}

func (r *realVerifierImplementation) VerifyAndParseClaims(ctx context.Context, rawIDToken string) (gha_oidc_claims.Claims, error) {
var claims gha_oidc_claims.Claims
if payload, err := r.Verify(ctx, rawIDToken); err != nil {
return claims, fmt.Errorf("(%s) failed to validate GHA OIDC JWT in '%s' header: %w", errors.BadRequest, Header, err)
} else if payload == nil {
return claims, fmt.Errorf("(%s) GHA OIDC JWT seemed to pass validation but payload was nil", errors.BadRequest)
} else if err = payload.Claims(&claims); err != nil {
return claims, fmt.Errorf("(%s) GHA OIDC JWT seemed to pass validation but couldn't be unmarshalled to %T: %w", errors.BadRequest, claims, err)
} else {
return claims, nil
}
}

func InitVerifier(ctx context.Context) error {
type extraConfigurationClaims struct {
IdTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
}
provider, err := oidc.NewProvider(ctx, config.Config.MustString("auth.githubActionsOIDC.issuer"))
if err != nil {
return err
}
var claims extraConfigurationClaims
if err = provider.Claims(&claims); err != nil {
return err
}
rawVerifier = &realVerifierImplementation{
provider.Verifier(&oidc.Config{
// The ClientID gets compared to the "aud" claim of the returned OIDC token.
// GitHub Actions actually allows customization of the "aud" claim when the ID token is created, so
// we can't rely on it as an actual security measure. What we're trying to do is match based the
// nonstandard "actor_id" claim to a stored GitHub user ID, so we can safely ignore the "aud" claim.
SkipClientIDCheck: true,
// The library says it defaults to RS256, but GitHub includes this information at its configuration
// endpoint, so we'll grab it to be safe.
SupportedSigningAlgs: claims.IdTokenSigningAlgValuesSupported,
}),
}
verifier = rawVerifier
return nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package gha_oidc_claims

import (
"fmt"
"strings"
)

type Claims struct {
// GitHub username that initiated the workflow
Actor string `json:"actor"`
// ID for the GitHub user that initiated the workflow
ActorID string `json:"actor_id"`
// Owner of the repo that the workflow ran in
RepositoryOwner string `json:"repository_owner"`
// Repo that the workflow ran in, given like <owner>/<name>
Repository string `json:"repository"`
// The ref path to the workflow, given like <owner>/<name>/<path>@<ref>
WorkflowRef string `json:"workflow_ref"`
// The ID of the workflow
RunID string `json:"run_id"`
// The number of times the workflow has been attempted
RunAttempt string `json:"run_attempt"`
}

// TrimmedRepositoryName provides the repository name without the leading owner and slash
func (c *Claims) TrimmedRepositoryName() string {
return strings.TrimPrefix(c.Repository, c.RepositoryOwner+"/")
}

// TrimmedWorkflowPath provides the workflow path without the leading repository owner, name, and slash or the
// trailing git ref
func (c *Claims) TrimmedWorkflowPath() string {
return strings.Split(strings.TrimPrefix(c.WorkflowRef, c.Repository+"/"), "@")[0]
}

func (c *Claims) WorkflowURL() string {
return fmt.Sprintf("https://github.com/%s/actions/runs/%s/attempts/%s", c.Repository, c.RunID, c.RunAttempt)
}
Loading
Loading