Repository: argoproj/argo
Pull request #4399 (2020-10-27 17:05:54)

fix: Use correct template when processing metrics
Fixes: https://github.com/argoproj/argo/issues/4381

Signed-off-by: Simon Behar <simbeh7@gmail.com>

Checklist:

* [ ] Either (a) I've created an [enhancement proposal](https://github.com/argoproj/argo/issues/new/choose) and discussed it with the community, (b) this is a bug fix, or (c) this is a chore.
* [ ] The title of the PR is (a) [conventional](https://www.conventionalcommits.org/en/v1.0.0/), (b) states what changed, and (c) suffixes the related issues number. E.g. `"fix(controller): Updates such and such. Fixes #1234"`.  
* [ ] My organization is added to [USERS.md](https://github.com/argoproj/argo/blob/master/USERS.md).
* [ ] I've signed the CLA.
* [ ] I have written unit and/or e2e tests for my change. PRs without these are unlikely to be merged.
* [ ] My builds are green. Try syncing with master if they are not. 



Patch:
diff --git a/.github/workflows/ci-build.yaml b/.github/workflows/ci-build.yaml
index ad7b65230..67d3ae6e7 100644
--- a/.github/workflows/ci-build.yaml
+++ b/.github/workflows/ci-build.yaml
@@ -33,7 +33,7 @@ jobs:
       - name: Run tests
         env:
           GOPATH: /home/runner/go
-        run: make test
+        run: make test STATIC_FILES=false
 
   e2e-tests:
     name: E2E Tests
@@ -43,7 +43,7 @@ jobs:
       fail-fast: false
       matrix:
         # kubelet is not included because it'd take ages to get it working methinks
-        test: [ "smoke", "test-e2e", "test-e2e-cron" ]
+        test: [ "smoke", "test-e2e", "test-cli", "test-e2e-cron" ]
         containerRuntimeExecutor: [ "docker", "k8sapi", "pns" ]
         # ok, so we're only running `smoke` for all CREs,
         exclude:
@@ -51,6 +51,10 @@ jobs:
             containerRuntimeExecutor: k8sapi
           - test: test-e2e
             containerRuntimeExecutor: pns
+          - test: test-cli
+            containerRuntimeExecutor: k8sapi
+          - test: test-cli
+            containerRuntimeExecutor: pns
           - test: test-e2e-cron
             containerRuntimeExecutor: k8sapi
           - test: test-e2e-cron
@@ -97,6 +101,8 @@ jobs:
           mkdir -p /tmp/log/argo-e2e
           git fetch --tags
           KUBECONFIG=~/.kube/config make start PROFILE=$PROFILE E2E_EXECUTOR=${{matrix.containerRuntimeExecutor}} DEV_IMAGE=true STATIC_FILES=false 2>&1 > /tmp/log/argo-e2e/argo.log &
+      - name: Install gotestsum
+        run: go install gotest.tools/gotestsum
       - name: Wait for Argo Server to be ready
         env:
           GOPATH: /home/runner/go
@@ -104,7 +110,7 @@ jobs:
       - name: Run tests
         env:
           GOPATH: /home/runner/go
-        run: make ${{ matrix.test }}
+        run: make ${{ matrix.test }} GOTEST='gotestsum --format testname --'
       - name: Upload logs
         if: ${{ always() }}
         uses: actions/upload-artifact@v1
diff --git a/.golangci.yml b/.golangci.yml
index 014ed9592..536543aff 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -8,6 +8,7 @@ run:
     - server/static/files.go
   build-tags:
     - e2e
+    - cli
 linters:
   enable:
     - goimports
diff --git a/Makefile b/Makefile
index ea0e5859c..6a90fe07b 100644
--- a/Makefile
+++ b/Makefile
@@ -48,6 +48,7 @@ CONTROLLER_IMAGE_FILE  := dist/controller-image.marker
 # perform static compilation
 STATIC_BUILD          ?= true
 STATIC_FILES          ?= true
+GOTEST                ?= go test
 PROFILE               ?= minimal
 # whether or not to start the Argo Service in TLS mode
 SECURE                := false
@@ -379,7 +380,7 @@ endif
 # for local we have a faster target that prints to stdout, does not use json, and can cache because it has no coverage
 .PHONY: test
 test: server/static/files.go
-	env KUBECONFIG=/dev/null go test ./...
+	env KUBECONFIG=/dev/null $(GOTEST) ./...
 
 dist/$(PROFILE).yaml: $(MANIFESTS) $(E2E_MANIFESTS) /usr/local/bin/kustomize
 	mkdir -p dist
@@ -457,15 +458,19 @@ mysql-cli:
 
 .PHONY: test-e2e
 test-e2e:
-	go test -timeout 20m -count 1 --tags e2e -p 1 --short ./test/e2e
+	$(GOTEST) -timeout 10m -count 1 --tags e2e -p 1 --short ./test/e2e
+
+.PHONY: test-cli
+test-cli:
+	$(GOTEST) -timeout 10m -count 1 --tags cli -p 1 --short ./test/e2e
 
 .PHONY: test-e2e-cron
 test-e2e-cron:
-	go test -count 1 --tags e2e -parallel 10 -run CronSuite ./test/e2e
+	$(GOTEST) -count 1 --tags e2e -parallel 10 -run CronSuite ./test/e2e
 
 .PHONY: smoke
 smoke:
-	go test -count 1 --tags e2e -p 1 -run SmokeSuite ./test/e2e
+	$(GOTEST) -count 1 --tags e2e -p 1 -run SmokeSuite ./test/e2e
 
 # clean
 
diff --git a/api/openapi-spec/swagger.json b/api/openapi-spec/swagger.json
index 633d7d0be..594787874 100644
--- a/api/openapi-spec/swagger.json
+++ b/api/openapi-spec/swagger.json
@@ -2570,6 +2570,12 @@
     "io.argoproj.workflow.v1alpha1.GetUserInfoResponse": {
       "type": "object",
       "properties": {
+        "groups": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
         "issuer": {
           "type": "string"
         },
diff --git a/docs/argo-server-sso.md b/docs/argo-server-sso.md
index 996aaaeb5..6a0482935 100644
--- a/docs/argo-server-sso.md
+++ b/docs/argo-server-sso.md
@@ -41,3 +41,69 @@ kubectl delete secret sso
     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.
+
+
+## SSO RBAC
+
+> v2.12 and after
+
+You can optionally add RBAC to SSO. This allows you to give different users different access levels. Except for `client` auth mode, all users of the Argo Server must ultimately use a service account. So we allow you to define rules that map a user (maybe using their OIDC groups) to a service account by annotating the service account.  
+
+RBAC config is installation-level, so any changes will need to be made by the team that installed Argo. Many complex rules will be burdensome on that team.
+
+Firstly, enable the `rbac:` setting in [workflow-controller-configmap.yaml](workflow-controller-configmap.yaml). You almost certainly want to be able configure RBAC using groups, so add `scopes:` to the SSO settings:
+
+```yaml
+sso:
+  # ...
+  scopes:
+   - groups
+  rbac:
+    enabled: true
+```
+
+!!! Note
+    Not all OIDC provider support the groups scope. Please speak to your provider about their options.
+
+To configure a service account to be used, annotate it:
+
+```yaml
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: admin-user
+  annotations:
+    # 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. 
+    # 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 
+    # counter-intuitive to you).
+    # If two rules match and have the same precedence, then which one used will 
+    # be arbitrary.
+    workflows.argoproj.io/rbac-rule-precedence: "1"
+```
+
+
+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
+      annotations:
+        workflows.argoproj.io/rbac-rule: "true"
+        workflows.argoproj.io/rbac-rule-precedence: "0"
+    ```
+    
+    The precedence must be the lowest of all your service accounts. 
diff --git a/docs/workflow-controller-configmap.yaml b/docs/workflow-controller-configmap.yaml
index d3e4d6918..41b0047eb 100644
--- a/docs/workflow-controller-configmap.yaml
+++ b/docs/workflow-controller-configmap.yaml
@@ -236,7 +236,12 @@ data:
       # be in the form <argo-server-root-url>/oauth2/callback. It must be
       # browser-accessible.
       redirectUrl: https://argo-server/oauth2/callback
-
+      # Additional scopes to request. Typically needed for SSO RBAC. >= v2.12
+      scopes:
+       - groups
+      # RBAC Config. >= v2.12
+      rbac:
+        enabled: false
     # workflowRequirements restricts the Workflows that the controller will process.
     # Current options:
     #   referenceOnly: Only Workflows using "workflowTemplateRef" will be processed. This allows the administrator of the controller
diff --git a/manifests/quick-start/sso/kustomization.yaml b/manifests/quick-start/sso/kustomization.yaml
index b8cb89158..3981219ff 100644
--- a/manifests/quick-start/sso/kustomization.yaml
+++ b/manifests/quick-start/sso/kustomization.yaml
@@ -6,4 +6,5 @@ resources:
   - dex
 
 patchesStrategicMerge:
-  - overlays/workflow-controller-configmap.yaml
\ No newline at end of file
+  - overlays/workflow-controller-configmap.yaml
+  - overlays/argo-server-sa.yaml
\ No newline at end of file
diff --git a/manifests/quick-start/sso/overlays/argo-server-sa.yaml b/manifests/quick-start/sso/overlays/argo-server-sa.yaml
new file mode 100644
index 000000000..462740824
--- /dev/null
+++ b/manifests/quick-start/sso/overlays/argo-server-sa.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: argo-server
+  annotations:
+    workflows.argoproj.io/rbac-rule: "'authors' in groups"
+    workflows.argoproj.io/rbac-rule-precedence: "1"
diff --git a/manifests/quick-start/sso/overlays/workflow-controller-configmap.yaml b/manifests/quick-start/sso/overlays/workflow-controller-configmap.yaml
index 538976b11..880224298 100644
--- a/manifests/quick-start/sso/overlays/workflow-controller-configmap.yaml
+++ b/manifests/quick-start/sso/overlays/workflow-controller-configmap.yaml
@@ -1,6 +1,6 @@
 apiVersion: v1
 data:
-  sso : |
+  sso: |
     issuer: http://dex:5556/dex
     clientId:
       name: argo-server-sso
@@ -9,6 +9,10 @@ data:
       name: argo-server-sso
       key: clientSecret
     redirectUrl: http://localhost:2746/oauth2/callback
+    scopes:
+    - groups
+    rbac:
+      enabled: true
 kind: ConfigMap
 metadata:
   name: workflow-controller-configmap
diff --git a/pkg/apiclient/argo-kube-client.go b/pkg/apiclient/argo-kube-client.go
index f43455d29..db1710949 100644
--- a/pkg/apiclient/argo-kube-client.go
+++ b/pkg/apiclient/argo-kube-client.go
@@ -44,7 +44,7 @@ func newArgoKubeClient(clientConfig clientcmd.ClientConfig, instanceIDService in
 	if err != nil {
 		return nil, nil, err
 	}
-	gatekeeper, err := auth.NewGatekeeper(auth.Modes{auth.Server: true}, wfClient, kubeClient, restConfig, nil)
+	gatekeeper, err := auth.NewGatekeeper(auth.Modes{auth.Server: true}, wfClient, kubeClient, restConfig, nil, auth.DefaultClientForAuthorization, "unused")
 	if err != nil {
 		return nil, nil, err
 	}
diff --git a/pkg/apiclient/info/info.pb.go b/pkg/apiclient/info/info.pb.go
index b72c546f3..928689ee5 100644
--- a/pkg/apiclient/info/info.pb.go
+++ b/pkg/apiclient/info/info.pb.go
@@ -203,6 +203,7 @@ var xxx_messageInfo_GetUserInfoRequest proto.InternalMessageInfo
 type GetUserInfoResponse struct {
 	Issuer               string   `protobuf:"bytes,1,opt,name=issuer,proto3" json:"issuer,omitempty"`
 	Subject              string   `protobuf:"bytes,2,opt,name=subject,proto3" json:"subject,omitempty"`
+	Groups               []string `protobuf:"bytes,3,rep,name=groups,proto3" json:"groups,omitempty"`
 	XXX_NoUnkeyedLiteral struct{} `json:"-"`
 	XXX_unrecognized     []byte   `json:"-"`
 	XXX_sizecache        int32    `json:"-"`
@@ -255,6 +256,13 @@ func (m *GetUserInfoResponse) GetSubject() string {
 	return ""
 }
 
+func (m *GetUserInfoResponse) GetGroups() []string {
+	if m != nil {
+		return m.Groups
+	}
+	return nil
+}
+
 func init() {
 	proto.RegisterType((*GetInfoRequest)(nil), "info.GetInfoRequest")
 	proto.RegisterType((*InfoResponse)(nil), "info.InfoResponse")
@@ -266,34 +274,35 @@ func init() {
 func init() { proto.RegisterFile("pkg/apiclient/info/info.proto", fileDescriptor_96940c93018255fa) }
 
 var fileDescriptor_96940c93018255fa = []byte{
-	// 435 bytes of a gzipped FileDescriptorProto
-	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x9c, 0x52, 0x41, 0x6b, 0x14, 0x31,
-	0x14, 0x66, 0x46, 0x6d, 0x31, 0x5b, 0xea, 0x36, 0x5d, 0xec, 0x38, 0xe8, 0x52, 0xe6, 0x54, 0x14,
-	0x12, 0xb6, 0x9e, 0x0a, 0x9e, 0xf4, 0x30, 0x14, 0x44, 0x61, 0x45, 0x0f, 0xe2, 0x25, 0x3b, 0x7d,
-	0x9b, 0xa6, 0x33, 0x9b, 0xc4, 0x24, 0x33, 0xbd, 0x78, 0xf2, 0xea, 0xd1, 0xbf, 0xe2, 0x8f, 0xf0,
-	0x28, 0xf8, 0x07, 0x64, 0xf1, 0x87, 0xc8, 0x64, 0x32, 0xed, 0xac, 0x0a, 0x82, 0x97, 0xf0, 0xde,
-	0xcb, 0xe3, 0xfb, 0xbe, 0xf7, 0xbd, 0x87, 0x1e, 0xe8, 0x92, 0x53, 0xa6, 0x45, 0x51, 0x09, 0x90,
-	0x8e, 0x0a, 0xb9, 0x54, 0xfe, 0x21, 0xda, 0x28, 0xa7, 0xf0, 0xcd, 0x36, 0x4e, 0xef, 0x73, 0xa5,
-	0x78, 0x05, 0x6d, 0x1f, 0x65, 0x52, 0x2a, 0xc7, 0x9c, 0x50, 0xd2, 0x76, 0x3d, 0xe9, 0x33, 0x2e,
-	0xdc, 0x79, 0xbd, 0x20, 0x85, 0x5a, 0x51, 0x66, 0xb8, 0xd2, 0x46, 0x5d, 0xf8, 0x80, 0x06, 0x6c,
-	0x4b, 0x2f, 0x95, 0x29, 0x97, 0x95, 0xba, 0xa4, 0xcd, 0x8c, 0x55, 0xfa, 0x9c, 0xcd, 0x28, 0x07,
-	0x09, 0x86, 0x39, 0x38, 0xeb, 0x40, 0xb2, 0x31, 0xda, 0xcd, 0xc1, 0x9d, 0xca, 0xa5, 0x9a, 0xc3,
-	0xfb, 0x1a, 0xac, 0xcb, 0x3e, 0x45, 0x68, 0xa7, 0xcb, 0xad, 0x56, 0xd2, 0x02, 0x7e, 0x88, 0xc6,
-	0x2b, 0x26, 0x19, 0x87, 0xb3, 0x17, 0x6c, 0x05, 0x56, 0xb3, 0x02, 0x92, 0xe8, 0x30, 0x3a, 0xba,
-	0x3d, 0xff, 0xa3, 0x8e, 0x5f, 0xa2, 0x5b, 0x95, 0x90, 0xa5, 0x4d, 0xe2, 0xc3, 0x1b, 0x47, 0xa3,
-	0xe3, 0x13, 0x72, 0xad, 0x91, 0xf4, 0x1a, 0x7d, 0x40, 0x74, 0xc9, 0x49, 0xab, 0x91, 0xf4, 0x1a,
-	0x49, 0xaf, 0x91, 0x3c, 0x17, 0xb2, 0x9c, 0x77, 0x38, 0xd9, 0x3e, 0xda, 0xcb, 0xc1, 0xbd, 0x01,
-	0x63, 0x85, 0x92, 0xbd, 0xc4, 0x09, 0xc2, 0x39, 0xb8, 0xd7, 0x16, 0xcc, 0x50, 0x78, 0x8e, 0xf6,
-	0x37, 0xaa, 0x41, 0xfe, 0x5d, 0xb4, 0x25, 0xac, 0xad, 0xc1, 0x04, 0xd1, 0x21, 0xc3, 0x09, 0xda,
-	0xb6, 0xf5, 0xe2, 0x02, 0x0a, 0x97, 0xc4, 0xfe, 0xa3, 0x4f, 0x8f, 0xbf, 0xc4, 0x68, 0xd4, 0x42,
-	0xbc, 0x02, 0xd3, 0x88, 0x02, 0xf0, 0x29, 0xda, 0x0e, 0x1e, 0xe1, 0x09, 0xf1, 0x4b, 0xda, 0xb4,
-	0x2c, 0xc5, 0x5d, 0x75, 0x48, 0x9b, 0x4d, 0x3e, 0x7e, 0xff, 0xf9, 0x39, 0xde, 0xc5, 0x3b, 0x7e,
-	0x7b, 0xcd, 0xcc, 0x6f, 0x17, 0x7f, 0x40, 0xe8, 0x7a, 0x1c, 0x7c, 0x70, 0x85, 0xb6, 0x39, 0x60,
-	0xfa, 0xe4, 0xbf, 0x7c, 0x0b, 0x20, 0xd9, 0x81, 0xa7, 0xde, 0xc3, 0x77, 0x7a, 0xea, 0x26, 0xf0,
-	0xbd, 0x43, 0xa3, 0x81, 0x43, 0x38, 0xb9, 0xa2, 0xff, 0xcd, 0xca, 0xf4, 0xde, 0x5f, 0x7e, 0xc2,
-	0x5c, 0x89, 0x07, 0xc7, 0x78, 0xdc, 0x83, 0xd7, 0x16, 0x4c, 0xdb, 0xfd, 0xf4, 0xe4, 0xeb, 0x7a,
-	0x1a, 0x7d, 0x5b, 0x4f, 0xa3, 0x1f, 0xeb, 0x69, 0xf4, 0xf6, 0xd1, 0xbf, 0xae, 0x73, 0x70, 0xf9,
-	0x8b, 0x2d, 0x7f, 0x8c, 0x8f, 0x7f, 0x05, 0x00, 0x00, 0xff, 0xff, 0xf2, 0x27, 0xe1, 0x44, 0x16,
+	// 451 bytes of a gzipped FileDescriptorProto
+	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x9c, 0x93, 0x41, 0x6b, 0x13, 0x41,
+	0x14, 0xc7, 0xd9, 0x44, 0x5b, 0x9c, 0x94, 0x9a, 0x4e, 0x83, 0x5d, 0x17, 0x0d, 0x61, 0x4f, 0x45,
+	0x61, 0x96, 0xd4, 0x53, 0xc1, 0x93, 0x1e, 0x42, 0x41, 0x14, 0x22, 0x7a, 0x10, 0x41, 0x26, 0xdb,
+	0x97, 0xe9, 0x74, 0x37, 0xf3, 0xc6, 0x99, 0xd9, 0xf4, 0xe2, 0xc9, 0xab, 0x47, 0xbf, 0x8a, 0x1f,
+	0xc2, 0xa3, 0xe0, 0x17, 0x90, 0xe0, 0x07, 0x91, 0x9d, 0x9d, 0x6d, 0x12, 0x15, 0x04, 0x2f, 0xcb,
+	0x7b, 0x6f, 0xde, 0xfc, 0xdf, 0xef, 0xbd, 0x37, 0x4b, 0xee, 0xeb, 0x42, 0x64, 0x5c, 0xcb, 0xbc,
+	0x94, 0xa0, 0x5c, 0x26, 0xd5, 0x1c, 0xfd, 0x87, 0x69, 0x83, 0x0e, 0xe9, 0x8d, 0xda, 0x4e, 0xee,
+	0x09, 0x44, 0x51, 0x42, 0x9d, 0x97, 0x71, 0xa5, 0xd0, 0x71, 0x27, 0x51, 0xd9, 0x26, 0x27, 0x79,
+	0x2a, 0xa4, 0xbb, 0xa8, 0x66, 0x2c, 0xc7, 0x45, 0xc6, 0x8d, 0x40, 0x6d, 0xf0, 0xd2, 0x1b, 0x59,
+	0xd0, 0xb6, 0xd9, 0x15, 0x9a, 0x62, 0x5e, 0xe2, 0x55, 0xb6, 0x1c, 0xf3, 0x52, 0x5f, 0xf0, 0x71,
+	0x26, 0x40, 0x81, 0xe1, 0x0e, 0xce, 0x1b, 0x91, 0xb4, 0x4f, 0xf6, 0x27, 0xe0, 0xce, 0xd4, 0x1c,
+	0xa7, 0xf0, 0xbe, 0x02, 0xeb, 0xd2, 0x4f, 0x11, 0xd9, 0x6b, 0x7c, 0xab, 0x51, 0x59, 0xa0, 0x0f,
+	0x48, 0x7f, 0xc1, 0x15, 0x17, 0x70, 0xfe, 0x9c, 0x2f, 0xc0, 0x6a, 0x9e, 0x43, 0x1c, 0x8d, 0xa2,
+	0xe3, 0x5b, 0xd3, 0x3f, 0xe2, 0xf4, 0x05, 0xb9, 0x59, 0x4a, 0x55, 0xd8, 0xb8, 0x33, 0xea, 0x1e,
+	0xf7, 0x4e, 0x4e, 0xd9, 0x9a, 0x91, 0xb5, 0x8c, 0xde, 0x60, 0xba, 0x10, 0xac, 0x66, 0x64, 0x2d,
+	0x23, 0x6b, 0x19, 0xd9, 0x33, 0xa9, 0x8a, 0x69, 0xa3, 0x93, 0x1e, 0x92, 0x83, 0x09, 0xb8, 0xd7,
+	0x60, 0xac, 0x44, 0xd5, 0x22, 0x0e, 0x08, 0x9d, 0x80, 0x7b, 0x65, 0xc1, 0x6c, 0x82, 0xbf, 0x23,
+	0x87, 0x5b, 0xd1, 0x80, 0x7f, 0x87, 0xec, 0x48, 0x6b, 0x2b, 0x30, 0x01, 0x3a, 0x78, 0x34, 0x26,
+	0xbb, 0xb6, 0x9a, 0x5d, 0x42, 0xee, 0xe2, 0x8e, 0x3f, 0x68, 0xdd, 0xfa, 0x86, 0x30, 0x58, 0x69,
+	0x1b, 0x77, 0x47, 0xdd, 0xfa, 0x46, 0xe3, 0x9d, 0x7c, 0xe9, 0x90, 0x5e, 0x2d, 0xfd, 0x12, 0xcc,
+	0x52, 0xe6, 0x40, 0xcf, 0xc8, 0x6e, 0x98, 0x1d, 0x1d, 0x30, 0xbf, 0xbc, 0xed, 0x51, 0x26, 0xb4,
+	0x89, 0x6e, 0xe2, 0xa4, 0x83, 0x8f, 0xdf, 0x7f, 0x7e, 0xee, 0xec, 0xd3, 0x3d, 0xbf, 0xd5, 0xe5,
+	0xd8, 0x6f, 0x9d, 0x7e, 0x20, 0x64, 0xdd, 0x26, 0x3d, 0xba, 0x56, 0xdb, 0x6e, 0x3c, 0x79, 0xfc,
+	0x5f, 0xf3, 0x0c, 0x22, 0xe9, 0x91, 0x2f, 0x7d, 0x40, 0x6f, 0xb7, 0xa5, 0x97, 0xa1, 0xde, 0x5b,
+	0xd2, 0xdb, 0x98, 0x1c, 0x8d, 0xaf, 0xcb, 0xff, 0x36, 0xe2, 0xe4, 0xee, 0x5f, 0x4e, 0x42, 0x5f,
+	0xb1, 0x17, 0xa7, 0xb4, 0xdf, 0x8a, 0x57, 0x16, 0x4c, 0x9d, 0xfd, 0xe4, 0xf4, 0xeb, 0x6a, 0x18,
+	0x7d, 0x5b, 0x0d, 0xa3, 0x1f, 0xab, 0x61, 0xf4, 0xe6, 0xe1, 0xbf, 0x5e, 0xed, 0xc6, 0x1f, 0x31,
+	0xdb, 0xf1, 0x8f, 0xf4, 0xd1, 0xaf, 0x00, 0x00, 0x00, 0xff, 0xff, 0x87, 0x05, 0x3d, 0x6e, 0x2e,
 	0x03, 0x00, 0x00,
 }
 
@@ -602,6 +611,15 @@ func (m *GetUserInfoResponse) MarshalToSizedBuffer(dAtA []byte) (int, error) {
 		i -= len(m.XXX_unrecognized)
 		copy(dAtA[i:], m.XXX_unrecognized)
 	}
+	if len(m.Groups) > 0 {
+		for iNdEx := len(m.Groups) - 1; iNdEx >= 0; iNdEx-- {
+			i -= len(m.Groups[iNdEx])
+			copy(dAtA[i:], m.Groups[iNdEx])
+			i = encodeVarintInfo(dAtA, i, uint64(len(m.Groups[iNdEx])))
+			i--
+			dAtA[i] = 0x1a
+		}
+	}
 	if len(m.Subject) > 0 {
 		i -= len(m.Subject)
 		copy(dAtA[i:], m.Subject)
@@ -702,6 +720,12 @@ func (m *GetUserInfoResponse) Size() (n int) {
 	if l > 0 {
 		n += 1 + l + sovInfo(uint64(l))
 	}
+	if len(m.Groups) > 0 {
+		for _, s := range m.Groups {
+			l = len(s)
+			n += 1 + l + sovInfo(uint64(l))
+		}
+	}
 	if m.XXX_unrecognized != nil {
 		n += len(m.XXX_unrecognized)
 	}
@@ -1089,6 +1113,38 @@ func (m *GetUserInfoResponse) Unmarshal(dAtA []byte) error {
 			}
 			m.Subject = string(dAtA[iNdEx:postIndex])
 			iNdEx = postIndex
+		case 3:
+			if wireType != 2 {
+				return fmt.Errorf("proto: wrong wireType = %d for field Groups", wireType)
+			}
+			var stringLen uint64
+			for shift := uint(0); ; shift += 7 {
+				if shift >= 64 {
+					return ErrIntOverflowInfo
+				}
+				if iNdEx >= l {
+					return io.ErrUnexpectedEOF
+				}
+				b := dAtA[iNdEx]
+				iNdEx++
+				stringLen |= uint64(b&0x7F) << shift
+				if b < 0x80 {
+					break
+				}
+			}
+			intStringLen := int(stringLen)
+			if intStringLen < 0 {
+				return ErrInvalidLengthInfo
+			}
+			postIndex := iNdEx + intStringLen
+			if postIndex < 0 {
+				return ErrInvalidLengthInfo
+			}
+			if postIndex > l {
+				return io.ErrUnexpectedEOF
+			}
+			m.Groups = append(m.Groups, string(dAtA[iNdEx:postIndex]))
+			iNdEx = postIndex
 		default:
 			iNdEx = preIndex
 			skippy, err := skipInfo(dAtA[iNdEx:])
diff --git a/pkg/apiclient/info/info.proto b/pkg/apiclient/info/info.proto
index fb2d75319..a0a2dc9fc 100644
--- a/pkg/apiclient/info/info.proto
+++ b/pkg/apiclient/info/info.proto
@@ -23,6 +23,7 @@ message GetUserInfoRequest {
 message GetUserInfoResponse {
     string issuer = 1;
     string subject = 2;
+    repeated string groups = 3;
 }
 
 service InfoService {
diff --git a/server/apiserver/argoserver.go b/server/apiserver/argoserver.go
index faeb004a4..95bd3e622 100644
--- a/server/apiserver/argoserver.go
+++ b/server/apiserver/argoserver.go
@@ -64,7 +64,7 @@ type argoServer struct {
 	managedNamespace string
 	kubeClientset    *kubernetes.Clientset
 	wfClientSet      *versioned.Clientset
-	authenticator    auth.Gatekeeper
+	gatekeeper       auth.Gatekeeper
 	oAuth2Service    sso.Interface
 	configController config.Controller
 	stopCh           chan struct{}
@@ -104,7 +104,7 @@ func NewArgoServer(opts ArgoServerOpts) (*argoServer, error) {
 	} else {
 		log.Info("SSO disabled")
 	}
-	gatekeeper, err := auth.NewGatekeeper(opts.AuthModes, opts.WfClientSet, opts.KubeClientset, opts.RestConfig, ssoIf)
+	gatekeeper, err := auth.NewGatekeeper(opts.AuthModes, opts.WfClientSet, opts.KubeClientset, opts.RestConfig, ssoIf, auth.DefaultClientForAuthorization, opts.Namespace)
 	if err != nil {
 		return nil, err
 	}
@@ -116,7 +116,7 @@ func NewArgoServer(opts ArgoServerOpts) (*argoServer, error) {
 		managedNamespace: opts.ManagedNamespace,
 		wfClientSet:      opts.WfClientSet,
 		kubeClientset:    opts.KubeClientset,
-		authenticator:    gatekeeper,
+		gatekeeper:       gatekeeper,
 		oAuth2Service:    ssoIf,
 		configController: configController,
 		stopCh:           make(chan struct{}),
@@ -159,7 +159,7 @@ func (as *argoServer) Run(ctx context.Context, port int, browserOpenFunc func(st
 		wfArchive = sqldb.NewWorkflowArchive(session, persistence.GetClusterName(), as.managedNamespace, instanceIDService)
 	}
 	eventRecorderManager := events.NewEventRecorderManager(as.kubeClientset)
-	artifactServer := artifacts.NewArtifactServer(as.authenticator, hydrator.New(offloadRepo), wfArchive, instanceIDService)
+	artifactServer := artifacts.NewArtifactServer(as.gatekeeper, hydrator.New(offloadRepo), wfArchive, instanceIDService)
 	eventServer := event.NewController(instanceIDService, eventRecorderManager, as.eventQueueSize, as.eventWorkerCount)
 	grpcServer := as.newGRPCServer(instanceIDService, offloadRepo, wfArchive, eventServer, config.Links)
 	httpServer := as.newHTTPServer(ctx, port, artifactServer)
@@ -218,13 +218,13 @@ func (as *argoServer) newGRPCServer(instanceIDService instanceid.Service, offloa
 			grpc_logrus.UnaryServerInterceptor(serverLog),
 			grpcutil.PanicLoggerUnaryServerInterceptor(serverLog),
 			grpcutil.ErrorTranslationUnaryServerInterceptor,
-			as.authenticator.UnaryServerInterceptor(),
+			as.gatekeeper.UnaryServerInterceptor(),
 		)),
 		grpc.StreamInterceptor(grpc_middleware.ChainStreamServer(
 			grpc_logrus.StreamServerInterceptor(serverLog),
 			grpcutil.PanicLoggerStreamServerInterceptor(serverLog),
 			grpcutil.ErrorTranslationStreamServerInterceptor,
-			as.authenticator.StreamServerInterceptor(),
+			as.gatekeeper.StreamServerInterceptor(),
 		)),
 	}
 
diff --git a/server/auth/gatekeeper.go b/server/auth/gatekeeper.go
index 74109a051..8b83bff77 100644
--- a/server/auth/gatekeeper.go
+++ b/server/auth/gatekeeper.go
@@ -2,21 +2,30 @@ package auth
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"net/http"
+	"sort"
+	"strconv"
 
+	"github.com/antonmedv/expr"
 	grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware"
+	log "github.com/sirupsen/logrus"
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/metadata"
 	"google.golang.org/grpc/status"
-	"gopkg.in/square/go-jose.v2/jwt"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/rest"
 
 	"github.com/argoproj/argo/pkg/client/clientset/versioned"
+	"github.com/argoproj/argo/server/auth/serviceaccount"
 	"github.com/argoproj/argo/server/auth/sso"
+	"github.com/argoproj/argo/server/auth/types"
 	"github.com/argoproj/argo/util/kubeconfig"
+	"github.com/argoproj/argo/workflow/common"
 )
 
 type ContextKey string
@@ -24,7 +33,7 @@ type ContextKey string
 const (
 	WfKey     ContextKey = "versioned.Interface"
 	KubeKey   ContextKey = "kubernetes.Interface"
-	ClaimsKey ContextKey = "jwt.Claims"
+	ClaimsKey ContextKey = "types.Claims"
 )
 
 //go:generate mockery -name Gatekeeper
@@ -35,20 +44,25 @@ type Gatekeeper interface {
 	StreamServerInterceptor() grpc.StreamServerInterceptor
 }
 
+type ClientForAuthorization func(authorization string) (*rest.Config, versioned.Interface, kubernetes.Interface, error)
+
 type gatekeeper struct {
 	Modes Modes
 	// global clients, not to be used if there are better ones
-	wfClient   versioned.Interface
-	kubeClient kubernetes.Interface
-	restConfig *rest.Config
-	ssoIf      sso.Interface
+	wfClient               versioned.Interface
+	kubeClient             kubernetes.Interface
+	restConfig             *rest.Config
+	ssoIf                  sso.Interface
+	clientForAuthorization ClientForAuthorization
+	// The namespace the server is installed in.
+	namespace string
 }
 
-func NewGatekeeper(modes Modes, wfClient versioned.Interface, kubeClient kubernetes.Interface, restConfig *rest.Config, ssoIf sso.Interface) (Gatekeeper, error) {
+func NewGatekeeper(modes Modes, wfClient versioned.Interface, kubeClient kubernetes.Interface, restConfig *rest.Config, ssoIf sso.Interface, clientForAuthorization ClientForAuthorization, namespace string) (Gatekeeper, error) {
 	if len(modes) == 0 {
 		return nil, fmt.Errorf("must specify at least one auth mode")
 	}
-	return &gatekeeper{modes, wfClient, kubeClient, restConfig, ssoIf}, nil
+	return &gatekeeper{modes, wfClient, kubeClient, restConfig, ssoIf, clientForAuthorization, namespace}, nil
 }
 
 func (s *gatekeeper) UnaryServerInterceptor() grpc.UnaryServerInterceptor {
@@ -89,8 +103,8 @@ func GetKubeClient(ctx context.Context) kubernetes.Interface {
 	return ctx.Value(KubeKey).(kubernetes.Interface)
 }
 
-func GetClaims(ctx context.Context) *jwt.Claims {
-	config, _ := ctx.Value(ClaimsKey).(*jwt.Claims)
+func GetClaims(ctx context.Context) *types.Claims {
+	config, _ := ctx.Value(ClaimsKey).(*types.Claims)
 	return config
 }
 
@@ -112,7 +126,7 @@ func getAuthHeader(md metadata.MD) string {
 	return ""
 }
 
-func (s gatekeeper) getClients(ctx context.Context) (versioned.Interface, kubernetes.Interface, *jwt.Claims, error) {
+func (s gatekeeper) getClients(ctx context.Context) (versioned.Interface, kubernetes.Interface, *types.Claims, error) {
 	md, _ := metadata.FromIncomingContext(ctx)
 	authorization := getAuthHeader(md)
 	mode, err := GetMode(authorization)
@@ -120,34 +134,120 @@ func (s gatekeeper) getClients(ctx context.Context) (versioned.Interface, kubern
 		return nil, nil, nil, status.Error(codes.InvalidArgument, err.Error())
 	}
 	if !s.Modes[mode] {
-		return nil, nil, nil, status.Errorf(codes.Unauthenticated, "no valid authentication methods found for mode %v", mode)
+		return nil, nil, nil, status.Errorf(codes.Unauthenticated, "client auth-mode is %v, but that mode is disabled", mode)
 	}
 	switch mode {
 	case Client:
-		restConfig, err := kubeconfig.GetRestConfig(authorization)
-		if err != nil {
-			return nil, nil, nil, status.Errorf(codes.Unauthenticated, "failed to create REST config: %v", err)
-		}
-		wfClient, err := versioned.NewForConfig(restConfig)
+		restConfig, wfClient, kubeClient, err := s.clientForAuthorization(authorization)
 		if err != nil {
-			return nil, nil, nil, status.Errorf(codes.Unauthenticated, "failure to create wfClientset with ClientConfig: %v", err)
-		}
-		kubeClient, err := kubernetes.NewForConfig(restConfig)
-		if err != nil {
-			return nil, nil, nil, status.Errorf(codes.Unauthenticated, "failure to create kubeClientset with ClientConfig: %v", err)
+			return nil, nil, nil, status.Error(codes.Unauthenticated, err.Error())
 		}
-		claims, _ := sso.ClaimSetFor(restConfig)
+		claims, _ := serviceaccount.ClaimSetFor(restConfig)
 		return wfClient, kubeClient, claims, nil
 	case Server:
-		claims, _ := sso.ClaimSetFor(s.restConfig)
+		claims, _ := serviceaccount.ClaimSetFor(s.restConfig)
 		return s.wfClient, s.kubeClient, claims, nil
 	case SSO:
 		claims, err := s.ssoIf.Authorize(authorization)
 		if err != nil {
 			return nil, nil, nil, status.Error(codes.Unauthenticated, err.Error())
 		}
-		return s.wfClient, s.kubeClient, claims, nil
+		if s.ssoIf.IsRBACEnabled() {
+			v, k, err := s.rbacAuthorization(claims)
+			if err != nil {
+				log.WithError(err).Error("failed to perform RBAC authorization")
+				return nil, nil, nil, status.Error(codes.PermissionDenied, "not allowed")
+			}
+			return v, k, claims, nil
+		} else {
+			return s.wfClient, s.kubeClient, claims, nil
+		}
 	default:
 		panic("this should never happen")
 	}
 }
+
+func (s *gatekeeper) rbacAuthorization(claims *types.Claims) (versioned.Interface, kubernetes.Interface, error) {
+	list, err := s.kubeClient.CoreV1().ServiceAccounts(s.namespace).List(metav1.ListOptions{})
+	if err != nil {
+		return nil, nil, fmt.Errorf("failed to list SSO RBAC service accounts: %w", err)
+	}
+	var serviceAccounts []corev1.ServiceAccount
+	for _, serviceAccount := range list.Items {
+		_, ok := serviceAccount.Annotations[common.AnnotationKeyRBACRule]
+		if !ok {
+			continue
+		}
+		serviceAccounts = append(serviceAccounts, serviceAccount)
+	}
+	precedence := func(serviceAccount corev1.ServiceAccount) int {
+		i, _ := strconv.Atoi(serviceAccount.Annotations[common.AnnotationKeyRBACRulePrecedence])
+		return i
+	}
+	sort.Slice(serviceAccounts, func(i, j int) bool { return precedence(serviceAccounts[i]) > precedence(serviceAccounts[j]) })
+	for _, serviceAccount := range serviceAccounts {
+		rule := serviceAccount.Annotations[common.AnnotationKeyRBACRule]
+		data, err := json.Marshal(claims)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to marshall claims: %w", err)
+		}
+		v := make(map[string]interface{})
+		err = json.Unmarshal(data, &v)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to unmarshall claims: %w", err)
+		}
+		result, err := expr.Eval(rule, v)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to evaluate rule: %w", err)
+		}
+		allow, ok := result.(bool)
+		if !ok {
+			return nil, nil, fmt.Errorf("failed to evaluate rule: not a boolean")
+		}
+		if !allow {
+			continue
+		}
+		authorization, err := s.authorizationForServiceAccount(serviceAccount.Name)
+		if err != nil {
+			return nil, nil, err
+		}
+		_, wfClient, kubeClient, err := s.clientForAuthorization(authorization)
+		if err != nil {
+			return nil, nil, err
+		}
+		log.WithFields(log.Fields{"serviceAccount": serviceAccount.Name, "subject": claims.Subject}).Info("selected SSO RBAC service account for user")
+		return wfClient, kubeClient, nil
+	}
+	return nil, nil, fmt.Errorf("no service account rule matches")
+}
+
+func (s *gatekeeper) authorizationForServiceAccount(serviceAccountName string) (string, error) {
+	serviceAccount, err := s.kubeClient.CoreV1().ServiceAccounts(s.namespace).Get(serviceAccountName, metav1.GetOptions{})
+	if err != nil {
+		return "", fmt.Errorf("failed to get service account: %w", err)
+	}
+	if len(serviceAccount.Secrets) == 0 {
+		return "", fmt.Errorf("expected at least one secret for SSO RBAC service account: %w", err)
+	}
+	secret, err := s.kubeClient.CoreV1().Secrets(s.namespace).Get(serviceAccount.Secrets[0].Name, metav1.GetOptions{})
+	if err != nil {
+		return "", fmt.Errorf("failed to get service account secret: %w", err)
+	}
+	return "Bearer " + string(secret.Data["token"]), nil
+}
+
+func DefaultClientForAuthorization(authorization string) (*rest.Config, versioned.Interface, kubernetes.Interface, error) {
+	restConfig, err := kubeconfig.GetRestConfig(authorization)
+	if err != nil {
+		return nil, nil, nil, fmt.Errorf("failed to create REST config: %w", err)
+	}
+	wfClient, err := versioned.NewForConfig(restConfig)
+	if err != nil {
+		return nil, nil, nil, fmt.Errorf("failure to create workflow client: %w", err)
+	}
+	kubeClient, err := kubernetes.NewForConfig(restConfig)
+	if err != nil {
+		return nil, nil, nil, fmt.Errorf("failure to create kubernetes client: %w", err)
+	}
+	return restConfig, wfClient, kubeClient, nil
+}
diff --git a/server/auth/rbac/config.go b/server/auth/rbac/config.go
new file mode 100644
index 000000000..51b7534f5
--- /dev/null
+++ b/server/auth/rbac/config.go
@@ -0,0 +1,9 @@
+package rbac
+
+type Config struct {
+	Enabled bool `json:"enabled,omitempty"`
+}
+
+func (c *Config) IsEnabled() bool {
+	return c != nil && c.Enabled
+}
diff --git a/server/auth/sso/jwt.go b/server/auth/serviceaccount/claims.go
similarity index 83%
rename from server/auth/sso/jwt.go
rename to server/auth/serviceaccount/claims.go
index 90c3423a6..6bc133146 100644
--- a/server/auth/sso/jwt.go
+++ b/server/auth/serviceaccount/claims.go
@@ -1,4 +1,4 @@
-package sso
+package serviceaccount
 
 import (
 	"encoding/base64"
@@ -10,12 +10,14 @@ import (
 	"k8s.io/client-go/rest"
 
 	"gopkg.in/square/go-jose.v2/jwt"
+
+	"github.com/argoproj/argo/server/auth/types"
 )
 
-func ClaimSetFor(restConfig *rest.Config) (*jwt.Claims, error) {
+func ClaimSetFor(restConfig *rest.Config) (*types.Claims, error) {
 	username := restConfig.Username
 	if username != "" {
-		return &jwt.Claims{Subject: username}, nil
+		return &types.Claims{Claims: jwt.Claims{Subject: username}}, nil
 	} else if restConfig.BearerToken != "" || restConfig.BearerTokenFile != "" {
 		bearerToken := restConfig.BearerToken
 		if bearerToken == "" {
@@ -35,7 +37,7 @@ func ClaimSetFor(restConfig *rest.Config) (*jwt.Claims, error) {
 		if err != nil {
 			return nil, fmt.Errorf("failed to decode bearer token's JWT payload: %w", err)
 		}
-		claims := &jwt.Claims{}
+		claims := &types.Claims{}
 		err = json.Unmarshal(data, &claims)
 		if err != nil {
 			return nil, fmt.Errorf("failed to unmarshal bearer token's JWT payload: %w", err)
diff --git a/server/auth/sso/mocks/Interface.go b/server/auth/sso/mocks/Interface.go
index ee2b6fa86..4e9accbf0 100644
--- a/server/auth/sso/mocks/Interface.go
+++ b/server/auth/sso/mocks/Interface.go
@@ -6,7 +6,8 @@ import (
 	http "net/http"
 
 	mock "github.com/stretchr/testify/mock"
-	jwt "gopkg.in/square/go-jose.v2/jwt"
+
+	types "github.com/argoproj/argo/server/auth/types"
 )
 
 // Interface is an autogenerated mock type for the Interface type
@@ -15,15 +16,15 @@ type Interface struct {
 }
 
 // Authorize provides a mock function with given fields: authorization
-func (_m *Interface) Authorize(authorization string) (*jwt.Claims, error) {
+func (_m *Interface) Authorize(authorization string) (*types.Claims, error) {
 	ret := _m.Called(authorization)
 
-	var r0 *jwt.Claims
-	if rf, ok := ret.Get(0).(func(string) *jwt.Claims); ok {
+	var r0 *types.Claims
+	if rf, ok := ret.Get(0).(func(string) *types.Claims); ok {
 		r0 = rf(authorization)
 	} else {
 		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*jwt.Claims)
+			r0 = ret.Get(0).(*types.Claims)
 		}
 	}
 
@@ -46,3 +47,17 @@ func (_m *Interface) HandleCallback(writer http.ResponseWriter, request *http.Re
 func (_m *Interface) HandleRedirect(writer http.ResponseWriter, request *http.Request) {
 	_m.Called(writer, request)
 }
+
+// IsRBACEnabled provides a mock function with given fields:
+func (_m *Interface) IsRBACEnabled() bool {
+	ret := _m.Called()
+
+	var r0 bool
+	if rf, ok := ret.Get(0).(func() bool); ok {
+		r0 = rf()
+	} else {
+		r0 = ret.Get(0).(bool)
+	}
+
+	return r0
+}
diff --git a/server/auth/sso/null_sso.go b/server/auth/sso/null_sso.go
index bc363ac70..7ef2242c9 100644
--- a/server/auth/sso/null_sso.go
+++ b/server/auth/sso/null_sso.go
@@ -4,14 +4,18 @@ import (
 	"fmt"
 	"net/http"
 
-	"gopkg.in/square/go-jose.v2/jwt"
+	"github.com/argoproj/argo/server/auth/types"
 )
 
 var NullSSO Interface = nullService{}
 
 type nullService struct{}
 
-func (n nullService) Authorize(string) (*jwt.Claims, error) {
+func (n nullService) IsRBACEnabled() bool {
+	return false
+}
+
+func (n nullService) Authorize(string) (*types.Claims, error) {
 	return nil, fmt.Errorf("not implemented")
 }
 
diff --git a/server/auth/sso/sso.go b/server/auth/sso/sso.go
index 718f84ae9..cdd276fe6 100644
--- a/server/auth/sso/sso.go
+++ b/server/auth/sso/sso.go
@@ -20,6 +20,9 @@ import (
 	apiv1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
+
+	"github.com/argoproj/argo/server/auth/rbac"
+	"github.com/argoproj/argo/server/auth/types"
 )
 
 const (
@@ -33,9 +36,10 @@ const (
 //go:generate mockery -name Interface
 
 type Interface interface {
-	Authorize(authorization string) (*jwt.Claims, error)
+	Authorize(authorization string) (*types.Claims, error)
 	HandleRedirect(writer http.ResponseWriter, request *http.Request)
 	HandleCallback(writer http.ResponseWriter, request *http.Request)
+	IsRBACEnabled() bool
 }
 
 var _ Interface = &sso{}
@@ -47,6 +51,11 @@ type sso struct {
 	secure          bool
 	privateKey      crypto.PrivateKey
 	encrypter       jose.Encrypter
+	rbacConfig      *rbac.Config
+}
+
+func (s *sso) IsRBACEnabled() bool {
+	return s.rbacConfig.IsEnabled()
 }
 
 type Config struct {
@@ -54,6 +63,9 @@ type Config struct {
 	ClientID     apiv1.SecretKeySelector `json:"clientId"`
 	ClientSecret apiv1.SecretKeySelector `json:"clientSecret"`
 	RedirectURL  string                  `json:"redirectUrl"`
+	RBAC         *rbac.Config            `json:"rbac,omitempty"`
+	// additional scopes (on top of "openid")
+	Scopes []string `json:"scopes,omitempty"`
 }
 
 // Abstract methods of oidc.Provider that our code uses into an interface. That
@@ -146,10 +158,10 @@ func newSso(
 		ClientSecret: string(clientSecret),
 		RedirectURL:  c.RedirectURL,
 		Endpoint:     provider.Endpoint(),
-		Scopes:       []string{oidc.ScopeOpenID},
+		Scopes:       append(c.Scopes, oidc.ScopeOpenID),
 	}
 	idTokenVerifier := provider.Verifier(&oidc.Config{ClientID: config.ClientID})
-	encrypter, err := jose.NewEncrypter(jose.A256GCM, jose.Recipient{Algorithm: jose.RSA_OAEP_256, Key: privateKey.Public()}, nil)
+	encrypter, err := jose.NewEncrypter(jose.A256GCM, jose.Recipient{Algorithm: jose.RSA_OAEP_256, Key: privateKey.Public()}, &jose.EncrypterOptions{Compression: jose.DEFLATE})
 	if err != nil {
 		return nil, fmt.Errorf("failed to create JWT encrpytor: %w", err)
 	}
@@ -161,6 +173,7 @@ func newSso(
 		secure:          secure,
 		privateKey:      privateKey,
 		encrypter:       encrypter,
+		rbacConfig:      c.RBAC,
 	}, nil
 }
 
@@ -212,13 +225,13 @@ func (s *sso) HandleCallback(w http.ResponseWriter, r *http.Request) {
 		_, _ = w.Write([]byte(fmt.Sprintf("failed to verify token: %v", err)))
 		return
 	}
-	c := &jwt.Claims{}
+	c := &types.Claims{}
 	if err := idToken.Claims(c); err != nil {
 		w.WriteHeader(401)
 		_, _ = w.Write([]byte(fmt.Sprintf("failed to get claims: %v", err)))
 		return
 	}
-	argoClaims := &jwt.Claims{Issuer: issuer, Expiry: jwt.NewNumericDate(time.Now().Add(expiry))}
+	argoClaims := &types.Claims{Claims: jwt.Claims{Issuer: issuer, Subject: c.Subject, Expiry: jwt.NewNumericDate(time.Now().Add(expiry))}, Groups: c.Groups}
 	raw, err := jwt.Encrypted(s.encrypter).Claims(argoClaims).CompactSerialize()
 	if err != nil {
 		panic(err)
@@ -237,12 +250,12 @@ func (s *sso) HandleCallback(w http.ResponseWriter, r *http.Request) {
 }
 
 // authorize verifies a bearer token and pulls user information form the claims.
-func (s *sso) Authorize(authorization string) (*jwt.Claims, error) {
+func (s *sso) Authorize(authorization string) (*types.Claims, error) {
 	tok, err := jwt.ParseEncrypted(strings.TrimPrefix(authorization, Prefix))
 	if err != nil {
 		return nil, fmt.Errorf("failed to parse encrypted token %v", err)
 	}
-	c := &jwt.Claims{}
+	c := &types.Claims{}
 	if err := tok.Claims(s.privateKey, c); err != nil {
 		return nil, fmt.Errorf("failed to parse claims: %v", err)
 	}
diff --git a/server/auth/types/claims.go b/server/auth/types/claims.go
new file mode 100644
index 000000000..7b8c55e41
--- /dev/null
+++ b/server/auth/types/claims.go
@@ -0,0 +1,8 @@
+package types
+
+import "gopkg.in/square/go-jose.v2/jwt"
+
+type Claims struct {
+	jwt.Claims
+	Groups []string `json:"groups,omitempty"`
+}
diff --git a/server/info/info_server.go b/server/info/info_server.go
index becbd3934..57ba9a584 100644
--- a/server/info/info_server.go
+++ b/server/info/info_server.go
@@ -17,7 +17,7 @@ type infoServer struct {
 func (i *infoServer) GetUserInfo(ctx context.Context, _ *infopkg.GetUserInfoRequest) (*infopkg.GetUserInfoResponse, error) {
 	claims := auth.GetClaims(ctx)
 	if claims != nil {
-		return &infopkg.GetUserInfoResponse{Subject: claims.Subject, Issuer: claims.Issuer}, nil
+		return &infopkg.GetUserInfoResponse{Subject: claims.Subject, Issuer: claims.Issuer, Groups: claims.Groups}, nil
 	}
 	return &infopkg.GetUserInfoResponse{}, nil
 }
diff --git a/ui/src/app/cluster-workflow-templates/components/cluster-workflow-template-list/cluster-workflow-template-list.tsx b/ui/src/app/cluster-workflow-templates/components/cluster-workflow-template-list/cluster-workflow-template-list.tsx
index 6a01d1af1..b9677c78a 100644
--- a/ui/src/app/cluster-workflow-templates/components/cluster-workflow-template-list/cluster-workflow-template-list.tsx
+++ b/ui/src/app/cluster-workflow-templates/components/cluster-workflow-template-list/cluster-workflow-template-list.tsx
@@ -5,6 +5,7 @@ import * as models from '../../../../models';
 import {uiUrl} from '../../../shared/base';
 import {BasePage} from '../../../shared/components/base-page';
 import {ErrorNotice} from '../../../shared/components/error-notice';
+import {ExampleManifests} from '../../../shared/components/example-manifests';
 import {Loading} from '../../../shared/components/loading';
 import {ResourceEditor} from '../../../shared/components/resource-editor/resource-editor';
 import {Timestamp} from '../../../shared/components/timestamp';
@@ -68,6 +69,9 @@ export class ClusterWorkflowTemplateList extends BasePage<RouteComponentProps<an
                                     services.clusterWorkflowTemplate.create(wfTmpl).then(wf => ctx.navigation.goto(uiUrl(`cluster-workflow-templates/${wf.metadata.name}`)))
                                 }
                             />
+                            <p>
+                                <ExampleManifests />.
+                            </p>
                         </SlidingPanel>
                     </Page>
                 )}
@@ -94,7 +98,9 @@ export class ClusterWorkflowTemplateList extends BasePage<RouteComponentProps<an
             return (
                 <ZeroState title='No cluster workflow templates'>
                     <p>You can create new templates here or using the CLI.</p>
-                    <p>{learnMore}.</p>
+                    <p>
+                        <ExampleManifests />. {learnMore}.
+                    </p>
                 </ZeroState>
             );
         }
@@ -120,7 +126,8 @@ export class ClusterWorkflowTemplateList extends BasePage<RouteComponentProps<an
                         ))}
                     </div>
                     <p>
-                        <i className='fa fa-info-circle' /> Cluster scoped Workflow templates are reusable templates you can create new workflows from. {learnMore}.
+                        <i className='fa fa-info-circle' /> Cluster scoped Workflow templates are reusable templates you can create new workflows from. <ExampleManifests />.{' '}
+                        {learnMore}.
                     </p>
                 </div>
             </div>
diff --git a/ui/src/app/cluster-workflow-templates/components/cluster-workflow-template-summary-panel.tsx b/ui/src/app/cluster-workflow-templates/components/cluster-workflow-template-summary-panel.tsx
index 857f73255..6f23cc26d 100644
--- a/ui/src/app/cluster-workflow-templates/components/cluster-workflow-template-summary-panel.tsx
+++ b/ui/src/app/cluster-workflow-templates/components/cluster-workflow-template-summary-panel.tsx
@@ -1,6 +1,7 @@
 import * as React from 'react';
 
 import {WorkflowTemplate} from '../../../models';
+import {ExampleManifests} from '../../shared/components/example-manifests';
 import {ResourceEditor} from '../../shared/components/resource-editor/resource-editor';
 import {Timestamp} from '../../shared/components/timestamp';
 import {services} from '../../shared/services';
@@ -38,6 +39,9 @@ export const ClusterWorkflowTemplateSummaryPanel = (props: Props) => {
                             services.clusterWorkflowTemplate.update(value, props.template.metadata.name).then(clusterWorkflowTemplate => props.onChange(clusterWorkflowTemplate))
                         }
                     />
+                    <p>
+                        <ExampleManifests />
+                    </p>
                 </div>
             </div>
         </div>
diff --git a/ui/src/app/cron-workflows/components/cron-workflow-list/cron-workflow-list.tsx b/ui/src/app/cron-workflows/components/cron-workflow-list/cron-workflow-list.tsx
index 838648f80..ab2e9f0f6 100644
--- a/ui/src/app/cron-workflows/components/cron-workflow-list/cron-workflow-list.tsx
+++ b/ui/src/app/cron-workflows/components/cron-workflow-list/cron-workflow-list.tsx
@@ -5,6 +5,7 @@ import * as models from '../../../../models';
 import {uiUrl} from '../../../shared/base';
 import {BasePage} from '../../../shared/components/base-page';
 import {ErrorNotice} from '../../../shared/components/error-notice';
+import {ExampleManifests} from '../../../shared/components/example-manifests';
 import {Loading} from '../../../shared/components/loading';
 import {NamespaceFilter} from '../../../shared/components/namespace-filter';
 import {ResourceEditor} from '../../../shared/components/resource-editor/resource-editor';
@@ -85,6 +86,9 @@ export class CronWorkflowList extends BasePage<RouteComponentProps<any>, State>
                                 editing={true}
                                 kind='CronWorkflow'
                             />
+                            <p>
+                                <ExampleManifests />.
+                            </p>
                         </SlidingPanel>
                     </Page>
                 )}
@@ -116,7 +120,9 @@ export class CronWorkflowList extends BasePage<RouteComponentProps<any>, State>
             return (
                 <ZeroState title='No cron workflows'>
                     <p>You can create new cron workflows here or using the CLI.</p>
-                    <p>{learnMore}.</p>
+                    <p>
+                        <ExampleManifests />. {learnMore}.
+                    </p>
                 </ZeroState>
             );
         }
@@ -148,7 +154,7 @@ export class CronWorkflowList extends BasePage<RouteComponentProps<any>, State>
                     ))}
                 </div>
                 <p>
-                    <i className='fa fa-info-circle' /> Cron workflows are workflows that run on a preset schedule. {learnMore}.
+                    <i className='fa fa-info-circle' /> Cron workflows are workflows that run on a preset schedule. <ExampleManifests />. {learnMore}.
                 </p>
             </>
         );
diff --git a/ui/src/app/reports/components/reports.tsx b/ui/src/app/reports/components/reports.tsx
index 311577ed1..70ef43b99 100644
--- a/ui/src/app/reports/components/reports.tsx
+++ b/ui/src/app/reports/components/reports.tsx
@@ -126,7 +126,7 @@ export class Reports extends BasePage<RouteComponentProps<any>, State> {
 
     private getExtractDatasets(workflows: Workflow[]) {
         const filteredWorkflows = workflows
-            .filter(wf => wf.status.finishedAt !== '')
+            .filter(wf => !!wf.status.finishedAt)
             .map(wf => ({
                 name: wf.metadata.name,
                 finishedAt: new Date(wf.status.finishedAt),
diff --git a/ui/src/app/shared/components/example-manifests.tsx b/ui/src/app/shared/components/example-manifests.tsx
new file mode 100644
index 000000000..581e3101b
--- /dev/null
+++ b/ui/src/app/shared/components/example-manifests.tsx
@@ -0,0 +1,8 @@
+import * as React from 'react';
+
+export const ExampleManifests = () => (
+    <>
+        You can find manifests <a href='https://github.com/argoproj/argo/tree/master/examples'>in the examples</a> or templates in{' '}
+        <a href='https://argoproj-labs.github.io/argo-workflows-catalog/'>Workflow Template Catalog</a>
+    </>
+);
diff --git a/ui/src/app/userinfo/components/user-info.tsx b/ui/src/app/userinfo/components/user-info.tsx
index f32c0411a..c0c976074 100644
--- a/ui/src/app/userinfo/components/user-info.tsx
+++ b/ui/src/app/userinfo/components/user-info.tsx
@@ -38,6 +38,7 @@ export class UserInfo extends BasePage<RouteComponentProps<any>, State> {
                             <>
                                 <p>Issuer: {this.state.userInfo.issuer || '-'}</p>
                                 <p>Subject: {this.state.userInfo.subject || '-'}</p>
+                                <p>Groups: {(this.state.userInfo.groups && this.state.userInfo.groups.length > 0 && this.state.userInfo.groups.join(', ')) || '-'}</p>
                             </>
                         )}
                         <a className='argo-button argo-button--base-o' href={uiUrl('login')}>
diff --git a/ui/src/app/workflow-templates/components/workflow-template-list/workflow-template-list.tsx b/ui/src/app/workflow-templates/components/workflow-template-list/workflow-template-list.tsx
index 521eceaea..c27e770d5 100644
--- a/ui/src/app/workflow-templates/components/workflow-template-list/workflow-template-list.tsx
+++ b/ui/src/app/workflow-templates/components/workflow-template-list/workflow-template-list.tsx
@@ -5,6 +5,7 @@ import * as models from '../../../../models';
 import {uiUrl} from '../../../shared/base';
 import {BasePage} from '../../../shared/components/base-page';
 import {ErrorNotice} from '../../../shared/components/error-notice';
+import {ExampleManifests} from '../../../shared/components/example-manifests';
 import {Loading} from '../../../shared/components/loading';
 import {NamespaceFilter} from '../../../shared/components/namespace-filter';
 import {ResourceEditor} from '../../../shared/components/resource-editor/resource-editor';
@@ -83,6 +84,9 @@ export class WorkflowTemplateList extends BasePage<RouteComponentProps<any>, Sta
                                 }
                                 editing={true}
                             />
+                            <p>
+                                <ExampleManifests />.
+                            </p>
                         </SlidingPanel>
                     </Page>
                 )}
@@ -114,7 +118,9 @@ export class WorkflowTemplateList extends BasePage<RouteComponentProps<any>, Sta
             return (
                 <ZeroState title='No workflow templates'>
                     <p>You can create new templates here or using the CLI.</p>
-                    <p>{learnMore}.</p>
+                    <p>
+                        <ExampleManifests />. {learnMore}.
+                    </p>
                 </ZeroState>
             );
         }
@@ -145,7 +151,7 @@ export class WorkflowTemplateList extends BasePage<RouteComponentProps<any>, Sta
                         ))}
                     </div>
                     <p>
-                        <i className='fa fa-info-circle' /> Workflow templates are reusable templates you can create new workflows from. {learnMore}.
+                        <i className='fa fa-info-circle' /> Workflow templates are reusable templates you can create new workflows from. <ExampleManifests />. {learnMore}.
                     </p>
                 </div>
             </div>
diff --git a/ui/src/app/workflows/components/workflow-details/workflow-details.tsx b/ui/src/app/workflows/components/workflow-details/workflow-details.tsx
index ef097a1ba..e627d2ad6 100644
--- a/ui/src/app/workflows/components/workflow-details/workflow-details.tsx
+++ b/ui/src/app/workflows/components/workflow-details/workflow-details.tsx
@@ -47,6 +47,10 @@ export class WorkflowDetails extends React.Component<RouteComponentProps<any>, W
     private changesSubscription: Subscription;
     private timelineComponent: WorkflowTimeline;
 
+    private get resourceVersion() {
+        return this.state.workflow && this.state.workflow.metadata.resourceVersion;
+    }
+
     private get selectedTabKey() {
         return new URLSearchParams(this.props.location.search).get('tab') || 'workflow';
     }
@@ -325,15 +329,15 @@ export class WorkflowDetails extends React.Component<RouteComponentProps<any>, W
         this.changesSubscription = null;
     }
 
-    private async loadWorkflow(namespace: string, name: string) {
+    private loadWorkflow(namespace: string, name: string) {
         try {
             this.ensureUnsubscribed();
             this.changesSubscription = services.workflows
-                .watch({name, namespace})
+                .watch({name, namespace, resourceVersion: this.resourceVersion})
                 .map(changeEvent => changeEvent.object)
                 .subscribe(
                     workflow => this.setState({workflow, error: null}),
-                    error => this.setState({error})
+                    error => this.setState({error}, () => this.loadWorkflow(namespace, name))
                 );
         } catch (error) {
             this.setState({error});
diff --git a/ui/src/app/workflows/components/workflow-drawer/workflow-drawer.tsx b/ui/src/app/workflows/components/workflow-drawer/workflow-drawer.tsx
index 80bf48fe5..db97b7589 100644
--- a/ui/src/app/workflows/components/workflow-drawer/workflow-drawer.tsx
+++ b/ui/src/app/workflows/components/workflow-drawer/workflow-drawer.tsx
@@ -34,19 +34,21 @@ export class WorkflowDrawer extends React.Component<WorkflowDrawerProps, Workflo
     }
 
     public render() {
-        if (!this.state.workflow) {
+        const wf = this.state.workflow;
+
+        if (!wf) {
             return <Loading />;
         }
-        const wf = this.state.workflow;
+
         return (
             <div className='workflow-drawer'>
-                {!wf.status.message ? null : (
+                {!wf.status || !wf.status.message ? null : (
                     <div className='workflow-drawer__section workflow-drawer__message'>
                         <div className='workflow-drawer__title workflow-drawer__message--label'>MESSAGE</div>
                         <div className='workflow-drawer__message--content'>{wf.status.message}</div>
                     </div>
                 )}
-                {!wf.status.conditions ? null : (
+                {!wf.status || !wf.status.conditions ? null : (
                     <div className='workflow-drawer__section'>
                         <div className='workflow-drawer__title'>CONDITIONS</div>
                         <div className='workflow-drawer__conditions'>
@@ -54,7 +56,7 @@ export class WorkflowDrawer extends React.Component<WorkflowDrawerProps, Workflo
                         </div>
                     </div>
                 )}
-                {!wf.status.resourcesDuration ? null : (
+                {!wf.status || !wf.status.resourcesDuration ? null : (
                     <div className='workflow-drawer__section'>
                         <div>
                             <InlineTable
@@ -89,7 +91,7 @@ export class WorkflowDrawer extends React.Component<WorkflowDrawerProps, Workflo
                 <div className='workflow-drawer__section'>
                     <div className='workflow-drawer__title'>FROM</div>
                     <div className='workflow-drawer__workflowFrom'>
-                        <WorkflowFrom namespace={wf.metadata.namespace} labels={wf.metadata.labels} />
+                        <WorkflowFrom namespace={wf.metadata.namespace || 'default'} labels={wf.metadata.labels || {}} />
                     </div>
                 </div>
                 <div className='workflow-drawer__section workflow-drawer__labels'>
diff --git a/ui/src/app/workflows/components/workflow-logs-viewer/workflow-logs-viewer.tsx b/ui/src/app/workflows/components/workflow-logs-viewer/workflow-logs-viewer.tsx
index ddcf35ff6..a9d6aaacf 100644
--- a/ui/src/app/workflows/components/workflow-logs-viewer/workflow-logs-viewer.tsx
+++ b/ui/src/app/workflows/components/workflow-logs-viewer/workflow-logs-viewer.tsx
@@ -18,16 +18,21 @@ interface WorkflowLogsViewerProps {
 interface WorkflowLogsViewerState {
     error?: Error;
     loaded: boolean;
-    lines: string[];
+    lineCount: number;
+}
+
+function identity<T>(value: T) {
+    return () => value;
 }
 
 export class WorkflowLogsViewer extends React.Component<WorkflowLogsViewerProps, WorkflowLogsViewerState> {
     private subscription: Subscription | null = null;
+    private logsObservable?: Observable<string>;
 
     constructor(props: WorkflowLogsViewerProps) {
         super(props);
 
-        this.state = {lines: [], loaded: false};
+        this.state = {lineCount: 0, loaded: false};
     }
 
     public componentDidMount(): void {
@@ -49,7 +54,7 @@ export class WorkflowLogsViewer extends React.Component<WorkflowLogsViewerProps,
                 )}
                 <p>
                     <i className='fa fa-box' /> {this.props.nodeId}/{this.props.container}
-                    {this.state.lines.length > 0 && <small className='muted'> {this.state.lines.length} line(s)</small>}
+                    {this.state.lineCount > 0 && <small className='muted'> {this.state.lineCount} line(s)</small>}
                 </p>
 
                 {this.state.error && <ErrorNotice error={this.state.error} onReload={() => this.refreshStream()} />}
@@ -60,19 +65,19 @@ export class WorkflowLogsViewer extends React.Component<WorkflowLogsViewerProps,
                         </p>
                     )}
                     {!this.state.error && this.podHasNoLogs() && <p>Pod did not output any logs.</p>}
-                    {this.state.lines.length > 0 && (
+                    {this.state.lineCount > 0 && this.logsObservable && (
                         <div className='log-box'>
                             <FullHeightLogsViewer
                                 source={{
                                     key: `${this.props.workflow.metadata.name}-${this.props.container}`,
-                                    loadLogs: () => Observable.from(this.state.lines),
+                                    loadLogs: identity(this.logsObservable),
                                     shouldRepeat: () => false
                                 }}
                             />
                         </div>
                     )}
                 </div>
-                {this.state.lines.length === 0 && (
+                {this.state.lineCount === 0 && (
                     <p>
                         Still waiting for data or an error? Try getting{' '}
                         <a href={services.workflows.getArtifactLogsUrl(this.props.workflow, this.props.nodeId, this.props.container, this.props.archived)}>
@@ -95,14 +100,15 @@ export class WorkflowLogsViewer extends React.Component<WorkflowLogsViewerProps,
     private refreshStream(): void {
         this.ensureUnsubscribed();
 
-        this.setState({lines: [], loaded: false, error: undefined});
+        this.setState({lineCount: 0, loaded: false, error: undefined});
+
+        const source = services.workflows.getContainerLogs(this.props.workflow, this.props.nodeId, this.props.container, this.props.archived).map(line => line + '\n');
 
-        this.subscription = services.workflows.getContainerLogs(this.props.workflow, this.props.nodeId, this.props.container, this.props.archived).subscribe(
+        this.logsObservable = source.publishReplay().refCount();
+        this.subscription = this.logsObservable.subscribe(
             log => {
                 this.setState(state => {
-                    const newState = {...state, loaded: true};
-                    newState.lines.push(log + '\n');
-                    return newState;
+                    return {...state, lineCount: state.lineCount + 1};
                 });
             },
             error => {
@@ -112,11 +118,11 @@ export class WorkflowLogsViewer extends React.Component<WorkflowLogsViewerProps,
     }
 
     private podHasNoLogs() {
-        return !this.isWaitingForData() && this.state.lines.length === 0;
+        return !this.isWaitingForData() && this.state.lineCount === 0;
     }
 
     private isWaitingForData() {
-        return this.state.lines.length === 0 && (this.isCurrentNodeRunningOrPending() || !this.state.loaded);
+        return this.state.lineCount === 0 && (this.isCurrentNodeRunningOrPending() || !this.state.loaded);
     }
 
     private isCurrentNodeRunningOrPending(): boolean {
diff --git a/ui/src/app/workflows/components/workflows-list/workflows-list.tsx b/ui/src/app/workflows/components/workflows-list/workflows-list.tsx
index 3c33517c7..ca59b8c2f 100644
--- a/ui/src/app/workflows/components/workflows-list/workflows-list.tsx
+++ b/ui/src/app/workflows/components/workflows-list/workflows-list.tsx
@@ -19,6 +19,7 @@ import * as Actions from '../../../shared/workflow-operations-map';
 
 import {CostOptimisationNudge} from '../../../shared/components/cost-optimisation-nudge';
 import {ErrorNotice} from '../../../shared/components/error-notice';
+import {ExampleManifests} from '../../../shared/components/example-manifests';
 import {PaginationPanel} from '../../../shared/components/pagination-panel';
 import {ResourceEditor} from '../../../shared/components/resource-editor/resource-editor';
 import {Pagination, parseLimit} from '../../../shared/pagination';
@@ -192,6 +193,9 @@ export class WorkflowsList extends BasePage<RouteComponentProps<any>, State> {
                                         .then(wf => ctx.navigation.goto(uiUrl(`workflows/${wf.metadata.namespace}/${wf.metadata.name}`)))
                                 }
                             />
+                            <p>
+                                <ExampleManifests />.
+                            </p>
                         </SlidingPanel>
                     </Page>
                 )}
@@ -293,6 +297,9 @@ export class WorkflowsList extends BasePage<RouteComponentProps<any>, State> {
             return (
                 <ZeroState title='No workflows'>
                     <p>To create a new workflow, use the button above.</p>
+                    <p>
+                        <ExampleManifests />.
+                    </p>
                 </ZeroState>
             );
         }
diff --git a/ui/src/models/info.ts b/ui/src/models/info.ts
index 5ddd827d0..84927f32a 100644
--- a/ui/src/models/info.ts
+++ b/ui/src/models/info.ts
@@ -16,4 +16,5 @@ export interface Version {
 export interface GetUserInfoResponse {
     subject?: string;
     issuer?: string;
+    groups?: string[];
 }
diff --git a/workflow/common/common.go b/workflow/common/common.go
index 9cf0a6406..df3ff82fd 100644
--- a/workflow/common/common.go
+++ b/workflow/common/common.go
@@ -35,6 +35,10 @@ const (
 	// AnnotationKeyNodeName is the node's type
 	AnnotationKeyNodeType = workflow.WorkflowFullName + "/node-type"
 
+	// AnnotationKeyRBACRule is a rule to match the claims
+	AnnotationKeyRBACRule           = workflow.WorkflowFullName + "/rbac-rule"
+	AnnotationKeyRBACRulePrecedence = workflow.WorkflowFullName + "/rbac-rule-precedence"
+
 	// AnnotationKeyNodeMessage is the pod metadata annotation key the executor will use to
 	// communicate errors encountered by the executor during artifact load/save, etc...
 	AnnotationKeyNodeMessage = workflow.WorkflowFullName + "/node-message"
diff --git a/workflow/controller/estimation/estimator.go b/workflow/controller/estimation/estimator.go
index a917e295e..27f2fa076 100644
--- a/workflow/controller/estimation/estimator.go
+++ b/workflow/controller/estimation/estimator.go
@@ -1,6 +1,8 @@
 package estimation
 
 import (
+	"strings"
+
 	wfv1 "github.com/argoproj/argo/pkg/apis/workflow/v1alpha1"
 )
 
@@ -26,10 +28,6 @@ func (e *estimator) EstimateNodeDuration(nodeName string) wfv1.EstimatedDuration
 	if e.baselineWF == nil {
 		return 0
 	}
-	// special case for root node
-	if nodeName == e.wf.Name {
-		nodeName = e.baselineWF.Name
-	}
-	oldNodeID := e.baselineWF.NodeID(nodeName)
+	oldNodeID := e.baselineWF.NodeID(strings.Replace(nodeName, e.wf.Name, e.baselineWF.Name, 1))
 	return wfv1.NewEstimatedDuration(e.baselineWF.Status.Nodes[oldNodeID].GetDuration())
 }
diff --git a/workflow/controller/operator.go b/workflow/controller/operator.go
index 55b3e822d..91456b93a 100644
--- a/workflow/controller/operator.go
+++ b/workflow/controller/operator.go
@@ -1553,13 +1553,13 @@ func (woc *wfOperationCtx) executeTemplate(nodeName string, orgTmpl wfv1.Templat
 				woc.controller.syncManager.Release(woc.wf, node.ID, processedTmpl.Synchronization)
 			}
 			woc.log.Debugf("Node %s already completed", nodeName)
-			if resolvedTmpl.Metrics != nil {
+			if processedTmpl.Metrics != nil {
 				// Check if this node completed between executions. If it did, emit metrics. If a node completes within
 				// the same execution, its metrics are emitted below.
 				// We can infer that this node completed during the current operation, emit metrics
 				if prevNodeStatus, ok := woc.preExecutionNodePhases[node.ID]; ok && !prevNodeStatus.Fulfilled() {
 					localScope, realTimeScope := woc.prepareMetricScope(node)
-					woc.computeMetrics(resolvedTmpl.Metrics.Prometheus, localScope, realTimeScope, false)
+					woc.computeMetrics(processedTmpl.Metrics.Prometheus, localScope, realTimeScope, false)
 				}
 			}
 			return node, nil
@@ -1643,14 +1643,14 @@ func (woc *wfOperationCtx) executeTemplate(nodeName string, orgTmpl wfv1.Templat
 		retryParentNode = processedRetryParentNode
 		// The retry node might have completed by now.
 		if retryParentNode.Fulfilled() {
-			if resolvedTmpl.Metrics != nil {
+			if processedTmpl.Metrics != nil {
 				// In this check, a completed node may or may not have existed prior to this execution. If it did exist, ensure that it wasn't
 				// completed before this execution. If it did not exist prior, then we can infer that it was completed during this execution.
 				// The statement "(!ok || !prevNodeStatus.Fulfilled())" checks for this behavior and represents the material conditional
 				// "ok -> !prevNodeStatus.Fulfilled()" (https://en.wikipedia.org/wiki/Material_conditional)
 				if prevNodeStatus, ok := woc.preExecutionNodePhases[retryParentNode.ID]; (!ok || !prevNodeStatus.Fulfilled()) && retryParentNode.Fulfilled() {
 					localScope, realTimeScope := woc.prepareMetricScope(node)
-					woc.computeMetrics(resolvedTmpl.Metrics.Prometheus, localScope, realTimeScope, false)
+					woc.computeMetrics(processedTmpl.Metrics.Prometheus, localScope, realTimeScope, false)
 				}
 			}
 			return retryParentNode, nil
@@ -1715,12 +1715,12 @@ func (woc *wfOperationCtx) executeTemplate(nodeName string, orgTmpl wfv1.Templat
 		}
 	}
 
-	if resolvedTmpl.Metrics != nil {
+	if processedTmpl.Metrics != nil {
 		// Check if the node was just created, if it was emit realtime metrics.
 		// If the node did not previously exist, we can infer that it was created during the current operation, emit real time metrics.
 		if _, ok := woc.preExecutionNodePhases[node.ID]; !ok {
 			localScope, realTimeScope := woc.prepareMetricScope(node)
-			woc.computeMetrics(resolvedTmpl.Metrics.Prometheus, localScope, realTimeScope, true)
+			woc.computeMetrics(processedTmpl.Metrics.Prometheus, localScope, realTimeScope, true)
 		}
 		// Check if the node completed during this execution, if it did emit metrics
 		//
@@ -1734,7 +1734,7 @@ func (woc *wfOperationCtx) executeTemplate(nodeName string, orgTmpl wfv1.Templat
 		// "ok -> !prevNodeStatus.Fulfilled()" (https://en.wikipedia.org/wiki/Material_conditional)
 		if prevNodeStatus, ok := woc.preExecutionNodePhases[node.ID]; (!ok || !prevNodeStatus.Fulfilled()) && node.Fulfilled() {
 			localScope, realTimeScope := woc.prepareMetricScope(node)
-			woc.computeMetrics(resolvedTmpl.Metrics.Prometheus, localScope, realTimeScope, false)
+			woc.computeMetrics(processedTmpl.Metrics.Prometheus, localScope, realTimeScope, false)
 		}
 	}
 
diff --git a/workflow/validate/validate.go b/workflow/validate/validate.go
index 722d9f9a0..0f15b58c8 100644
--- a/workflow/validate/validate.go
+++ b/workflow/validate/validate.go
@@ -222,7 +222,7 @@ func ValidateWorkflow(wftmplGetter templateresolution.WorkflowTemplateNamespaced
 		if hasWorkflowTemplateRef {
 			tmpl = &wfv1.WorkflowStep{TemplateRef: wfTmplRef}
 		}
-		_, err = ctx.validateTemplateHolder(tmpl, tmplCtx, args, map[string]interface{}{})
+		_, err = ctx.validateTemplateHolder(tmpl, tmplCtx, args)
 		if err != nil {
 			return nil, err
 		}
@@ -231,7 +231,7 @@ func ValidateWorkflow(wftmplGetter templateresolution.WorkflowTemplateNamespaced
 		// now when validating onExit, {{workflow.status}} is now available as a global
 		ctx.globalParams[common.GlobalVarWorkflowStatus] = placeholderGenerator.NextPlaceholder()
 		ctx.globalParams[common.GlobalVarWorkflowFailures] = placeholderGenerator.NextPlaceholder()
-		_, err = ctx.validateTemplateHolder(&wfv1.WorkflowStep{Template: wf.Spec.OnExit}, tmplCtx, &wf.Spec.Arguments, map[string]interface{}{})
+		_, err = ctx.validateTemplateHolder(&wfv1.WorkflowStep{Template: wf.Spec.OnExit}, tmplCtx, &wf.Spec.Arguments)
 		if err != nil {
 			return nil, err
 		}
@@ -247,7 +247,7 @@ func ValidateWorkflow(wftmplGetter templateresolution.WorkflowTemplateNamespaced
 
 	// Check if all templates can be resolved.
 	for _, template := range wf.Spec.Templates {
-		_, err := ctx.validateTemplateHolder(&wfv1.WorkflowStep{Template: template.Name}, tmplCtx, &FakeArguments{}, map[string]interface{}{})
+		_, err := ctx.validateTemplateHolder(&wfv1.WorkflowStep{Template: template.Name}, tmplCtx, &FakeArguments{})
 		if err != nil {
 			return nil, errors.Errorf(errors.CodeBadRequest, "templates.%s %s", template.Name, err.Error())
 		}
@@ -358,12 +358,12 @@ For more information, see: %s
 	return out
 }
 
-func (ctx *templateValidationCtx) validateTemplate(tmpl *wfv1.Template, tmplCtx *templateresolution.Context, args wfv1.ArgumentsProvider, extraScope map[string]interface{}) error {
+func (ctx *templateValidationCtx) validateTemplate(tmpl *wfv1.Template, tmplCtx *templateresolution.Context, args wfv1.ArgumentsProvider) error {
 	if err := validateTemplateType(tmpl); err != nil {
 		return err
 	}
 
-	scope, err := validateInputs(tmpl, extraScope)
+	scope, err := validateInputs(tmpl)
 	if err != nil {
 		return err
 	}
@@ -458,7 +458,7 @@ func (ctx *templateValidationCtx) validateTemplate(tmpl *wfv1.Template, tmplCtx
 }
 
 // validateTemplateHolder validates a template holder and returns the validated template.
-func (ctx *templateValidationCtx) validateTemplateHolder(tmplHolder wfv1.TemplateReferenceHolder, tmplCtx *templateresolution.Context, args wfv1.ArgumentsProvider, extraScope map[string]interface{}) (*wfv1.Template, error) {
+func (ctx *templateValidationCtx) validateTemplateHolder(tmplHolder wfv1.TemplateReferenceHolder, tmplCtx *templateresolution.Context, args wfv1.ArgumentsProvider) (*wfv1.Template, error) {
 	tmplRef := tmplHolder.GetTemplateRef()
 	tmplName := tmplHolder.GetTemplateName()
 	if tmplRef != nil {
@@ -512,7 +512,7 @@ func (ctx *templateValidationCtx) validateTemplateHolder(tmplHolder wfv1.Templat
 		}
 	}
 
-	return resolvedTmpl, ctx.validateTemplate(resolvedTmpl, tmplCtx, args, extraScope)
+	return resolvedTmpl, ctx.validateTemplate(resolvedTmpl, tmplCtx, args)
 }
 
 // validateTemplateType validates that only one template type is defined
@@ -537,7 +537,7 @@ func validateTemplateType(tmpl *wfv1.Template) error {
 	return nil
 }
 
-func validateInputs(tmpl *wfv1.Template, extraScope map[string]interface{}) (map[string]interface{}, error) {
+func validateInputs(tmpl *wfv1.Template) (map[string]interface{}, error) {
 	err := validateWorkflowFieldNames(tmpl.Inputs.Parameters)
 	if err != nil {
 		return nil, errors.Errorf(errors.CodeBadRequest, "templates.%s.inputs.parameters%s", tmpl.Name, err.Error())
@@ -547,9 +547,6 @@ func validateInputs(tmpl *wfv1.Template, extraScope map[string]interface{}) (map
 		return nil, errors.Errorf(errors.CodeBadRequest, "templates.%s.inputs.artifacts%s", tmpl.Name, err.Error())
 	}
 	scope := make(map[string]interface{})
-	for name, value := range extraScope {
-		scope[name] = value
-	}
 	for _, param := range tmpl.Inputs.Parameters {
 		scope[fmt.Sprintf("inputs.parameters.%s", param.Name)] = true
 	}
@@ -815,7 +812,7 @@ func (ctx *templateValidationCtx) validateSteps(scope map[string]interface{}, tm
 			if err != nil {
 				return err
 			}
-			resolvedTmpl, err := ctx.validateTemplateHolder(&step, tmplCtx, &FakeArguments{}, scope)
+			resolvedTmpl, err := ctx.validateTemplateHolder(&step, tmplCtx, &FakeArguments{})
 			if err != nil {
 				return errors.Errorf(errors.CodeBadRequest, "templates.%s.steps[%d].%s %s", tmpl.Name, i, step.Name, err.Error())
 			}
@@ -837,7 +834,7 @@ func (ctx *templateValidationCtx) validateSteps(scope map[string]interface{}, tm
 			ctx.addOutputsToScope(resolvedTmpl, fmt.Sprintf("steps.%s", step.Name), scope, aggregate, false)
 
 			// Validate the template again with actual arguments.
-			_, err = ctx.validateTemplateHolder(&step, tmplCtx, &step.Arguments, scope)
+			_, err = ctx.validateTemplateHolder(&step, tmplCtx, &step.Arguments)
 			if err != nil {
 				return errors.Errorf(errors.CodeBadRequest, "templates.%s.steps[%d].%s %s", tmpl.Name, i, step.Name, err.Error())
 			}
@@ -1216,7 +1213,7 @@ func (ctx *templateValidationCtx) validateDAG(scope map[string]interface{}, tmpl
 			return errors.Errorf(errors.CodeBadRequest, "templates.%s cannot use 'continueOn' when using 'depends'. Instead use 'dep-task.Failed'/'dep-task.Errored'", tmpl.Name)
 		}
 
-		resolvedTmpl, err := ctx.validateTemplateHolder(&task, tmplCtx, &FakeArguments{}, map[string]interface{}{})
+		resolvedTmpl, err := ctx.validateTemplateHolder(&task, tmplCtx, &FakeArguments{})
 		if err != nil {
 			return errors.Errorf(errors.CodeBadRequest, "templates.%s.tasks.%s %s", tmpl.Name, task.Name, err.Error())
 		}
@@ -1286,7 +1283,7 @@ func (ctx *templateValidationCtx) validateDAG(scope map[string]interface{}, tmpl
 			return err
 		}
 		// Validate the template again with actual arguments.
-		_, err = ctx.validateTemplateHolder(&task, tmplCtx, &task.Arguments, scope)
+		_, err = ctx.validateTemplateHolder(&task, tmplCtx, &task.Arguments)
 		if err != nil {
 			return errors.Errorf(errors.CodeBadRequest, "templates.%s.tasks.%s %s", tmpl.Name, task.Name, err.Error())
 		}


Test patch:
diff --git a/server/auth/gatekeeper_test.go b/server/auth/gatekeeper_test.go
index e1bdefe85..54267ea65 100644
--- a/server/auth/gatekeeper_test.go
+++ b/server/auth/gatekeeper_test.go
@@ -2,44 +2,94 @@ package auth
 
 import (
 	"context"
+	"os"
 	"testing"
 
+	log "github.com/sirupsen/logrus"
+	"github.com/sirupsen/logrus/hooks/test"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 	"google.golang.org/grpc/metadata"
-	"k8s.io/client-go/kubernetes/fake"
-	"k8s.io/client-go/rest"
-
 	"gopkg.in/square/go-jose.v2/jwt"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+	kubefake "k8s.io/client-go/kubernetes/fake"
+	"k8s.io/client-go/rest"
 
+	"github.com/argoproj/argo/pkg/client/clientset/versioned"
 	fakewfclientset "github.com/argoproj/argo/pkg/client/clientset/versioned/fake"
-	"github.com/argoproj/argo/server/auth/sso/mocks"
+	ssomocks "github.com/argoproj/argo/server/auth/sso/mocks"
+	"github.com/argoproj/argo/server/auth/types"
+	"github.com/argoproj/argo/workflow/common"
 )
 
 func TestServer_GetWFClient(t *testing.T) {
-	wfClient := &fakewfclientset.Clientset{}
-	kubeClient := &fake.Clientset{}
+	// prevent using local KUBECONFIG - which will fail on CI
+	_ = os.Setenv("KUBECONFIG", "/dev/null")
+	defer func() { _ = os.Unsetenv("KUBECONFIG") }()
+	wfClient := fakewfclientset.NewSimpleClientset()
+	kubeClient := kubefake.NewSimpleClientset(
+		&corev1.ServiceAccount{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: "my-other-sa", Namespace: "my-ns",
+				Annotations: map[string]string{
+					common.AnnotationKeyRBACRule:           "'other-group' in groups",
+					common.AnnotationKeyRBACRulePrecedence: "0",
+				},
+			},
+			Secrets: []corev1.ObjectReference{{Name: "my-secret"}},
+		},
+		&corev1.ServiceAccount{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: "my-sa", Namespace: "my-ns",
+				Annotations: map[string]string{
+					common.AnnotationKeyRBACRule:           "'my-group' in groups",
+					common.AnnotationKeyRBACRulePrecedence: "1",
+				},
+			},
+			Secrets: []corev1.ObjectReference{{Name: "my-secret"}},
+		},
+		&corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{Name: "my-secret", Namespace: "my-ns"},
+			Data: map[string][]byte{
+				"token": {},
+			},
+		},
+	)
+	var clientForAuthorization ClientForAuthorization = func(authorization string) (*rest.Config, versioned.Interface, kubernetes.Interface, error) {
+		return &rest.Config{}, &fakewfclientset.Clientset{}, &kubefake.Clientset{}, nil
+	}
 	t.Run("None", func(t *testing.T) {
-		_, err := NewGatekeeper(Modes{}, wfClient, kubeClient, nil, nil)
+		_, err := NewGatekeeper(Modes{}, wfClient, kubeClient, nil, nil, clientForAuthorization, "")
 		assert.Error(t, err)
 	})
 	t.Run("Invalid", func(t *testing.T) {
-		g, err := NewGatekeeper(Modes{Client: true}, wfClient, kubeClient, nil, nil)
+		g, err := NewGatekeeper(Modes{Client: true}, wfClient, kubeClient, nil, nil, clientForAuthorization, "")
 		if assert.NoError(t, err) {
 			_, err := g.Context(x("invalid"))
 			assert.Error(t, err)
 		}
 	})
 	t.Run("NotAllowed", func(t *testing.T) {
-		g, err := NewGatekeeper(Modes{SSO: true}, wfClient, kubeClient, nil, nil)
+		g, err := NewGatekeeper(Modes{SSO: true}, wfClient, kubeClient, nil, nil, clientForAuthorization, "")
 		if assert.NoError(t, err) {
 			_, err := g.Context(x("Bearer "))
 			assert.Error(t, err)
 		}
 	})
-	// not possible to unit test client auth today
+	t.Run("Client", func(t *testing.T) {
+		g, err := NewGatekeeper(Modes{Client: true}, wfClient, kubeClient, &rest.Config{Username: "my-username"}, nil, clientForAuthorization, "")
+		assert.NoError(t, err)
+		ctx, err := g.Context(x("Bearer "))
+		if assert.NoError(t, err) {
+			assert.NotEqual(t, wfClient, GetWfClient(ctx))
+			assert.NotEqual(t, kubeClient, GetKubeClient(ctx))
+			assert.Nil(t, GetClaims(ctx))
+		}
+	})
 	t.Run("Server", func(t *testing.T) {
-		g, err := NewGatekeeper(Modes{Server: true}, wfClient, kubeClient, &rest.Config{Username: "my-username"}, nil)
+		g, err := NewGatekeeper(Modes{Server: true}, wfClient, kubeClient, &rest.Config{Username: "my-username"}, nil, clientForAuthorization, "")
 		assert.NoError(t, err)
 		ctx, err := g.Context(x(""))
 		if assert.NoError(t, err) {
@@ -49,18 +99,63 @@ func TestServer_GetWFClient(t *testing.T) {
 		}
 	})
 	t.Run("SSO", func(t *testing.T) {
-		ssoIf := &mocks.Interface{}
-		ssoIf.On("Authorize", mock.Anything, mock.Anything).Return(&jwt.Claims{}, nil)
-		g, err := NewGatekeeper(Modes{SSO: true}, wfClient, kubeClient, nil, ssoIf)
+		ssoIf := &ssomocks.Interface{}
+		ssoIf.On("Authorize", mock.Anything, mock.Anything).Return(&types.Claims{Claims: jwt.Claims{Subject: "my-sub"}}, nil)
+		ssoIf.On("IsRBACEnabled").Return(false)
+		g, err := NewGatekeeper(Modes{SSO: true}, wfClient, kubeClient, nil, ssoIf, clientForAuthorization, "my-ns")
 		if assert.NoError(t, err) {
 			ctx, err := g.Context(x("Bearer v2:whatever"))
 			if assert.NoError(t, err) {
 				assert.Equal(t, wfClient, GetWfClient(ctx))
 				assert.Equal(t, kubeClient, GetKubeClient(ctx))
-				assert.NotNil(t, GetClaims(ctx))
+				if assert.NotNil(t, GetClaims(ctx)) {
+					assert.Equal(t, "my-sub", GetClaims(ctx).Subject)
+				}
 			}
 		}
 	})
+	hook := &test.Hook{}
+	log.AddHook(hook)
+	defer log.StandardLogger().ReplaceHooks(nil)
+	t.Run("SSO+RBAC,precedence=1", func(t *testing.T) {
+		ssoIf := &ssomocks.Interface{}
+		ssoIf.On("Authorize", mock.Anything, mock.Anything).Return(&types.Claims{Groups: []string{"my-group", "other-group"}}, nil)
+		ssoIf.On("IsRBACEnabled").Return(true)
+		g, err := NewGatekeeper(Modes{SSO: true}, wfClient, kubeClient, nil, ssoIf, clientForAuthorization, "my-ns")
+		if assert.NoError(t, err) {
+			ctx, err := g.Context(x("Bearer v2:whatever"))
+			if assert.NoError(t, err) {
+				assert.NotEqual(t, wfClient, GetWfClient(ctx))
+				assert.NotEqual(t, kubeClient, GetKubeClient(ctx))
+				if assert.NotNil(t, GetClaims(ctx)) {
+					assert.Equal(t, []string{"my-group", "other-group"}, GetClaims(ctx).Groups)
+				}
+				assert.Equal(t, "my-sa", hook.LastEntry().Data["serviceAccount"])
+			}
+		}
+	})
+	t.Run("SSO+RBAC,precedence=0", func(t *testing.T) {
+		ssoIf := &ssomocks.Interface{}
+		ssoIf.On("Authorize", mock.Anything, mock.Anything).Return(&types.Claims{Groups: []string{"other-group"}}, nil)
+		ssoIf.On("IsRBACEnabled").Return(true)
+		g, err := NewGatekeeper(Modes{SSO: true}, wfClient, kubeClient, nil, ssoIf, clientForAuthorization, "my-ns")
+		if assert.NoError(t, err) {
+			_, err := g.Context(x("Bearer v2:whatever"))
+			if assert.NoError(t, err) {
+				assert.Equal(t, "my-other-sa", hook.LastEntry().Data["serviceAccount"])
+			}
+		}
+	})
+	t.Run("SSO+RBAC,denied", func(t *testing.T) {
+		ssoIf := &ssomocks.Interface{}
+		ssoIf.On("Authorize", mock.Anything, mock.Anything).Return(&types.Claims{}, nil)
+		ssoIf.On("IsRBACEnabled").Return(true)
+		g, err := NewGatekeeper(Modes{SSO: true}, wfClient, kubeClient, nil, ssoIf, clientForAuthorization, "my-ns")
+		if assert.NoError(t, err) {
+			_, err := g.Context(x("Bearer v2:whatever"))
+			assert.EqualError(t, err, "rpc error: code = PermissionDenied desc = not allowed")
+		}
+	})
 }
 
 func x(authorization string) context.Context {
diff --git a/server/auth/sso/jwt_test.go b/server/auth/serviceaccount/claims_test.go
similarity index 98%
rename from server/auth/sso/jwt_test.go
rename to server/auth/serviceaccount/claims_test.go
index 8eedad9dd..9cefe07ee 100644
--- a/server/auth/sso/jwt_test.go
+++ b/server/auth/serviceaccount/claims_test.go
@@ -1,4 +1,4 @@
-package sso
+package serviceaccount
 
 import (
 	"io/ioutil"
diff --git a/server/clusterworkflowtemplate/cluster_workflow_template_server_test.go b/server/clusterworkflowtemplate/cluster_workflow_template_server_test.go
index 4c7e82690..8f34af4a0 100644
--- a/server/clusterworkflowtemplate/cluster_workflow_template_server_test.go
+++ b/server/clusterworkflowtemplate/cluster_workflow_template_server_test.go
@@ -12,6 +12,7 @@ import (
 	"github.com/argoproj/argo/pkg/apis/workflow/v1alpha1"
 	wftFake "github.com/argoproj/argo/pkg/client/clientset/versioned/fake"
 	"github.com/argoproj/argo/server/auth"
+	"github.com/argoproj/argo/server/auth/types"
 	testutil "github.com/argoproj/argo/test/util"
 	"github.com/argoproj/argo/util/instanceid"
 	"github.com/argoproj/argo/workflow/common"
@@ -148,7 +149,7 @@ func init() {
 func getClusterWorkflowTemplateServer() (clusterwftmplpkg.ClusterWorkflowTemplateServiceServer, context.Context) {
 	kubeClientSet := fake.NewSimpleClientset()
 	wfClientset := wftFake.NewSimpleClientset(&unlabelled, &cwftObj2, &cwftObj3)
-	ctx := context.WithValue(context.WithValue(context.WithValue(context.TODO(), auth.WfKey, wfClientset), auth.KubeKey, kubeClientSet), auth.ClaimsKey, &jwt.Claims{Subject: "my-sub"})
+	ctx := context.WithValue(context.WithValue(context.WithValue(context.TODO(), auth.WfKey, wfClientset), auth.KubeKey, kubeClientSet), auth.ClaimsKey, &types.Claims{Claims: jwt.Claims{Subject: "my-sub"}})
 	return NewClusterWorkflowTemplateServer(instanceid.NewService("my-instanceid")), ctx
 }
 
diff --git a/server/cronworkflow/cron_workflow_server_test.go b/server/cronworkflow/cron_workflow_server_test.go
index e782f89e7..0bf28b799 100644
--- a/server/cronworkflow/cron_workflow_server_test.go
+++ b/server/cronworkflow/cron_workflow_server_test.go
@@ -5,13 +5,13 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
-
 	"gopkg.in/square/go-jose.v2/jwt"
 
 	cronworkflowpkg "github.com/argoproj/argo/pkg/apiclient/cronworkflow"
 	wfv1 "github.com/argoproj/argo/pkg/apis/workflow/v1alpha1"
 	wftFake "github.com/argoproj/argo/pkg/client/clientset/versioned/fake"
 	"github.com/argoproj/argo/server/auth"
+	"github.com/argoproj/argo/server/auth/types"
 	testutil "github.com/argoproj/argo/test/util"
 	"github.com/argoproj/argo/util/instanceid"
 	"github.com/argoproj/argo/workflow/common"
@@ -53,7 +53,7 @@ metadata:
 
 	wfClientset := wftFake.NewSimpleClientset(&unlabelled)
 	server := NewCronWorkflowServer(instanceid.NewService("my-instanceid"))
-	ctx := context.WithValue(context.WithValue(context.TODO(), auth.WfKey, wfClientset), auth.ClaimsKey, &jwt.Claims{Subject: "my-sub"})
+	ctx := context.WithValue(context.WithValue(context.TODO(), auth.WfKey, wfClientset), auth.ClaimsKey, &types.Claims{Claims: jwt.Claims{Subject: "my-sub"}})
 
 	t.Run("CreateCronWorkflow", func(t *testing.T) {
 		created, err := server.CreateCronWorkflow(ctx, &cronworkflowpkg.CreateCronWorkflowRequest{
diff --git a/server/event/dispatch/operation_test.go b/server/event/dispatch/operation_test.go
index 78de00394..df113c9dd 100644
--- a/server/event/dispatch/operation_test.go
+++ b/server/event/dispatch/operation_test.go
@@ -6,14 +6,14 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"google.golang.org/grpc/metadata"
+	"gopkg.in/square/go-jose.v2/jwt"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/tools/record"
 
-	"gopkg.in/square/go-jose.v2/jwt"
-
 	wfv1 "github.com/argoproj/argo/pkg/apis/workflow/v1alpha1"
 	"github.com/argoproj/argo/pkg/client/clientset/versioned/fake"
 	"github.com/argoproj/argo/server/auth"
+	"github.com/argoproj/argo/server/auth/types"
 	"github.com/argoproj/argo/util/instanceid"
 	"github.com/argoproj/argo/workflow/common"
 )
@@ -45,7 +45,7 @@ func TestNewOperation(t *testing.T) {
 			ObjectMeta: metav1.ObjectMeta{Name: "my-wft", Namespace: "my-ns", Labels: map[string]string{common.LabelKeyControllerInstanceID: "my-instanceid"}},
 		},
 	)
-	ctx := context.WithValue(context.WithValue(context.Background(), auth.WfKey, client), auth.ClaimsKey, &jwt.Claims{Subject: "my-sub"})
+	ctx := context.WithValue(context.WithValue(context.Background(), auth.WfKey, client), auth.ClaimsKey, &types.Claims{Claims: jwt.Claims{Subject: "my-sub"}})
 	recorder := record.NewFakeRecorder(1)
 
 	// act
diff --git a/server/info/info_server_test.go b/server/info/info_server_test.go
index df4ece042..8e0d9cf04 100644
--- a/server/info/info_server_test.go
+++ b/server/info/info_server_test.go
@@ -5,18 +5,19 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
-
 	"gopkg.in/square/go-jose.v2/jwt"
 
 	"github.com/argoproj/argo/server/auth"
+	"github.com/argoproj/argo/server/auth/types"
 )
 
 func Test_infoServer_GetUserInfo(t *testing.T) {
 	i := &infoServer{}
-	ctx := context.WithValue(context.TODO(), auth.ClaimsKey, &jwt.Claims{Issuer: "my-iss", Subject: "my-sub"})
+	ctx := context.WithValue(context.TODO(), auth.ClaimsKey, &types.Claims{Claims: jwt.Claims{Issuer: "my-iss", Subject: "my-sub"}, Groups: []string{"my-group"}})
 	info, err := i.GetUserInfo(ctx, nil)
 	if assert.NoError(t, err) {
 		assert.Equal(t, "my-iss", info.Issuer)
 		assert.Equal(t, "my-sub", info.Subject)
+		assert.Equal(t, []string{"my-group"}, info.Groups)
 	}
 }
diff --git a/server/workflow/workflow_server_test.go b/server/workflow/workflow_server_test.go
index d16b05cc2..0e0824bdb 100644
--- a/server/workflow/workflow_server_test.go
+++ b/server/workflow/workflow_server_test.go
@@ -8,6 +8,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
+	"gopkg.in/square/go-jose.v2/jwt"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
@@ -15,8 +16,6 @@ import (
 	"k8s.io/client-go/kubernetes/fake"
 	ktesting "k8s.io/client-go/testing"
 
-	"gopkg.in/square/go-jose.v2/jwt"
-
 	"github.com/argoproj/argo/persist/sqldb"
 	"github.com/argoproj/argo/persist/sqldb/mocks"
 	workflowpkg "github.com/argoproj/argo/pkg/apiclient/workflow"
@@ -24,6 +23,7 @@ import (
 	"github.com/argoproj/argo/pkg/client/clientset/versioned"
 	v1alpha "github.com/argoproj/argo/pkg/client/clientset/versioned/fake"
 	"github.com/argoproj/argo/server/auth"
+	"github.com/argoproj/argo/server/auth/types"
 	testutil "github.com/argoproj/argo/test/util"
 	"github.com/argoproj/argo/util"
 	"github.com/argoproj/argo/util/instanceid"
@@ -586,7 +586,7 @@ func getWorkflowServer() (workflowpkg.WorkflowServiceServer, context.Context) {
 	kubeClientSet := fake.NewSimpleClientset()
 	wfClientset := v1alpha.NewSimpleClientset(&unlabelledObj, &wfObj1, &wfObj2, &wfObj3, &wfObj4, &wfObj5, &failedWfObj, &wftmpl, &cronwfObj, &cwfTmpl)
 	wfClientset.PrependReactor("create", "workflows", generateNameReactor)
-	ctx := context.WithValue(context.WithValue(context.WithValue(context.TODO(), auth.WfKey, wfClientset), auth.KubeKey, kubeClientSet), auth.ClaimsKey, &jwt.Claims{Subject: "my-sub"})
+	ctx := context.WithValue(context.WithValue(context.WithValue(context.TODO(), auth.WfKey, wfClientset), auth.KubeKey, kubeClientSet), auth.ClaimsKey, &types.Claims{Claims: jwt.Claims{Subject: "my-sub"}})
 	return server, ctx
 }
 
diff --git a/server/workflowtemplate/workflow_template_server_test.go b/server/workflowtemplate/workflow_template_server_test.go
index acadb614c..734acac0c 100644
--- a/server/workflowtemplate/workflow_template_server_test.go
+++ b/server/workflowtemplate/workflow_template_server_test.go
@@ -5,15 +5,15 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"gopkg.in/square/go-jose.v2/jwt"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/kubernetes/fake"
 
-	"gopkg.in/square/go-jose.v2/jwt"
-
 	workflowtemplatepkg "github.com/argoproj/argo/pkg/apiclient/workflowtemplate"
 	"github.com/argoproj/argo/pkg/apis/workflow/v1alpha1"
 	wftFake "github.com/argoproj/argo/pkg/client/clientset/versioned/fake"
 	"github.com/argoproj/argo/server/auth"
+	"github.com/argoproj/argo/server/auth/types"
 	testutil "github.com/argoproj/argo/test/util"
 	"github.com/argoproj/argo/util/instanceid"
 	"github.com/argoproj/argo/workflow/common"
@@ -167,7 +167,7 @@ func getWorkflowTemplateServer() (workflowtemplatepkg.WorkflowTemplateServiceSer
 	testutil.MustUnmarshallJSON(wftStr3, &wftObj2)
 	kubeClientSet := fake.NewSimpleClientset()
 	wfClientset := wftFake.NewSimpleClientset(&unlabelledObj, &wftObj1, &wftObj2)
-	ctx := context.WithValue(context.WithValue(context.WithValue(context.TODO(), auth.WfKey, wfClientset), auth.KubeKey, kubeClientSet), auth.ClaimsKey, &jwt.Claims{Subject: "my-sub"})
+	ctx := context.WithValue(context.WithValue(context.WithValue(context.TODO(), auth.WfKey, wfClientset), auth.KubeKey, kubeClientSet), auth.ClaimsKey, &types.Claims{Claims: jwt.Claims{Subject: "my-sub"}})
 	return NewWorkflowTemplateServer(instanceid.NewService("my-instanceid")), ctx
 }
 
diff --git a/test/e2e/argo_server_test.go b/test/e2e/argo_server_test.go
index aa9a471f4..ce77858ef 100644
--- a/test/e2e/argo_server_test.go
+++ b/test/e2e/argo_server_test.go
@@ -1,10 +1,9 @@
-// +build e2e
+// +build cli
 
 package e2e
 
 import (
 	"bufio"
-	"crypto/tls"
 	"io/ioutil"
 	"net/http"
 	"strings"
@@ -28,13 +27,6 @@ import (
 
 const baseUrl = "http://localhost:2746"
 
-var httpClient = &http.Client{
-	Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
-	CheckRedirect: func(req *http.Request, via []*http.Request) error {
-		return http.ErrUseLastResponse
-	},
-}
-
 // ensure basic HTTP functionality works,
 // testing behaviour really is a non-goal
 type ArgoServerSuite struct {
@@ -49,13 +41,6 @@ func (s *ArgoServerSuite) BeforeTest(suiteName, testName string) {
 	s.CheckError(err)
 }
 
-type httpLogger struct {
-}
-
-func (d *httpLogger) Logf(fmt string, args ...interface{}) {
-	log.Debugf(fmt, args...)
-}
-
 func (s *ArgoServerSuite) e() *httpexpect.Expect {
 	return httpexpect.
 		WithConfig(httpexpect.Config{
diff --git a/test/e2e/cli_test.go b/test/e2e/cli_test.go
index c0e6bf0ed..9d4f745b0 100644
--- a/test/e2e/cli_test.go
+++ b/test/e2e/cli_test.go
@@ -1,4 +1,4 @@
-// +build e2e
+// +build cli
 
 package e2e
 
diff --git a/test/e2e/cli_with_server_test.go b/test/e2e/cli_with_server_test.go
index 0f4367a55..79ecdd2c6 100644
--- a/test/e2e/cli_with_server_test.go
+++ b/test/e2e/cli_with_server_test.go
@@ -1,4 +1,4 @@
-// +build e2e
+// +build cli
 
 package e2e
 
diff --git a/test/e2e/client.go b/test/e2e/client.go
new file mode 100644
index 000000000..5916c1623
--- /dev/null
+++ b/test/e2e/client.go
@@ -0,0 +1,13 @@
+package e2e
+
+import (
+	"crypto/tls"
+	"net/http"
+)
+
+var httpClient = &http.Client{
+	Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
+	CheckRedirect: func(req *http.Request, via []*http.Request) error {
+		return http.ErrUseLastResponse
+	},
+}
diff --git a/test/e2e/http_logger.go b/test/e2e/http_logger.go
new file mode 100644
index 000000000..2443de626
--- /dev/null
+++ b/test/e2e/http_logger.go
@@ -0,0 +1,10 @@
+package e2e
+
+import log "github.com/sirupsen/logrus"
+
+type httpLogger struct {
+}
+
+func (d *httpLogger) Logf(fmt string, args ...interface{}) {
+	log.Debugf(fmt, args...)
+}
diff --git a/workflow/controller/estimation/estimator_test.go b/workflow/controller/estimation/estimator_test.go
index 7324e4f43..bf16242e2 100644
--- a/workflow/controller/estimation/estimator_test.go
+++ b/workflow/controller/estimation/estimator_test.go
@@ -18,8 +18,9 @@ func Test_estimator(t *testing.T) {
 			ObjectMeta: metav1.ObjectMeta{Name: "my-wf"},
 			Status: wfv1.WorkflowStatus{
 				Nodes: map[string]wfv1.NodeStatus{
-					"my-wf":           {StartedAt: a, FinishedAt: a},
-					"my-wy-873244444": {StartedAt: a, FinishedAt: a},
+					"my-wf":             {StartedAt: a, FinishedAt: a},
+					"my-wy-873244444":   {StartedAt: a, FinishedAt: a},
+					"my-wy-873244444.x": {StartedAt: a, FinishedAt: a},
 				},
 			},
 		},
@@ -29,8 +30,9 @@ func Test_estimator(t *testing.T) {
 				StartedAt:  a,
 				FinishedAt: b,
 				Nodes: map[string]wfv1.NodeStatus{
-					"my-baseline":           {StartedAt: a, FinishedAt: b},
-					"my-baseline-873244444": {StartedAt: a, FinishedAt: b},
+					"my-baseline":             {StartedAt: a, FinishedAt: b},
+					"my-baseline-873244444":   {StartedAt: a, FinishedAt: b},
+					"my-baseline-873244444.x": {StartedAt: a, FinishedAt: b},
 				},
 			},
 		},
diff --git a/workflow/creator/creator_test.go b/workflow/creator/creator_test.go
index 608881783..cdcdfa4d6 100644
--- a/workflow/creator/creator_test.go
+++ b/workflow/creator/creator_test.go
@@ -11,6 +11,7 @@ import (
 
 	wfv1 "github.com/argoproj/argo/pkg/apis/workflow/v1alpha1"
 	"github.com/argoproj/argo/server/auth"
+	"github.com/argoproj/argo/server/auth/types"
 	"github.com/argoproj/argo/workflow/common"
 )
 
@@ -22,14 +23,14 @@ func TestLabel(t *testing.T) {
 	})
 	t.Run("NotEmpty", func(t *testing.T) {
 		wf := &wfv1.Workflow{}
-		Label(context.WithValue(context.TODO(), auth.ClaimsKey, &jwt.Claims{Subject: "my-sub"}), wf)
+		Label(context.WithValue(context.TODO(), auth.ClaimsKey, &types.Claims{Claims: jwt.Claims{Subject: "my-sub"}}), wf)
 		if assert.NotEmpty(t, wf.Labels) {
 			assert.Contains(t, wf.Labels, common.LabelKeyCreator)
 		}
 	})
 	t.Run("TooLong", func(t *testing.T) {
 		wf := &wfv1.Workflow{}
-		Label(context.WithValue(context.TODO(), auth.ClaimsKey, &jwt.Claims{Subject: strings.Repeat("x", 63) + "y"}), wf)
+		Label(context.WithValue(context.TODO(), auth.ClaimsKey, &types.Claims{Claims: jwt.Claims{Subject: strings.Repeat("x", 63) + "y"}}), wf)
 		if assert.NotEmpty(t, wf.Labels) {
 			assert.Equal(t, strings.Repeat("x", 62)+"y", wf.Labels[common.LabelKeyCreator])
 		}
diff --git a/workflow/validate/validate_test.go b/workflow/validate/validate_test.go
index e6708d1df..6d427428c 100644
--- a/workflow/validate/validate_test.go
+++ b/workflow/validate/validate_test.go
@@ -170,6 +170,35 @@ spec:
       image: docker/whalesay:{{inputs.parameters.unresolved}}
 `
 
+var unresolvedStepInput = `
+apiVersion: argoproj.io/v1alpha1
+kind: Workflow
+metadata:
+  generateName: hello-world-
+spec:
+  entrypoint: entry-step
+  arguments:
+    parameters: []
+  templates:
+    - steps:
+        - - name: a
+            arguments:
+              parameters:
+                - name: message
+                  value: "{{inputs.parameters.message}}"
+            template: whalesay
+      name: entry-step
+      inputs:
+        parameters:
+          - name: message
+            value: hello world
+    - name: whalesay
+      container:
+        image: docker/whalesay
+        command: [cowsay]
+        args: ["{{inputs.parameters.message}}"]
+`
+
 var unresolvedOutput = `
 apiVersion: argoproj.io/v1alpha1
 kind: Workflow
@@ -197,6 +226,10 @@ func TestUnresolved(t *testing.T) {
 	if assert.NotNil(t, err) {
 		assert.Contains(t, err.Error(), "failed to resolve")
 	}
+	_, err = validate(unresolvedStepInput)
+	if assert.NotNil(t, err) {
+		assert.Contains(t, err.Error(), "failed to resolve")
+	}
 	_, err = validate(unresolvedOutput)
 	if assert.NotNil(t, err) {
 		assert.Contains(t, err.Error(), "failed to resolve")