The Hidden Complexities of Slack User Fields

By
Andrew Adams
January 25, 2024

A few months ago, we introduced an intuitive feature to Sym: the ability to select a Slack user (or multiple Slack users) in a dropdown as part of your access request form. This makes it unnecessary to rely on clunkier solutions like manually typing in a user’s email address or username, which is naturally error-prone. While the feature itself is easy to describe and understand, the decisions our team made as we created it represent our philosophy on making access management workflows easy to implement while minimizing boilerplate and toil. Also, as with most easy-to-describe features, this effort had some real hidden complexity under the hood. In this post, I’ll walk through the feature itself and some of the decision-making our team made in designing it.

Our design philosophy

Simply put, an implementer of the Sym platform should, to whatever extent possible, be enabled to think about their solution not in terms of the code they’re writing, but of the problem they’re solving. Minutia, boilerplate, and “non-working” code should be minimized; expressions of logic that impact a workflow should be enabled to be clear and legible; the translation from a business process diagram to code should feel obvious and intuitive.

There are ways to situationally express this philosophy in short-hand (a current favorite is “principle of least surprise”), but the goal always boils down to the same thing: the implementer should be thinking about their problems – not ours.

With this in mind, let’s take a look at the journey from a naive solution, to what we believe is a best-case solution.

What solutions did we consider?

Let’s look at a common Sym use case we have observed and, in fact, use ourselves internally: naming a specific person in your organization to approve your request. Before this feature, you would need to do something like have a free-form text box for the user’s email address in the request form, and then use that address input to send a DM from the Sym SDK. But then you run into two problems: You need to make sure the user has entered a valid email address in your organization, and you would need to make sure the user didn’t enter their own email address. The Python code you might have written to make this happen in Sym would look something like this:


@hook
def on_request(event):
  # The email address of the user who needs to approve the request
  approver_email = event.payload.fields["approver"]

  # Ensure they are a valid user in the Slack workspace
  all_emails = {user["profile"]["email"] for user in slack.list_users(active_only=True)}
  if approver_email not in all_emails:
    return ApprovalTemplate.ignore("You specified an invalid email address for 'approver'")

  # We know the email is valid now, just make sure the user didn't select
  # themselves. This one is easier at least
  if approver_email == event.user.email:
    return ApprovalTemplate.ignore("You cannot specify yourself as the approver.")

@hook
def on_approve(event):
  # Because this is the approve event, event.user is now the person who
  # clicked the approve button, not the person who submitted the request.
  approver_email = event.payload.fields["approver"]
  if event.user.email != approver_email:
    return ApprovalTemplate.ignore("Only the selected approver may approve this request.")

It’s not a great experience for the user or the implementer: not only does the requester have to manually type a full email address into their request, but the code itself is complex, hard to parse, and therefore error prone. Granted, this is still simpler than if you weren’t using Sym, since then you would have to define all the helper methods used here, like slack.list_users(), but it’s still far from ideal!

This is where the Slack user selection dropdown comes in. It’s a standard block type supported by Slack where they pre-populate a dropdown with all the users in your workspace. Done and done, right?

Of course not – that’s only half the problem. While Slack guarantees that you have selected a valid user in your Slack workspace, all they give you back is the selected user’s Slack user ID – something like U1234ABC56. This is a good step towards reducing complexity, but still doesn’t create a great experience because it’s, well, just an ID. Let’s update the example from above:


@hook
def on_request(event):
  # The Slack User ID of the user who needs to approve the request
  approver_uid = event.payload.fields["approver"]

  # Check that the user did not select themselves
  # Look up the Slack identity saved in Sym for the requesting user
  if approver_uid == event.user.identity(service_type="slack").user_id:
    return ApprovalTemplate.ignore("You cannot specify yourself as the approver.")

@hook
def on_approve(event):
  allowed_approver_uid = event.payload.fields["approver"]
  actual_approver_uid = event.user.identity(service_type="slack").user_id

  if actual_approver_uid != allowed_approver_uid:
    return ApprovalTemplate.ignore("Only the selected approver may approve this request.")

Now, this is overall quite a bit better and perfectly functional, but it isn’t the delightful experience we want to create. Put simply, IDs are not something we want an implementer to think about, ever – our design philosophy dictates that in this situation, a user is a user, and should be handled as a user. (And one part, the on_approve() hook that determines who is allowed to approve the request, actually got a bit worse, which is another strike against this flavor of solution).

What we really want is this:


@hook
def on_request(event):
  # A user object representing the user who needs to approve the request
  approver_user = event.payload.fields["approver"]

  # Check that the user did not select themselves
  if event.user == approver_user:
    return ApprovalTemplate.ignore("You cannot specify yourself as the approver.")

@hook
def on_approve(event):
  approver_user = event.payload.fields["approver"]
  if event.user != approver_user:
    return ApprovalTemplate.ignore("Only the selected approver may approve this request.")

Straightforward and effective. This is what we ended up creating, and there’s a lot more to this feature than merely supporting Slack’s user selection dropdown blocks.

Thinking holistically about identity

Sym abstracts away much of the complexity around users and identity: here, we’ve shown that there’s no need to make manual calls to the Slack API to get information about the selected user, which is handy. That said, with a shared abstraction for user identity, it’s also easy to tie this into another service, like Okta.

Let’s extend the example to ensure that the selected user is in a specific Okta group:


@hook
def on_request(event):
  approver_user = event.payload.fields["approver"]

  if event.user == approver_user:
    return ApprovalTemplate.ignore("You cannot specify yourself as the approver.")

  # 00g12345abcde is the "Managers" Okta group
  if not okta.is_user_in_group(approver_user, group_id="00g12345abcde"):
    return ApprovalTemplate.ignore("You must specify a Manager as the approver.")

@hook
def on_approve(event):
  approver_user = event.payload.fields["approver"]
  if event.user != approver_user:
    return ApprovalTemplate.ignore("Only the selected approver may approve this request.")

No need to do manual lookups, or to figure out how to turn a Slack user ID into an email, then into an Okta user ID; that all gets handled for you and exemplifies one of the overarching goals of Sym—to provide a tool for engineers to make it easy to express complex just-in-time access rules as simple, succinct code.

The hidden complexity

Of course, we do a lot in the background to make all this work – an ergonomic, “least surprising” SDK is always going to hide some complexity that has to be dealt with somewhere. In this particular case, the major complexity was latency, which led to a service refactor in how we manage Slack identity lookups.

Latency

Previously we’ve been able to make the assumption that an individual access request is essentially “complete” (from a data modeling perspective) once a user submits it; it has all the information necessary for us to figure out what the user needs access to, who needs to review the request, and what information we need to make available in our SDK. Since Slack doesn’t provide the details of selected users though, just their user IDs, this is no longer the case: we need to make additional API calls or lookups in our database to retrieve the necessary information.

A common constraint that we (and other apps in Slack) run into is that Slack requires a response to interactions within ~3 seconds of the user submitting the interaction, so there were some minor architectural changes needed to Sym to support doing the Slack user lookups required to make this feature magical without causing the user to see request timeouts. We also needed to make sure we wouldn’t blow through Slack’s API rate limits.

The refactor

Given that we don’t restrict the number of users that can be selected in a dropdown, this means that we need to do this asynchronously to avoid exceeding the Slack interaction timeout, which in turn meant that we needed to move this out of our API service (which handles Slack event interactions) and into our queueing service, which also handles pre-processing of Sym events.

In our queuing service, we attempt to match the selected Slack user IDs to user information we already have on hand in the Sym user database, or if we can’t find any we call the Slack API to get the user profile (and then create a user in the Sym user database, as part of Sym’s automatic identity management system). This information is then persisted in a per-request store, effectively caching the information for the lifetime of that single request so we don’t have to do these lookups multiple times per request. From there, the request is sent along to our runtime service, which is responsible for actually executing the workflows our customers specify, including the customer-provided Python implementation code. Before executing any such customer-provided code, our runtime service finally translates any Slack user IDs in the event payload to the user objects retrieved from the per-request store, enabling customers to work with those objects instead of IDs in their workflows.

We also had the ever-present consideration of designing our API methods to “rhyme” with each other: getting a user in Slack should feel like getting a user in Okta (or AWS IAM, or OneLogin, or PagerDuty).

We’re happy with where we ended up: an intuitive, performant typeahead field that translates in the Sym SDK into an obvious, useful object. Easy to describe, kind of complex to build, and ultimately, useful for Sym customers.

Want to learn more? Check out our documentation for the feature!

Recommended Posts