diff --git a/USERS.md b/USERS.md index 682ae1a30122..ed879b5e9cbf 100644 --- a/USERS.md +++ b/USERS.md @@ -95,6 +95,7 @@ Currently, the following organizations are **officially** using Argo Workflows: 1. [nrd.io](https://nrd.io/) 1. [NVIDIA](https://www.nvidia.com/) 1. [Onepanel](https://docs.onepanel.ai) +1. [Oracle](https://www.oracle.com/) 1. [OVH](https://www.ovh.com/) 1. [Peak AI](https://www.peak.ai/) 1. [PDOK](https://www.pdok.nl/) diff --git a/docs/argo-server-sso.md b/docs/argo-server-sso.md index 94c528e465d3..b5d0b125e94c 100644 --- a/docs/argo-server-sso.md +++ b/docs/argo-server-sso.md @@ -31,16 +31,16 @@ argo server --auth-mode sso --auth-mode ... As of v2.12 we issue a JWE token for users rather than give them the ID token from your OAuth2 provider. This token is opaque and has a longer expiry time (10h by default). -The token encryption key is automatically generated by the Argo Server and stored in a Kubernetes secret name "sso". +The token encryption key is automatically generated by the Argo Server and stored in a Kubernetes secret name "sso". -You can revoke all tokens by deleting the encryption key and restarting the Argo Server (so it generates a new key). +You can revoke all tokens by deleting the encryption key and restarting the Argo Server (so it generates a new key). ``` kubectl delete secret sso ``` !!! Warning - The old key will be in the memory the any running Argo Server, and they will therefore accept and user with token encrypted using the old key. Every Argo Server MUST be restarted. + The old key will be in the memory the any running Argo Server, and they will therefore accept and user with token encrypted using the old key. Every Argo Server MUST be restarted. All users will need to log in again. Sorry. @@ -77,21 +77,21 @@ kind: ServiceAccount metadata: name: admin-user annotations: - # The rule is an expression used to determine if this service account - # should be used. + # The rule is an expression used to determine if this service account + # should be used. # * `groups` - an array of the OIDC groups # * `iss` - the issuer ("argo-server") # * `sub` - the subject (typically the username) - # Must evaluate to a boolean. + # Must evaluate to a boolean. # If you want an account to be the default to use, this rule can be "true". # Details of the expression language are available in # https://github.com/antonmedv/expr/blob/master/docs/Language-Definition.md. workflows.argoproj.io/rbac-rule: "'admin' in groups" # The precedence is used to determine which service account to use whe # Precedence is an integer. It may be negative. If omitted, it defaults to "0". - # Numerically higher values have higher precedence (not lower, which maybe + # Numerically higher values have higher precedence (not lower, which maybe # counter-intuitive to you). - # If two rules match and have the same precedence, then which one used will + # If two rules match and have the same precedence, then which one used will # be arbitrary. workflows.argoproj.io/rbac-rule-precedence: "1" ``` @@ -101,7 +101,7 @@ If no rule matches, we deny the user access. !!! Tip You'll probably want to configure a default account to use if no other rule matches, e.g. a read-only account, you can do this as follows: - + ```yaml metadata: name: read-only @@ -109,8 +109,8 @@ If no rule matches, we deny the user access. workflows.argoproj.io/rbac-rule: "true" workflows.argoproj.io/rbac-rule-precedence: "0" ``` - - The precedence must be the lowest of all your service accounts. + + The precedence must be the lowest of all your service accounts. ## SSO Login Time @@ -123,3 +123,23 @@ sso: # Expiry defines how long your login is valid for in hours. (optional) sessionExpiry: 240h ``` +## Custom claims + +> v3.1.4 and after + +If your OIDC provider provides groups information with a claim name other than `groups`, you could confiure config-map to specify custom claim name for groups. Argo now arbitary custom claims and any claim can be used for `expr eval`. However, since group information is displayed in UI, it still needs to be an array of strings with group names as elements. + +customClaim in this case will be mapped to `groups` key and we can use the same key `groups` for evaluating our expressions + +```yaml +sso: + # Specify custom claim name for OIDC groups. + customGroupClaimName: argo_groups +``` + + #### Example expr + +```shell +# assuming customClaimGroupName: argo_groups +workflows.argoproj.io/rbac-rule: "'argo_admins' in groups" +``` diff --git a/docs/running-locally.md b/docs/running-locally.md index cfc60e932684..6f2466eda3ae 100644 --- a/docs/running-locally.md +++ b/docs/running-locally.md @@ -15,7 +15,7 @@ We recommend using [K3D](https://k3d.io/) to set up the local Kubernetes cluster since this will allow you to test RBAC set-up and is fast. You can set-up K3D to be part of your default kube config as follows: k3d cluster start --wait - + Alternatively, you can use [Minikube](https://github.com/kubernetes/minikube) to set up the local Kubernetes cluster. Once a local Kubernetes cluster has started via `minikube start`, your kube config will use Minikube's context automatically. @@ -32,7 +32,7 @@ Add to /etc/hosts: To install into the “argo” namespace of your cluster: Argo and MinIO (for saving artifacts and logs): - make start + make start ### 4. (Optional) Set up a DB for the Workflow archive @@ -62,9 +62,20 @@ Before submitting/running workflows, build all Argo images, so they're available make build +### 7. SSO with Dex +For testing SSO integration, you can start a Argo with sso profile which will deploy +a pre-configured dex instance in argo namespace + +```sh +make start PROFILE=SSO +``` + ## Troubleshooting Notes -If you get a similar error when running one of the make pre-commit tests `make: *** [pkg/apiclient/clusterworkflowtemplate/cluster-workflow-template.swagger.json] Error 1`, ensure you are working within your $GOPATH (YOUR-GOPATH/src/github.com/argoproj/argo-workflows). +* If you get a similar error when running one of the make pre-commit tests `make: *** [pkg/apiclient/clusterworkflowtemplate/cluster-workflow-template.swagger.json] Error 1`, ensure you are working within your $GOPATH (YOUR-GOPATH/src/github.com/argoproj/argo-workflows). + +* If you encounter out of heap issues when building UI through Docker, please validate resources allocated to Docker. Compilation may fail if allocated RAM is less than 4Gi + ## Clean diff --git a/server/auth/sso/sso.go b/server/auth/sso/sso.go index 5e38a1546672..66cdcc78ba95 100644 --- a/server/auth/sso/sso.go +++ b/server/auth/sso/sso.go @@ -53,6 +53,7 @@ type sso struct { encrypter jose.Encrypter rbacConfig *rbac.Config expiry time.Duration + customClaimName string } func (s *sso) IsRBACEnabled() bool { @@ -68,6 +69,8 @@ type Config struct { // additional scopes (on top of "openid") Scopes []string `json:"scopes,omitempty"` SessionExpiry metav1.Duration `json:"sessionExpiry,omitempty"` + // customGroupClaimName will override the groups claim name + CustomGroupClaimName string `json:"customGroupClaimName,omitempty"` } func (c Config) GetSessionExpiry() time.Duration { @@ -184,6 +187,7 @@ func newSso( encrypter: encrypter, rbacConfig: c.RBAC, expiry: c.GetSessionExpiry(), + customClaimName: c.CustomGroupClaimName, }, nil } @@ -238,20 +242,37 @@ func (s *sso) HandleCallback(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(fmt.Sprintf("failed to get claims: %v", err))) return } + + // Default to groups claim but if customClaimName is set + // extract groups based on that claim key + groups := c.Groups + if s.customClaimName != "" { + groups, err = c.GetCustomGroup(s.customClaimName) + if err != nil { + w.WriteHeader(401) + _, _ = w.Write([]byte(fmt.Sprintf("failed to get custom claim: %v", err))) + return + } + } + argoClaims := &types.Claims{ Claims: jwt.Claims{ Issuer: issuer, Subject: c.Subject, Expiry: jwt.NewNumericDate(time.Now().Add(s.expiry)), }, - Groups: c.Groups, + Groups: groups, + RawClaim: c.RawClaim, Email: c.Email, EmailVerified: c.EmailVerified, ServiceAccountName: c.ServiceAccountName, } + raw, err := jwt.Encrypted(s.encrypter).Claims(argoClaims).CompactSerialize() if err != nil { - panic(err) + w.WriteHeader(401) + _, _ = w.Write([]byte(fmt.Sprintf("failed to encode claims: %v", err))) + return } value := Prefix + raw log.Debugf("handing oauth2 callback %v", value) @@ -287,9 +308,11 @@ func (s *sso) Authorize(authorization string) (*types.Claims, error) { if err := tok.Claims(s.privateKey, c); err != nil { return nil, fmt.Errorf("failed to parse claims: %v", err) } + if err := c.Validate(jwt.Expected{Issuer: issuer}); err != nil { return nil, fmt.Errorf("failed to validate claims: %v", err) } + return c, nil } diff --git a/server/auth/sso/sso_test.go b/server/auth/sso/sso_test.go index cee4facf277d..41eb8276ee65 100644 --- a/server/auth/sso/sso_test.go +++ b/server/auth/sso/sso_test.go @@ -53,16 +53,19 @@ var ssoConfigSecret = &apiv1.Secret{ func TestLoadSsoClientIdFromSecret(t *testing.T) { fakeClient := fake.NewSimpleClientset(ssoConfigSecret).CoreV1().Secrets(testNamespace) config := Config{ - Issuer: "https://test-issuer", - ClientID: getSecretKeySelector("argo-sso-secret", "client-id"), - ClientSecret: getSecretKeySelector("argo-sso-secret", "client-secret"), - RedirectURL: "https://dummy", + Issuer: "https://test-issuer", + ClientID: getSecretKeySelector("argo-sso-secret", "client-id"), + ClientSecret: getSecretKeySelector("argo-sso-secret", "client-secret"), + RedirectURL: "https://dummy", + CustomGroupClaimName: "argo_groups", } ssoInterface, err := newSso(fakeOidcFactory, config, fakeClient, "/", false) assert.NoError(t, err) ssoObject := ssoInterface.(*sso) assert.Equal(t, "sso-client-id-value", ssoObject.config.ClientID) assert.Equal(t, "sso-client-secret-value", ssoObject.config.ClientSecret) + assert.Equal(t, "argo_groups", ssoObject.customClaimName) + assert.Equal(t, 10*time.Hour, ssoObject.expiry) } diff --git a/server/auth/types/claims.go b/server/auth/types/claims.go index 1dd441ccbefc..6b6ac568cb15 100644 --- a/server/auth/types/claims.go +++ b/server/auth/types/claims.go @@ -1,11 +1,64 @@ package types -import "gopkg.in/square/go-jose.v2/jwt" +import ( + "encoding/json" + "fmt" + + "gopkg.in/square/go-jose.v2/jwt" +) type Claims struct { jwt.Claims - Groups []string `json:"groups,omitempty"` - Email string `json:"email,omitempty"` - EmailVerified bool `json:"email_verified,omitempty"` - ServiceAccountName string `json:"service_account_name,omitempty"` + Groups []string `json:"groups,omitempty"` + Email string `json:"email,omitempty"` + EmailVerified bool `json:"email_verified,omitempty"` + ServiceAccountName string `json:"service_account_name,omitempty"` + RawClaim map[string]interface{} `json:"-"` +} + +// UnmarshalJSON is a custom Unmarshal that overwrites +// json.Unmarshal to mash every claim into a custom map +func (c *Claims) UnmarshalJSON(data []byte) error { + type claimAlias Claims + var localClaim claimAlias = claimAlias(*c) + + // Populate the claims struct as much as possible + err := json.Unmarshal(data, &localClaim) + if err != nil { + return err + } + + // Populate the raw data struct + err = json.Unmarshal(data, &localClaim.RawClaim) + if err != nil { + return err + } + + *c = Claims(localClaim) + return nil +} + +// GetCustomGroup is responsible for extracting groups based on the +// provided custom claim key +func (c *Claims) GetCustomGroup(customKeyName string) ([]string, error) { + groups, ok := c.RawClaim[customKeyName] + if !ok { + return nil, fmt.Errorf("No claim found for key: %v", customKeyName) + } + + sliceInterface, ok := groups.([]interface{}) + if !ok { + return nil, fmt.Errorf("Expected an array, got %v", groups) + } + + newSlice := []string{} + for _, a := range sliceInterface { + val, ok := a.(string) + if !ok { + return nil, fmt.Errorf("Group name %v was not a string", a) + } + newSlice = append(newSlice, val) + } + + return newSlice, nil } diff --git a/server/auth/types/claims_test.go b/server/auth/types/claims_test.go new file mode 100644 index 000000000000..c956027820ec --- /dev/null +++ b/server/auth/types/claims_test.go @@ -0,0 +1,198 @@ +package types + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/square/go-jose.v2/jwt" +) + +func TestUnmarshalJSON(t *testing.T) { + testExpiry := jwt.NumericDate(1626527469) + testissuedAt := jwt.NumericDate(1626467469) + + tests := []struct { + description string + data string + customClaimName string + expectedClaims *Claims + expectedErr error + }{ + { + description: "unmarshal valid data", + data: `{"user_tz":"America\/Chicago","sub":"test-user@argoproj.github.io","user_locale":"en","idp_name":"UserNamePassword","user.tenant.name":"test-user","onBehalfOfUser":true,"idp_guid":"UserNamePassword","amr":["USERNAME_PASSWORD"],"iss":"https:\/\/identity-service.argoproj.github.io","user_tenantname":"test-user","client_id":"tokenGenerator","user_isAdmin":true,"sub_type":"user","scope":"","client_tenantname":"argo-proj","region_name":"us1","user_lang":"en","userAppRoles":["Authenticated","Global Viewer","Identity Domain Administrator"],"exp":1626527469,"iat":1626467469,"client_guid":"adsf34534645654653454","client_name":"tokenGenerator","idp_type":"LOCAL","tenant":"test-user23523423","jti":"345sd435d454356","ad_groups":["argo_admin", "argo_readonly"],"gtp":"jwt","user_displayname":"Test User","sub_mappingattr":"userName","primTenant":true,"tok_type":"AT","ca_guid":"test-ca_guid","aud":["example-aud"],"user_id":"8948923893458945234","clientAppRoles":["Authenticated Client","Cross Tenant"],"tenant_iss":"https:\/\/identiy-service.argoproj.github.io"}`, + customClaimName: "ad_groups", + expectedErr: nil, + expectedClaims: &Claims{ + Claims: jwt.Claims{ + ID: "345sd435d454356", + Audience: jwt.Audience{"example-aud"}, + Issuer: "https://identity-service.argoproj.github.io", + Subject: "test-user@argoproj.github.io", + Expiry: &testExpiry, + NotBefore: nil, + IssuedAt: &testissuedAt, + }, + ServiceAccountName: "", + RawClaim: map[string]interface{}{ + "ad_groups": []interface{}{"argo_admin", "argo_readonly"}, + "amr": []interface{}{"USERNAME_PASSWORD"}, + "aud": []interface{}{"example-aud"}, + "clientAppRoles": []interface{}{"Authenticated Client", "Cross Tenant"}, + "userAppRoles": []interface{}{"Authenticated", "Global Viewer", "Identity Domain Administrator"}, + "ca_guid": "test-ca_guid", + "client_guid": "adsf34534645654653454", + "client_id": "tokenGenerator", + "client_name": "tokenGenerator", + "client_tenantname": "argo-proj", + "exp": 1.626527469e+09, + "gtp": "jwt", + "iat": 1.626467469e+09, + "idp_guid": "UserNamePassword", + "idp_name": "UserNamePassword", + "idp_type": "LOCAL", + "iss": "https://identity-service.argoproj.github.io", + "jti": "345sd435d454356", + "onBehalfOfUser": true, + "primTenant": true, + "region_name": "us1", + "scope": "", + "sub": "test-user@argoproj.github.io", + "sub_mappingattr": "userName", + "sub_type": "user", + "tenant": "test-user23523423", + "tenant_iss": "https://identiy-service.argoproj.github.io", + "tok_type": "AT", + "user.tenant.name": "test-user", + "user_id": "8948923893458945234", + "user_isAdmin": true, + "user_lang": "en", + "user_locale": "en", + "user_tenantname": "test-user", + "user_tz": "America/Chicago", + "user_displayname": "Test User", + }, + }, + }, + { + description: "unmarshal valid data, with default custom groups name", + data: `{"user_tz":"America\/Chicago","sub":"test-user@argoproj.github.io","user_locale":"en","idp_name":"UserNamePassword","user.tenant.name":"test-user","onBehalfOfUser":true,"idp_guid":"UserNamePassword","amr":["USERNAME_PASSWORD"],"iss":"https:\/\/identity-service.argoproj.github.io","user_tenantname":"test-user","client_id":"tokenGenerator","user_isAdmin":true,"sub_type":"user","scope":"","client_tenantname":"argo-proj","region_name":"us1","user_lang":"en","userAppRoles":["Authenticated","Global Viewer","Identity Domain Administrator"],"exp":1626527469,"iat":1626467469,"client_guid":"adsf34534645654653454","client_name":"tokenGenerator","idp_type":"LOCAL","tenant":"test-user23523423","jti":"345sd435d454356","groups":["argo_admin", "argo_readonly"],"gtp":"jwt","user_displayname":"Test User","sub_mappingattr":"userName","primTenant":true,"tok_type":"AT","ca_guid":"test-ca_guid","aud":["example-aud"],"user_id":"8948923893458945234","clientAppRoles":["Authenticated Client","Cross Tenant"],"tenant_iss":"https:\/\/identiy-service.argoproj.github.io"}`, + expectedErr: nil, + expectedClaims: &Claims{ + Claims: jwt.Claims{ + ID: "345sd435d454356", + Audience: jwt.Audience{"example-aud"}, + Issuer: "https://identity-service.argoproj.github.io", + Subject: "test-user@argoproj.github.io", + Expiry: &testExpiry, + NotBefore: nil, + IssuedAt: &testissuedAt, + }, + Groups: []string{"argo_admin", "argo_readonly"}, + ServiceAccountName: "", + RawClaim: map[string]interface{}{ + "groups": []interface{}{"argo_admin", "argo_readonly"}, + "amr": []interface{}{"USERNAME_PASSWORD"}, + "aud": []interface{}{"example-aud"}, + "clientAppRoles": []interface{}{"Authenticated Client", "Cross Tenant"}, + "userAppRoles": []interface{}{"Authenticated", "Global Viewer", "Identity Domain Administrator"}, + "ca_guid": "test-ca_guid", + "client_guid": "adsf34534645654653454", + "client_id": "tokenGenerator", + "client_name": "tokenGenerator", + "client_tenantname": "argo-proj", + "exp": 1.626527469e+09, + "gtp": "jwt", + "iat": 1.626467469e+09, + "idp_guid": "UserNamePassword", + "idp_name": "UserNamePassword", + "idp_type": "LOCAL", + "iss": "https://identity-service.argoproj.github.io", + "jti": "345sd435d454356", + "onBehalfOfUser": true, + "primTenant": true, + "region_name": "us1", + "scope": "", + "sub": "test-user@argoproj.github.io", + "sub_mappingattr": "userName", + "sub_type": "user", + "tenant": "test-user23523423", + "tenant_iss": "https://identiy-service.argoproj.github.io", + "tok_type": "AT", + "user.tenant.name": "test-user", + "user_id": "8948923893458945234", + "user_isAdmin": true, + "user_lang": "en", + "user_locale": "en", + "user_tenantname": "test-user", + "user_tz": "America/Chicago", + "user_displayname": "Test User", + }, + }, + }, + { + description: "unmarshal no data", + data: `{}`, + expectedErr: nil, + expectedClaims: &Claims{ + RawClaim: map[string]interface{}{}, + }, + }, + } + for _, test := range tests { + + claims := &Claims{} + err := json.Unmarshal([]byte(test.data), &claims) + + assert.Equal(t, test.expectedErr, err, test.description) + assert.Equal(t, test.expectedClaims, claims, test.description) + } +} + +func TestGetCustomGroup(t *testing.T) { + + t.Run("NoCustomGroupSet", func(t *testing.T) { + claims := &Claims{} + _, err := claims.GetCustomGroup(("ad_groups")) + if assert.Error(t, err) { + assert.EqualError(t, err, "No claim found for key: ad_groups") + } + }) + t.Run("CustomGroupSet", func(t *testing.T) { + tGroup := []string{"my-group"} + tGroupsIf := make([]interface{}, len(tGroup)) + for i := range tGroup { + tGroupsIf[i] = tGroup[i] + } + claims := &Claims{RawClaim: map[string]interface{}{ + "ad_groups": tGroupsIf, + }} + groups, err := claims.GetCustomGroup(("ad_groups")) + if assert.NoError(t, err) { + assert.Equal(t, []string{"my-group"}, groups) + } + }) + t.Run("CustomGroupNotString", func(t *testing.T) { + tGroup := []int{0} + tGroupsIf := make([]interface{}, len(tGroup)) + for i := range tGroup { + tGroupsIf[i] = tGroup[i] + } + claims := &Claims{RawClaim: map[string]interface{}{ + "ad_groups": tGroupsIf, + }} + _, err := claims.GetCustomGroup(("ad_groups")) + if assert.Error(t, err) { + assert.EqualError(t, err, "Group name 0 was not a string") + } + }) + t.Run("CustomGroupNotSlice", func(t *testing.T) { + tGroup := "None" + claims := &Claims{RawClaim: map[string]interface{}{ + "ad_groups": tGroup, + }} + _, err := claims.GetCustomGroup(("ad_groups")) + assert.Error(t, err) + }) +}