Add role ARN support for OIDC identity provider (#13651)

- Allows setting a role policy parameter when configuring OIDC provider

- When role policy is set, the server prints a role ARN usable in STS API requests

- The given role policy is applied to STS API requests when the roleARN parameter is provided.

- Service accounts for role policy are also possible and work as expected.
This commit is contained in:
Aditya Manthramurthy
2021-11-26 19:22:40 -08:00
committed by GitHub
parent 4ce6d35e30
commit 4c0f48c548
12 changed files with 582 additions and 80 deletions

View File

@@ -245,9 +245,10 @@ func getClaimsFromToken(token string) (map[string]interface{}, error) {
// Session token must have a policy, reject requests without policy
// claim.
_, pokOpenID := claims.MapClaims[iamPolicyClaimNameOpenID()]
_, pokOpenIDClaimName := claims.MapClaims[iamPolicyClaimNameOpenID()]
_, pokOpenIDRoleArn := claims.MapClaims[roleArnClaim]
_, pokSA := claims.MapClaims[iamPolicyClaimNameSA()]
if !pokOpenID && !pokSA {
if !pokOpenIDClaimName && !pokOpenIDRoleArn && !pokSA {
return nil, errAuthentication
}

View File

@@ -327,7 +327,7 @@ func validateConfig(s config.Config) error {
}
}
if _, err := openid.LookupConfig(s[config.IdentityOpenIDSubSys][config.Default],
NewGatewayHTTPTransport(), xhttp.DrainBody); err != nil {
NewGatewayHTTPTransport(), xhttp.DrainBody, globalSite.Region); err != nil {
return err
}
@@ -513,7 +513,7 @@ func lookupConfigs(s config.Config, objAPI ObjectLayer) {
}
globalOpenIDConfig, err = openid.LookupConfig(s[config.IdentityOpenIDSubSys][config.Default],
NewGatewayHTTPTransport(), xhttp.DrainBody)
NewGatewayHTTPTransport(), xhttp.DrainBody, globalSite.Region)
if err != nil {
logger.LogIf(ctx, fmt.Errorf("Unable to initialize OpenID: %w", err))
}

View File

@@ -33,7 +33,9 @@ import (
humanize "github.com/dustin/go-humanize"
"github.com/minio/madmin-go"
"github.com/minio/minio-go/v7/pkg/set"
"github.com/minio/minio/internal/arn"
"github.com/minio/minio/internal/auth"
"github.com/minio/minio/internal/color"
"github.com/minio/minio/internal/logger"
iampolicy "github.com/minio/pkg/iam/policy"
etcd "go.etcd.io/etcd/client/v3"
@@ -66,6 +68,8 @@ type IAMSys struct {
usersSysType UsersSysType
rolesMap map[arn.ARN]string
// Persistence layer for IAM subsystem
store *IAMStoreSys
@@ -215,6 +219,7 @@ func (sys *IAMSys) Init(ctx context.Context, objAPI ObjectLayer, etcdClient *etc
r := rand.New(rand.NewSource(time.Now().UnixNano()))
// Migrate storage format if needed.
for {
// let one of the server acquire the lock, if not let them timeout.
// which shall be retried again by this loop.
@@ -263,6 +268,7 @@ func (sys *IAMSys) Init(ctx context.Context, objAPI ObjectLayer, etcdClient *etc
break
}
// Load IAM data from storage.
for {
if err := sys.Load(retryCtx); err != nil {
if configRetriableErrors(err) {
@@ -308,7 +314,40 @@ func (sys *IAMSys) Init(ctx context.Context, objAPI ObjectLayer, etcdClient *etc
}()
}
// Start watching changes to storage.
go sys.watch(ctx)
// Load RoleARN
if roleARN, rolePolicy, enabled := globalOpenIDConfig.GetRoleInfo(); enabled {
numPolicies := len(strings.Split(rolePolicy, ","))
validPolicies, _ := sys.store.FilterPolicies(rolePolicy, "")
numValidPolicies := len(strings.Split(validPolicies, ","))
if numPolicies != numValidPolicies {
logger.LogIf(ctx, fmt.Errorf("Some specified role policies (%s) were not defined - role based policies will not be enabled.", rolePolicy))
return
}
sys.rolesMap = map[arn.ARN]string{
roleARN: rolePolicy,
}
}
sys.printIAMRoles()
}
// Prints IAM role ARNs.
func (sys *IAMSys) printIAMRoles() {
arns := sys.GetRoleARNs()
if len(arns) == 0 {
return
}
msgs := make([]string, 0, len(arns))
for _, arn := range arns {
msgs = append(msgs, color.Bold(arn))
}
logStartupMessage(fmt.Sprintf("%s %s", color.Blue("IAM Roles:"), strings.Join(msgs, " ")))
}
// HasWatcher - returns if the IAM system has a watcher to be notified of
@@ -389,6 +428,28 @@ func (sys *IAMSys) loadWatchedEvent(ctx context.Context, event iamWatchEvent) (e
return err
}
// GetRoleARNs - returns a list of enabled role ARNs.
func (sys *IAMSys) GetRoleARNs() []string {
var res []string
for arn := range sys.rolesMap {
res = append(res, arn.String())
}
return res
}
// GetRolePolicy - returns policies associated with a role ARN.
func (sys *IAMSys) GetRolePolicy(arnStr string) (string, error) {
arn, err := arn.Parse(arnStr)
if err != nil {
return "", fmt.Errorf("RoleARN parse err: %v", err)
}
rolePolicy, ok := sys.rolesMap[arn]
if !ok {
return "", fmt.Errorf("RoleARN %s is not defined.", arnStr)
}
return rolePolicy, nil
}
// DeletePolicy - deletes a canned policy from backend or etcd.
func (sys *IAMSys) DeletePolicy(ctx context.Context, policyName string) error {
if !sys.Initialized() {
@@ -1029,7 +1090,7 @@ func (sys *IAMSys) IsAllowedServiceAccount(args iampolicy.Args, parentUser strin
return false
}
// Check policy for this service account.
// Check policy for parent user of service account.
svcPolicies, err := sys.PolicyDBGet(parentUser, false, args.Groups...)
if err != nil {
logger.LogIf(GlobalContext, err)
@@ -1037,9 +1098,20 @@ func (sys *IAMSys) IsAllowedServiceAccount(args iampolicy.Args, parentUser strin
}
if len(svcPolicies) == 0 {
// If parent user has no policies, look in OpenID claims in case it exists.
policySet, ok := iampolicy.GetPoliciesFromClaims(args.Claims, iamPolicyClaimNameOpenID())
if ok {
// If parent user has no policies, check for OpenID
// claims/RoleARN in case it exists.
roleArn := args.GetRoleArn()
if roleArn != "" {
arn, err := arn.Parse(roleArn)
if err != nil {
logger.LogIf(GlobalContext, fmt.Errorf("error parsing role ARN %s: %v", roleArn, err))
return false
}
svcPolicies = newMappedPolicy(sys.rolesMap[arn]).toSlice()
} else {
// If there is no roleArn claim, check the OpenID
// provider's policy claim.
policySet, _ := iampolicy.GetPoliciesFromClaims(args.Claims, iamPolicyClaimNameOpenID())
svcPolicies = policySet.ToSlice()
}
if len(svcPolicies) == 0 {
@@ -1156,35 +1228,49 @@ func (sys *IAMSys) IsAllowedSTS(args iampolicy.Args, parentUser string) bool {
return sys.IsAllowedLDAPSTS(args, parentUser)
}
policies, ok := args.GetPolicies(iamPolicyClaimNameOpenID())
if !ok {
// When claims are set, it should have a policy claim field.
return false
var policies []string
roleArn := args.GetRoleArn()
if roleArn != "" {
arn, err := arn.Parse(roleArn)
if err != nil {
logger.LogIf(GlobalContext, fmt.Errorf("error parsing role ARN %s: %v", roleArn, err))
return false
}
policies = newMappedPolicy(sys.rolesMap[arn]).toSlice()
} else {
// If roleArn is not used, we fall back to using policy claim
// from JWT.
policySet, ok := args.GetPolicies(iamPolicyClaimNameOpenID())
if !ok {
// When claims are set, it should have a policy claim field.
return false
}
// When claims are set, it should have policies as claim.
if policySet.IsEmpty() {
// No policy, no access!
return false
}
// If policy is available for given user, check the policy.
mp, ok := sys.store.GetMappedPolicy(args.AccountName, false)
if !ok {
// No policy set for the user that we can find, no access!
return false
}
if !policySet.Equals(mp.policySet()) {
// When claims has a policy, it should match the
// policy of args.AccountName which server remembers.
// if not reject such requests.
return false
}
policies = policySet.ToSlice()
}
// When claims are set, it should have policies as claim.
if policies.IsEmpty() {
// No policy, no access!
return false
}
// If policy is available for given user, check the policy.
mp, ok := sys.store.GetMappedPolicy(args.AccountName, false)
if !ok {
// No policy set for the user that we can find, no access!
return false
}
if !policies.Equals(mp.policySet()) {
// When claims has a policy, it should match the
// policy of args.AccountName which server remembers.
// if not reject such requests.
return false
}
combinedPolicy, err := sys.store.GetPolicy(strings.Join(policies.ToSlice(), ","))
combinedPolicy, err := sys.store.GetPolicy(strings.Join(policies, ","))
if err == errNoSuchPolicy {
for pname := range policies {
for _, pname := range policies {
_, err := sys.store.GetPolicy(pname)
if err == errNoSuchPolicy {
// all policies presented in the claim should exist

View File

@@ -45,6 +45,7 @@ const (
stsAction = "Action"
stsPolicy = "Policy"
stsToken = "Token"
stsRoleArn = "RoleArn"
stsWebIdentityToken = "WebIdentityToken"
stsWebIdentityAccessToken = "WebIdentityAccessToken" // only valid if UserInfo is enabled.
stsDurationSeconds = "DurationSeconds"
@@ -73,6 +74,9 @@ const (
// LDAP claim keys
ldapUser = "ldapUser"
ldapUserN = "ldapUsername"
// Role Claim key
roleArnClaim = "roleArn"
)
func parseOpenIDParentUser(parentUser string) (userID string, err error) {
@@ -399,45 +403,42 @@ func (sts *stsAPIHandlers) AssumeRoleWithSSO(w http.ResponseWriter, r *http.Requ
}
}
var subFromToken string
if v, ok := m[subClaim]; ok {
subFromToken, _ = v.(string)
}
if subFromToken == "" {
writeSTSErrorResponse(ctx, w, true, ErrSTSInvalidParameterValue,
errors.New("STS JWT Token has `sub` claim missing, `sub` claim is mandatory"))
return
}
var issFromToken string
if v, ok := m[issClaim]; ok {
issFromToken, _ = v.(string)
}
// JWT has requested a custom claim with policy value set.
// This is a MinIO STS API specific value, this value should
// be set and configured on your identity provider as part of
// JWT custom claims.
var policyName string
policySet, ok := iampolicy.GetPoliciesFromClaims(m, iamPolicyClaimNameOpenID())
policies := strings.Join(policySet.ToSlice(), ",")
if ok {
policyName = globalIAMSys.CurrentPolicies(policies)
}
if globalPolicyOPA == nil {
if !ok {
roleArn := r.Form.Get(stsRoleArn)
if roleArn != "" {
_, err := globalIAMSys.GetRolePolicy(roleArn)
if err != nil {
writeSTSErrorResponse(ctx, w, true, ErrSTSInvalidParameterValue,
fmt.Errorf("%s claim missing from the JWT token, credentials will not be generated", iamPolicyClaimNameOpenID()))
return
} else if policyName == "" {
writeSTSErrorResponse(ctx, w, true, ErrSTSInvalidParameterValue,
fmt.Errorf("None of the given policies (`%s`) are defined, credentials will not be generated", policies))
fmt.Errorf("Error processing %s parameter: %v", stsRoleArn, err))
return
}
// If roleArn is used, we set it as a claim, and use the
// associated policy when credentials are used.
m[roleArnClaim] = roleArn
} else {
// JWT has requested a custom claim with policy value set.
// This is a MinIO STS API specific value, this value should
// be set and configured on your identity provider as part of
// JWT custom claims.
policySet, ok := iampolicy.GetPoliciesFromClaims(m, iamPolicyClaimNameOpenID())
policies := strings.Join(policySet.ToSlice(), ",")
if ok {
policyName = globalIAMSys.CurrentPolicies(policies)
}
if globalPolicyOPA == nil {
if !ok {
writeSTSErrorResponse(ctx, w, true, ErrSTSInvalidParameterValue,
fmt.Errorf("%s claim missing from the JWT token, credentials will not be generated", iamPolicyClaimNameOpenID()))
return
} else if policyName == "" {
writeSTSErrorResponse(ctx, w, true, ErrSTSInvalidParameterValue,
fmt.Errorf("None of the given policies (`%s`) are defined, credentials will not be generated", policies))
return
}
}
m[iamPolicyClaimNameOpenID()] = policyName
}
m[iamPolicyClaimNameOpenID()] = policyName
sessionPolicyStr := r.Form.Get(stsPolicy)
// https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html
@@ -476,6 +477,22 @@ func (sts *stsAPIHandlers) AssumeRoleWithSSO(w http.ResponseWriter, r *http.Requ
// this is to ensure that ParentUser doesn't change and we get to use
// parentUser as per the requirements for service accounts for OpenID
// based logins.
var subFromToken string
if v, ok := m[subClaim]; ok {
subFromToken, _ = v.(string)
}
if subFromToken == "" {
writeSTSErrorResponse(ctx, w, true, ErrSTSInvalidParameterValue,
errors.New("STS JWT Token has `sub` claim missing, `sub` claim is mandatory"))
return
}
var issFromToken string
if v, ok := m[issClaim]; ok {
issFromToken, _ = v.(string)
}
cred.ParentUser = "openid:" + subFromToken + ":" + issFromToken
// Set the newly generated credentials.

View File

@@ -741,7 +741,7 @@ const (
// SetUpOpenID - expects to setup an OpenID test server using the test OpenID
// container and canned data from https://github.com/minio/minio-ldap-testing
func (s *TestSuiteIAM) SetUpOpenID(c *check, serverAddr string) {
func (s *TestSuiteIAM) SetUpOpenID(c *check, serverAddr string, rolePolicy string) {
ctx, cancel := context.WithTimeout(context.Background(), testDefaultTimeout)
defer cancel()
@@ -750,10 +750,14 @@ func (s *TestSuiteIAM) SetUpOpenID(c *check, serverAddr string) {
fmt.Sprintf("config_url=%s/.well-known/openid-configuration", serverAddr),
"client_id=minio-client-app",
"client_secret=minio-client-app-secret",
"claim_name=groups",
"scopes=openid,groups",
"redirect_uri=http://127.0.0.1:10000/oauth_callback",
}
if rolePolicy != "" {
configCmds = append(configCmds, fmt.Sprintf("role_policy=%s", rolePolicy))
} else {
configCmds = append(configCmds, "claim_name=groups")
}
_, err := s.adm.SetConfigKV(ctx, strings.Join(configCmds, " "))
if err != nil {
c.Fatalf("unable to setup OpenID for tests: %v", err)
@@ -797,7 +801,7 @@ func TestIAMWithOpenIDServerSuite(t *testing.T) {
}
suite.SetUpSuite(c)
suite.SetUpOpenID(c, openIDServer)
suite.SetUpOpenID(c, openIDServer, "")
suite.TestOpenIDSTS(c)
suite.TestOpenIDServiceAcc(c)
suite.TearDownSuite(c)
@@ -805,3 +809,163 @@ func TestIAMWithOpenIDServerSuite(t *testing.T) {
)
}
}
func TestIAMWithOpenIDWithRolePolicyServerSuite(t *testing.T) {
baseTestCases := []TestSuiteCommon{
// Init and run test on FS backend with signature v4.
{serverType: "FS", signer: signerV4},
// Init and run test on FS backend, with tls enabled.
{serverType: "FS", signer: signerV4, secure: true},
// Init and run test on Erasure backend.
{serverType: "Erasure", signer: signerV4},
// Init and run test on ErasureSet backend.
{serverType: "ErasureSet", signer: signerV4},
}
testCases := []*TestSuiteIAM{}
for _, bt := range baseTestCases {
testCases = append(testCases,
newTestSuiteIAM(bt, false),
newTestSuiteIAM(bt, true),
)
}
for i, testCase := range testCases {
etcdStr := ""
if testCase.withEtcdBackend {
etcdStr = " (with etcd backend)"
}
t.Run(
fmt.Sprintf("Test: %d, ServerType: %s%s", i+1, testCase.serverType, etcdStr),
func(t *testing.T) {
c := &check{t, testCase.serverType}
suite := testCase
openIDServer := os.Getenv(EnvTestOpenIDServer)
if openIDServer == "" {
c.Skip("Skipping OpenID test as no OpenID server is provided.")
}
suite.SetUpSuite(c)
suite.SetUpOpenID(c, openIDServer, "readwrite")
suite.TestOpenIDSTSWithRolePolicy(c)
suite.TestOpenIDServiceAccWithRolePolicy(c)
suite.TearDownSuite(c)
},
)
}
}
const (
testRoleARN = "arn:minio:iam:::role/127.0.0.1_minio-cl"
)
func (s *TestSuiteIAM) TestOpenIDSTSWithRolePolicy(c *check) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
bucket := getRandomBucketName()
err := s.client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{})
if err != nil {
c.Fatalf("bucket create error: %v", err)
}
// Generate web identity STS token by interacting with OpenID IDP.
token, err := mockTestUserInteraction(ctx, testProvider, "dillon@example.io", "dillon")
if err != nil {
c.Fatalf("mock user err: %v", err)
}
webID := cr.STSWebIdentity{
Client: s.TestSuiteCommon.client,
STSEndpoint: s.endPoint,
GetWebIDTokenExpiry: func() (*cr.WebIdentityToken, error) {
return &cr.WebIdentityToken{
Token: token,
}, nil
},
RoleARN: testRoleARN,
}
value, err := webID.Retrieve()
if err != nil {
c.Fatalf("Expected to generate STS creds, got err: %#v", err)
}
// fmt.Printf("value: %#v\n", value)
minioClient, err := minio.New(s.endpoint, &minio.Options{
Creds: cr.NewStaticV4(value.AccessKeyID, value.SecretAccessKey, value.SessionToken),
Secure: s.secure,
Transport: s.TestSuiteCommon.client.Transport,
})
if err != nil {
c.Fatalf("Error initializing client: %v", err)
}
// Validate that the client from sts creds can access the bucket.
c.mustListObjects(ctx, minioClient, bucket)
}
func (s *TestSuiteIAM) TestOpenIDServiceAccWithRolePolicy(c *check) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
bucket := getRandomBucketName()
err := s.client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{})
if err != nil {
c.Fatalf("bucket create error: %v", err)
}
// Generate web identity STS token by interacting with OpenID IDP.
token, err := mockTestUserInteraction(ctx, testProvider, "dillon@example.io", "dillon")
if err != nil {
c.Fatalf("mock user err: %v", err)
}
webID := cr.STSWebIdentity{
Client: s.TestSuiteCommon.client,
STSEndpoint: s.endPoint,
GetWebIDTokenExpiry: func() (*cr.WebIdentityToken, error) {
return &cr.WebIdentityToken{
Token: token,
}, nil
},
RoleARN: testRoleARN,
}
value, err := webID.Retrieve()
if err != nil {
c.Fatalf("Expected to generate STS creds, got err: %#v", err)
}
// Create an madmin client with user creds
userAdmClient, err := madmin.NewWithOptions(s.endpoint, &madmin.Options{
Creds: cr.NewStaticV4(value.AccessKeyID, value.SecretAccessKey, value.SessionToken),
Secure: s.secure,
})
if err != nil {
c.Fatalf("Err creating user admin client: %v", err)
}
userAdmClient.SetCustomTransport(s.TestSuiteCommon.client.Transport)
// Create svc acc
cr := c.mustCreateSvcAccount(ctx, value.AccessKeyID, userAdmClient)
// 1. Check that svc account appears in listing
c.assertSvcAccAppearsInListing(ctx, userAdmClient, value.AccessKeyID, cr.AccessKey)
// 2. Check that svc account info can be queried
c.assertSvcAccInfoQueryable(ctx, userAdmClient, value.AccessKeyID, cr.AccessKey, true)
// 3. Check S3 access
c.assertSvcAccS3Access(ctx, s, cr, bucket)
// 4. Check that svc account can restrict the policy, and that the
// session policy can be updated.
c.assertSvcAccSessionPolicyUpdate(ctx, s, userAdmClient, value.AccessKeyID, bucket)
// 4. Check that service account's secret key and account status can be
// updated.
c.assertSvcAccSecretKeyAndStatusUpdate(ctx, s, userAdmClient, value.AccessKeyID, bucket)
// 5. Check that service account can be deleted.
c.assertSvcAccDeletion(ctx, s, userAdmClient, value.AccessKeyID, bucket)
}