Cloud Native

Argo Workflows SSO

January 14, 2025

by

Marc Boorshtein

OpenUnison makes it much easier for users to access Argo Workflows securely. As an application, Argo Workflows has a really interesting way it interacts with the Kubernetes API server, it maps your identity to a ServiceAccount that in turn needs its own permissions. We'll walk through how Argo Workflows interacts with identity and how to use OpenUnison to automate that mapping.

How'd we start working with Argo Workflows? Prior to the holidays, we posted several blogs about creating security token services to access your Kubernetes clusters from GitLab and GitHub. Next, we wanted to tackle securely accessing Kubernetes from a CI/CD system running on a remote Kubernetes cluster. Since Argo Workflows has become popular, we'd thought we'd create a CI/CD workflow in Argo that calls a remote cluster. We started diving into how Argo does SSO and realized that there's more to SSO here then just OIDC. Before we get into the particulars of how to configure Argo Workflows with OpenUnison, let's explore how Argo Workflow works with and utilizes identity.

Argo Workflows and Identity

Argo Workflows does not have its own policy engine or authorization language, it relies on the Kubernetes API server's RBAC implementation to manage permissions. This is different from Argo CD, which interacts with Kubernetes using its own ServiceAccount and relies on its own internal policy engine. If you've worked with OpenUnison, you may assume that Argo Workflows will work with a header from a proxy similar to the Kubernetes Dashboard or Kiali. While this approach creates a strong security bond, it doesn't work will with asynchronous operations for what Argo Workflows needs. Instead of using a token from your identity provider, Argo Workflows maps your user's account to a ServiceAccount and then interacts with the API server using the ServiceAccount's token.

While this approach provides considerable flexibility for interacting with the API server, it also creates some problems. The first is, how do you create ServiceAccount objects for all of your users? Since you can't create arbitrary groups for ServiceAccounts, how will you map them into RBAC bindings that line up with your users? If you're running a single tenant system, you could use some automation to do this in your favorite infrastructure as code (IaC) tool, but once you get into multitenancy this issue can really become difficult to manage. The issue of traceability becomes really important if you have any compliance rules that you want to manage by centralizing authorizations based on your identity provider's groups.

To make this easier to manage, Argo provides a mapping mechanism that allows you to map several user identities to a single ServiceAccount, which then can be assigned to whatever bindings you want to provide access. To approximate group based access, you could include the group in the id_token sent to Argo and then map all members of that group to a specific ServiceAccount. This would allow you to dynamically control who can leverage that ServiceAccount via your identity provider's groups.

While the above approach provides simplicity, it means you lose visibility. The audit log from Kubernetes will show every action performed by this ServiceAccount, not by the original user. Additionally, there's no way to tie an action to a specific user in the Argo logs. There's an action for the mapping, and for the action, but they're not tied together. If a nefarious actor were to gain control of an account, you can't draw a line from that account to an action in the API server.

Thankfully, OpenUnison's combination of SSO and Just-In-Time provisioning makes for a great solution! Let's walk through how to make this work.

Creating Accounts With Just-In-Time Provisioning

One of the things that makes OpenUnison different from other identity providers is our built in Just-In-Time provisioning capabilities. This allows us to run a workflow on authentication, or during other events, that can take your user's context and propagate it down to the applications that rely on that context. This is very important for situations like this where the application isn't pulling data from a central directory. We'll create a workflow in OpenUnison that will allow Argo to work with the same user context as our kubectl access.

In our case, we're going to need our workflow to do two things:

  • Create a ServiceAccount - When the user logs in to OpenUnison, we'll create a ServiceAccount object with the appropriate annotations to map the user
  • Add the ServiceAccount to the appropriate RBAC bindings - Based on the groups the user is a member of, we'll want to add (or remove)

The first step is pretty easy in OpenUnison. First we have to map the user's sub to something that can be used as a Kubernetes name or label value, then we create the ServiceAccount and its Secret:

- taskType: customTask
  className: com.tremolosecurity.provisioning.customTasks.JavaScriptTask
  params:
    javaScript: |-
      HashMap = Java.type("java.util.HashMap");
      OpenShiftTarget = Java.type("com.tremolosecurity.unison.openshiftv3.OpenShiftTarget");
      Attribute = Java.type("com.tremolosecurity.saml.Attribute");
      K8sUtils = Java.type("com.tremolosecurity.k8s.util.K8sUtils");
      System = Java.type("java.lang.System");

      function init(task,params) {
      // nothing to do
      }

      function reInit(task) {
      // do nothing
      }

      function doTask(user,request) {
          // map the user to a dns compliant name
          var saname = OpenShiftTarget.sub2uid(user.getAttribs().get("uid").getValues().get(0));
          request.put("saname", saname);
          user.getAttribs().put("saname",new Attribute("saname",saname));
          request.put("sub",user.getAttribs().get("uid").getValues().get(0));

          return true;
      }

# create a ServiceAccount that maps to the the user
- taskType: customTask
  className: com.tremolosecurity.provisioning.tasks.CreateK8sObject
  params:
      targetName: k8s
      template: |-
          kind: ServiceAccount
          apiVersion: v1
          metadata:
            name: $saname$
            namespace: argowf
            annotations:
              workflows.argoproj.io/rbac-rule: "sub == \"$sub$\""
          spec: {}
      srcType: yaml

# create a Secret for our ServiceAccount
- taskType: customTask
  className: com.tremolosecurity.provisioning.tasks.CreateK8sObject
  params:
      targetName: k8s
      template: |-
          kind: Secret
          apiVersion: v1
          metadata:
            name: $saname$.service-account-token
            namespace: argowf
            annotations:
              kubernetes.io/service-account.name: $saname$
          type: kubernetes.io/service-account-token
          spec: {}
      srcType: yaml

When our user, mmosley, logs in a ServiceAccount and Secret are generated that map the account to the user's token via the sub claim. Here's the ServiceAccount object we generated:

apiVersion: v1
kind: ServiceAccount
metadata:
  annotations:
    workflows.argoproj.io/rbac-rule: sub == "mmosley"
  name: mmosley
  namespace: argowf

When creating the ServiceAccount object, OpenUnison added the annotation workflows.argoproj.io/rbac-rule: sub == "mmosley", which tells Argo that this account maps to any JWT with the sub of mmosley. In addition to the ServiceAccount, we created a bound Secret with the name of our account plus ".service-account-token" (there is a an open issue to remove the need for a static token, please boost it!).

When we login, we'll find that Argo won't let us see anything. That's because while we have our account, that account doesn't have any permissions. The next step is to map our ServiceAccount's permissions map to our user's account's permissions.

Mapping Groups to Bindings

So far we can create a ServiceAccount that is mapped to our user. We now need to give our ServiceAccount permissions. The challenge here is that ServiceAccounts can't be members of generic groups the way user accounts can. There are some tricks I've seen of setting up namespaces to store ServiceAccounts, then using the internal groups that get generated to mimic groups specified by a JWT, but that's very rigid and falls apart if you need to be a member of multiple groups. Thankfully, OpenUnison gives us the capabilities to solve this problem!

In addition to the standard Kubernetes provisioning target, OpenUnison also has a target that will provision directly to RoleBindings and ClusterRoleBindings. Starting in 1.0.42, this target supports both user accounts and ServiceAccounts. I generally preach that adding users directly to bindings is an anti-pattern, but in this case it's needed because you can't create generic groups for ServiceAccounts (the original use case for this target was for brown-field implementations where there were already bindings that had to be managed).

Now that we have a mechanism to provision bindings for our ServiceAccount, we need to map groups from our identity provider to the correct binding. The easiest way to do this is with a ConfigMap that stores our mappings. As an example:

apiVersion: v1
kind: ConfigMap
metadata:
  name: argowf-groups2bindings
  namespace: openunison
data:
  mappings: |-
    {
      "CN=k8s-admins,CN=Users,DC=ent2k22,DC=tremolo,DC=dev": {
        "kind":"crb",
        "name": "argowf-clusteradmins",
        "namespace": ""
      }
    }

In our ConfigMap, the mappings key has some json where each group from the JWT is a key, with the value being an object that maps the group to a RoleBinding in a specific namespace or a ClusterRoleBinding. This mapping can be created from a helm template or updated via a workflow. This gives us a way to map our groups to bindings. Now we need our Workflow to do something with that mapping. We'll expand on the JavaScript we used earlier in the Workflow to generate the name of our ServiceAccount to also map our groups to bindings:

- taskType: customTask
  className: com.tremolosecurity.provisioning.customTasks.JavaScriptTask
  params:
    javaScript: |-
      HashMap = Java.type("java.util.HashMap");
      OpenShiftTarget = Java.type("com.tremolosecurity.unison.openshiftv3.OpenShiftTarget");
      Attribute = Java.type("com.tremolosecurity.saml.Attribute");
      K8sUtils = Java.type("com.tremolosecurity.k8s.util.K8sUtils");
      System = Java.type("java.lang.System");

      function init(task,params) {
      // nothing to do
      }

      function reInit(task) {
      // do nothing
      }

      function doTask(user,request) {
          // map the user to a dns compliant name
          var saname = OpenShiftTarget.sub2uid(user.getAttribs().get("uid").getValues().get(0));
          request.put("saname", saname);
          user.getAttribs().put("saname",new Attribute("saname",saname));
          request.put("sub",user.getAttribs().get("uid").getValues().get(0));

          // load the ConfigMap that stores mappings from groups to (Cluster)RoleBindings
          var group2bindings = JSON.parse(K8sUtils.loadConfigMap("k8s","openunison","argowf-groups2bindings").get("mappings"));
          var bindings = new Attribute("bindings");
          var memberOf = user.getAttribs().get("groups");
          for (var i = 0;i < memberOf.getValues().size();i++) {
            var group = memberOf.getValues().get(i);
            System.out.println("group:" + group);
            var binding = group2bindings[group];
            
            if (binding != null && binding != "") {
              // there's a binding, map to json
              
              
              if (binding["kind"] == "crb") {
                // a ClusterRoleBinding doesn't have a namespace
                bindings.getValues().add("crb:" +  binding["name"]);
              } else if (binding["kind"] == "rb") {
                // RoleBindings require a namespace
                bindings.getValues().add("rb:" +  binding["namespace"] + ":" + binding["name"]);
              } // else, ignore
            }
          }

          // add the attribute, we'll map into groups later
          user.getAttribs().put("bindings",bindings);

          return true;
      }

Our Workflow has mapped our groups to bindings, the last step is to provision everything. In an OpenUnison Workflow, we need to create a context that's unique for our RBAC target that represents our ServiceAccount, not our logged in user. To do this we add a mapping so create the context, and any tasks run within the mapping are run against the context we create, not our logged in user:

# map our user to our created ServiceAccount so we can provision the user's (Cluster)RoleBindings
# the mapping only applies to tasks in onSuccess
- taskType: mapping
  strict: true
  map:
    - targetAttributeName: TREMOLO_USER_ID
      targetAttributeSource: ${saname}:argowf
      sourceType: composite
    - targetAttributeName: bindings
      targetAttributeSource: bindings
      sourceType: user
  onSuccess:
  # clear the existing groups for the mapped user
  - taskType: customTask
    className: com.tremolosecurity.provisioning.customTasks.ClearGroups
    params: {}
  # make our mapped user's groups our bindings
  - taskType: customTask
    className: com.tremolosecurity.provisioning.customTasks.Attribute2Groups
    params:
      attributeName: bindings
  
  - taskType: customTask
    className: com.tremolosecurity.provisioning.customTasks.PrintUserInfo
    params:
      message: inside workflow

  # synchronize the ServiceAccount's (Cluster)RoleBindings
  - taskType: provision
    sync: true
    target: k8s-rbac
    setPassword: false
    onlyPassedInAttributes: false
    attributes: []

The first thing we do is set our context's unique id to our ServiceAccount name and namespace, per the target's docs. Next, we'll take the groups we created from our mapping and add them to our new user context too. We set the mapping's strict to true, so no other attributes will come into this context. Next, we'll clear the current groups and then add all the values of the bindings attribute as groups. Finally, we provision our ServiceAccount to the bindings.

Something that's important to note is that the Just-In-Time provisioning process will not just add bindings, but remove them too! This is really important from a security standpoint, If you're ServiceAccount user gets added to a binding that's not mapped to a group in your JWT, it will get removed. This means you can't just add the ServiceAccount to a binding, it needs to be part of a mapping. If you did, then when the user logs in next, they'll lose that access.

Deploying OpenUnison and Argo Workflows

Don't worry about having to build out these objects yourself. We've built a helm chart and step-by-step instructions! Once you have Argo Workflows enabled for SSO, I'm sure you'll want to checkout how to enable SSO with Argo CD too!

Getting Help and Commercial Support

If you run into issues, we're here to help! An of course, you wouldn't want to go into production without a commercial support contract, right? We'd love to hear from you and how we can help.

Related Posts