Skip to content
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
7 changes: 5 additions & 2 deletions central/auth/datastore/datastore.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package datastore
import (
"context"

"github.com/stackrox/rox/central/auth/m2m"
"github.com/stackrox/rox/central/auth/store"
"github.com/stackrox/rox/generated/storage"
)
Expand All @@ -14,9 +15,11 @@ type DataStore interface {
AddAuthM2MConfig(ctx context.Context, config *storage.AuthMachineToMachineConfig) (*storage.AuthMachineToMachineConfig, error)
UpdateAuthM2MConfig(ctx context.Context, config *storage.AuthMachineToMachineConfig) error
RemoveAuthM2MConfig(ctx context.Context, id string) error

GetTokenExchanger(ctx context.Context, issuer string) (m2m.TokenExchanger, bool)
}

// New returns an instance of an auth machine to machine Datastore.
func New(store store.Store) DataStore {
return &datastoreImpl{store: store}
func New(store store.Store, set m2m.TokenExchangerSet) DataStore {
return &datastoreImpl{store: store, set: set}
}
110 changes: 110 additions & 0 deletions central/auth/datastore/datastore_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,151 @@ package datastore

import (
"context"
"errors"

"github.com/stackrox/rox/central/auth/m2m"
"github.com/stackrox/rox/central/auth/store"
"github.com/stackrox/rox/generated/storage"
"github.com/stackrox/rox/pkg/sac"
"github.com/stackrox/rox/pkg/sac/resources"
"github.com/stackrox/rox/pkg/sync"
)

var (
_ DataStore = (*datastoreImpl)(nil)

accessSAC = sac.ForResource(resources.Access)
)

type datastoreImpl struct {
store store.Store
set m2m.TokenExchangerSet

mutex sync.RWMutex
}

func (d *datastoreImpl) GetAuthM2MConfig(ctx context.Context, id string) (*storage.AuthMachineToMachineConfig, bool, error) {
d.mutex.RLock()
defer d.mutex.RUnlock()
return d.getAuthM2MConfigNoLock(ctx, id)
}

func (d *datastoreImpl) getAuthM2MConfigNoLock(ctx context.Context, id string) (*storage.AuthMachineToMachineConfig, bool, error) {
return d.store.Get(ctx, id)
}

func (d *datastoreImpl) ListAuthM2MConfigs(ctx context.Context) ([]*storage.AuthMachineToMachineConfig, error) {
d.mutex.RLock()
defer d.mutex.RUnlock()
return d.listAuthM2MConfigsNoLock(ctx)
}

func (d *datastoreImpl) listAuthM2MConfigsNoLock(ctx context.Context) ([]*storage.AuthMachineToMachineConfig, error) {
return d.store.GetAll(ctx)
}

func (d *datastoreImpl) AddAuthM2MConfig(ctx context.Context, config *storage.AuthMachineToMachineConfig) (*storage.AuthMachineToMachineConfig, error) {
d.mutex.Lock()
defer d.mutex.Unlock()
exchanger, err := d.set.NewTokenExchangerFromConfig(ctx, config)
if err != nil {
return nil, err
}

if err := d.store.Upsert(ctx, config); err != nil {
return nil, err
}

if err := d.set.UpsertTokenExchanger(exchanger, config.GetIssuer()); err != nil {
return nil, err
}
return config, nil
}

func (d *datastoreImpl) UpdateAuthM2MConfig(ctx context.Context, config *storage.AuthMachineToMachineConfig) error {
d.mutex.Lock()
defer d.mutex.Unlock()

existingConfig, exists, err := d.getAuthM2MConfigNoLock(ctx, config.GetId())
if err != nil {
return err
}

exchanger, err := d.set.NewTokenExchangerFromConfig(ctx, config)
if err != nil {
return err
}

if err := d.store.Upsert(ctx, config); err != nil {
return err
}

if err := d.set.UpsertTokenExchanger(exchanger, config.GetIssuer()); err != nil {
return err
}

// We need to ensure that any previously existing config is removed from the token exchanger set.
// Since this updated config may have updated the issuer, we need to fetch the existing, stored config from the
// database and ensure it's removed properly from the set. We do this at the end since we want the new config
// to successfully exist beforehand.
if exists && config.GetIssuer() != existingConfig.GetIssuer() {
if err := d.set.RemoveTokenExchanger(existingConfig.GetIssuer()); err != nil {
return err
}
}

return nil
}

func (d *datastoreImpl) GetTokenExchanger(ctx context.Context, issuer string) (m2m.TokenExchanger, bool) {
if err := sac.VerifyAuthzOK(accessSAC.ReadAllowed(ctx)); err != nil {
return nil, false
}
return d.set.GetTokenExchanger(issuer)
}

func (d *datastoreImpl) RemoveAuthM2MConfig(ctx context.Context, id string) error {
d.mutex.Lock()
defer d.mutex.Unlock()

config, exists, err := d.getAuthM2MConfigNoLock(ctx, id)
if err != nil {
return err
}
if !exists {
return nil
}
if err := d.set.RemoveTokenExchanger(config.GetIssuer()); err != nil {
return err
}

return d.store.Delete(ctx, id)
}

func (d *datastoreImpl) InitializeTokenExchangers() error {
d.mutex.Lock()
defer d.mutex.Unlock()
ctx := sac.WithGlobalAccessScopeChecker(context.Background(), sac.AllowFixedScopes(
sac.AccessModeScopeKeys(storage.Access_READ_ACCESS), sac.ResourceScopeKeys(resources.Access)))

configs, err := d.listAuthM2MConfigsNoLock(ctx)
if err != nil {
return err
}

var tokenExchangerErrors error
for _, config := range configs {
exchanger, err := d.set.NewTokenExchangerFromConfig(ctx, config)
if err != nil {
tokenExchangerErrors = errors.Join(tokenExchangerErrors, err)
continue
}
if err := d.set.UpsertTokenExchanger(exchanger, config.GetId()); err != nil {
tokenExchangerErrors = errors.Join(tokenExchangerErrors, err)
}
}
if tokenExchangerErrors != nil {
return tokenExchangerErrors
}
return tokenExchangerErrors
}
10 changes: 9 additions & 1 deletion central/auth/datastore/datastore_impl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"testing"

"github.com/stackrox/rox/central/auth/m2m/mocks"
pgStore "github.com/stackrox/rox/central/auth/store/postgres"
roleDataStore "github.com/stackrox/rox/central/role/datastore"
permissionSetPostgresStore "github.com/stackrox/rox/central/role/store/permissionset/postgres"
Expand All @@ -19,6 +20,7 @@ import (
"github.com/stackrox/rox/pkg/set"
"github.com/stackrox/rox/pkg/uuid"
"github.com/stretchr/testify/suite"
"go.uber.org/mock/gomock"
)

const (
Expand All @@ -42,6 +44,7 @@ type datastorePostgresTestSuite struct {
pool *pgtest.TestPostgres
authDataStore DataStore
roleDataStore roleDataStore.DataStore
mockSet *mocks.MockTokenExchangerSet
}

func (s *datastorePostgresTestSuite) SetupTest() {
Expand All @@ -56,7 +59,6 @@ func (s *datastorePostgresTestSuite) SetupTest() {
s.Require().NotNil(s.pool)

store := pgStore.New(s.pool.DB)
s.authDataStore = New(store)

permSetStore := permissionSetPostgresStore.New(s.pool.DB)
accessScopeStore := accessScopePostgresStore.New(s.pool.DB)
Expand All @@ -67,6 +69,12 @@ func (s *datastorePostgresTestSuite) SetupTest() {

s.addRoles()

s.mockSet = mocks.NewMockTokenExchangerSet(gomock.NewController(s.T()))
s.mockSet.EXPECT().UpsertTokenExchanger(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
s.mockSet.EXPECT().RemoveTokenExchanger(gomock.Any()).Return(nil).AnyTimes()
s.mockSet.EXPECT().GetTokenExchanger(gomock.Any()).Return(nil, true).AnyTimes()
s.mockSet.EXPECT().NewTokenExchangerFromConfig(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
s.authDataStore = New(store, s.mockSet)
}

func (s *datastorePostgresTestSuite) TearDownTest() {
Expand Down
14 changes: 13 additions & 1 deletion central/auth/datastore/singleton.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package datastore

import (
"github.com/stackrox/rox/central/auth/m2m"
pgStore "github.com/stackrox/rox/central/auth/store/postgres"
"github.com/stackrox/rox/central/globaldb"
"github.com/stackrox/rox/central/jwt"
roleDataStore "github.com/stackrox/rox/central/role/datastore"
"github.com/stackrox/rox/pkg/sync"
"github.com/stackrox/rox/pkg/utils"
)

var (
Expand All @@ -15,7 +19,15 @@ var (
// Singleton provides a singleton auth machine to machine DataStore.
func Singleton() DataStore {
once.Do(func() {
ds = New(pgStore.New(globaldb.GetPostgres()))
set := m2m.TokenExchangerSetSingleton(roleDataStore.Singleton(), jwt.IssuerFactorySingleton())
ds = New(pgStore.New(globaldb.GetPostgres()), set)

// On initialization of the store, list all existing configs and fill the set.
// However, we do this in the background since the creation of the token exchanger
// will reach out to the OIDC provider's configuration endpoint.
go func() {
utils.Should(ds.(*datastoreImpl).InitializeTokenExchangers())
}()
})
return ds
}
132 changes: 132 additions & 0 deletions central/auth/m2m/claims.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package m2m

import (
"fmt"

"github.com/coreos/go-oidc/v3/oidc"
"github.com/pkg/errors"
"github.com/stackrox/rox/generated/storage"
"github.com/stackrox/rox/pkg/auth/tokens"
"github.com/stackrox/rox/pkg/stringutils"
"github.com/stackrox/rox/pkg/utils"
)

var (
_ claimExtractor = (*githubClaimExtractor)(nil)
_ claimExtractor = (*genericClaimExtractor)(nil)
)

type claimExtractor interface {
ExtractRoxClaims(idToken *oidc.IDToken) (tokens.RoxClaims, error)
}

func newClaimExtractorFromConfig(config *storage.AuthMachineToMachineConfig) claimExtractor {
if config.GetType() == storage.AuthMachineToMachineConfig_GENERIC {
return &genericClaimExtractor{configID: config.GetId()}
}

return &githubClaimExtractor{configID: config.GetId()}
}

type genericClaimExtractor struct {
configID string
}

func (g *genericClaimExtractor) ExtractRoxClaims(idToken *oidc.IDToken) (tokens.RoxClaims, error) {
var unstructured map[string]interface{}
if err := idToken.Claims(&unstructured); err != nil {
return tokens.RoxClaims{}, errors.Wrap(err, "extracting claims")
}

return createRoxClaimsFromGenericClaims(idToken.Subject, unstructured), nil
}

func createRoxClaimsFromGenericClaims(subject string, unstructured map[string]interface{}) tokens.RoxClaims {
stringClaims := mapToStringClaims(unstructured)

friendlyName := getFriendlyName(stringClaims)

userID := utils.IfThenElse(friendlyName == "", subject,
fmt.Sprintf("%s|%s", subject, friendlyName))

userClaims := &tokens.ExternalUserClaim{
UserID: userID,
FullName: stringutils.FirstNonEmpty(friendlyName, userID),
Attributes: mapToStringClaims(unstructured),
}

return tokens.RoxClaims{
ExternalUser: userClaims,
Name: userID,
}
}

func getFriendlyName(claims map[string][]string) string {
// These are some sample claims that typically have the user's name or email.
userNameClaims := []string{
"email",
"preferred_username",
"full_name",
}

for _, userNameClaim := range userNameClaims {
if value, ok := claims[userNameClaim]; ok && len(value) == 1 {
return value[0]
}
}

return ""
}

type githubClaimExtractor struct {
configID string
}

// Claims of the ID token issued for github actions.
// See: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#understanding-the-oidc-token
type githubActionClaims struct {
Actor string `json:"actor"`
ActorID string `json:"actor_id"`
Environment string `json:"environment"`
EventName string `json:"event_name"`
GitRef string `json:"ref"`
Repository string `json:"repository"`
RepositoryOwner string `json:"repository_owner"`
}

func (g *githubClaimExtractor) ExtractRoxClaims(idToken *oidc.IDToken) (tokens.RoxClaims, error) {
// OIDC tokens issued for GitHub Actions have special claims, we'll reuse them.
var claims githubActionClaims
if err := idToken.Claims(&claims); err != nil {
return tokens.RoxClaims{}, errors.Wrap(err, "extracting GitHub Actions claims")
}

return createRoxClaimsFromGitHubClaims(idToken.Subject, idToken.Audience, claims), nil
}

func createRoxClaimsFromGitHubClaims(subject string, audiences []string, claims githubActionClaims) tokens.RoxClaims {
// This is in-line with the user ID we use for other auth providers, where a mix of username + ID wil be used.
// In general, "|" is used as a separator for auth attributes.
actorWithID := fmt.Sprintf("%s|%s", claims.ActorID, claims.Actor)

userClaims := &tokens.ExternalUserClaim{
UserID: actorWithID,
FullName: claims.Actor,
Attributes: map[string][]string{
"actor": {claims.Actor},
"actor_id": {claims.ActorID},
"repository": {claims.Repository},
"repository_owner": {claims.RepositoryOwner},
"environment": {claims.Environment},
"event_name": {claims.EventName},
"ref": {claims.GitRef},
"sub": {subject},
"aud": audiences,
},
}

return tokens.RoxClaims{
ExternalUser: userClaims,
Name: actorWithID,
}
}
Loading