Routing Access Requests

Sym makes it easy to route different requests to different channels.

Overview

When you make a Sym request, four things will happen:

  1. You will see a prompt asking you to select a Flow
  2. Depending on which Flow you choose, you will be prompted for input
  3. After submitting input, the request will show up in Sym's web app
  4. Depending on the Flow's implementation, notifications about the new request will be sent

For the purposes of this guide, all Python implementation files will be referred to as impl.py. You can (of course) name your files anything you'd like, but impl.py is Sym's standard example filename.

Concepts

Every Flow must reference an impl.py

Every sym_flow resource must reference an impl.py file that contains your custom logic for the Flow. The same impl.py may be referenced by multiple Flows.

resource "sym_flow" "this" {
  name  = "approval"
  label = "Approval"

  # This is the contents of this Flow's Python Implementation file
  implementation = file("${path.module}/impl.py")

  ...
}

Workflow Handlers contain your Flow logic

Workflow Handlers are specially-decorated and -named functions that execute at specific times during a Flow's lifecycle. While your impl.py files may contain various helper functions, all logic that directly impacts a Flow will be contained in a Handler.

The only required Handler is a Reducer called get_permissions, which takes an event representing the current request and returns who has permission to do what in regards to that request (e.g. who can approve it).

The simplest implementation of get_permissions is to explicitly declare that only admins can view and act on requests:

from sym.sdk.annotations import reducer
from sym.sdk.request_permission import PermissionLevel, RequestPermission

@reducer
def get_permissions(event):
    return RequestPermission(
        # Only admins may view this request in Sym's web app.
        webapp_view=PermissionLevel.ADMIN, 
      	# Any non-guest user may approve or deny requests.
        approve_deny=PermissionLevel.MEMBER, 
        # allow_self_approval lets users approve their own requests. This is great for testing!
        allow_self_approval=False
    )

Optionally, the get_request_notifications Reducer can be added, which will send notifications about new requests. The most common use of this is to send notifications to Slack channels, where they can be approved or denied directly:

from sym.sdk.annotations import reducer
from sym.sdk.notifications import Notification

@reducer
def get_request_notifications(event):
    return [
        Notification(destinations=[slack.channel("#sym-requests")])
    ]

Implementation

Pass variables from Terraform to your impl.py

In some cases, you might want to extract information from your Flow's event and use it to make variable decisions. For example, if you want to check the urgency of a request, and in cases of "Emergency," route the request to a different channel, you might want to introduce Flow Variables.

For Flow Variables, we recommend the following pattern:

Define your flow_variables in a .tfvars file

flow_variables = {
  request_channel = "#sym-requests"
  emergency_channel = "#sym-emergencies"
}

Declare your flow_variables in a variables.tf file

variable "flow_variables" {
  description = "Configuration values for the Flow, available in its implementation for hooks and reducers."
  type        = map(string)
  default     = {}
}

Reference your variables in your sym_flow

Note that below, we are also introducing a prompt_field called "Urgency" through which a Flow user can declare an "Emergency."

resource "sym_flow" "aws_sso" {
  name  = "approval"
  label = "Approval"

  implementation = file("${path.module}/impl.py")

  # This ensures your vars are available to this Flow's impl.py
  vars = var.flow_variables

  params {
    strategy_id = sym_strategy.this.id

    prompt_field {
      name     = "reason"
      label    = "Why do you need access?"
      type     = "string"
      required = true
    }

    prompt_field {
      name           = "urgency"
      label          = "Urgency"
      type           = "string"
      required       = true
      allowed_values = ["Normal", "Emergency"]
    }
  }
}

Use your Flow Variables to make decisions in your impl.py

Now that we have our "Urgency" prompt and our Flow Variables, we can use both to construct a more nuanced implementation:

from sym.sdk.annotations import reducer
from sym.sdk.request_permission import PermissionLevel, RequestPermission
from sym.sdk.integrations import slack
from sym.sdk.notifications import Notification

@reducer
def get_permissions(event):
    """Normally, only admins can approve or deny requests.
    When a request is urgent, let the requester approve their own request.
    """  
    allow_self = False
    if event.payload.fields.get("urgency") == "Emergency":
        allow_self = True
    
    return RequestPermission(
        webapp_view=PermissionLevel.ADMIN, 
        approve_deny=PermissionLevel.ADMIN, 
        allow_self_approval=allow_self,
    )

@reducer
def get_request_notifications(event):
    """Send notifications to different Slack channels based on urgency."""
    # Make sure our Flow Variables are available
    fvars = event.flow.vars

    if event.payload.fields.get("urgency") == "Emergency":
        slack_channel = slack.channel(fvars["emergency_channel"])
    else:
        slack_channel = slack.channel(fvars["request_channel"])

    return [
        Notification(destinations=[slack_channel])
    ]

Congratulations! Now you know how to manage the Slack destination for requests for your Sym Flows. 🎉

Advanced routing

Send requests to the channel they came from

Some Sym workflows will be best served in their originating context -- that is to say, they should be sent into the same channel from which they were made.

Sym's get_request_notifications Reducer makes this easy by providing access to your initiating channel (event.run.source_channel.identifier) and allowing multiple Notification objects to be returned:

from sym.sdk.annotations import reducer
from sym.sdk.integrations import slack
from sym.sdk.notifications import Notification

@reducer
def get_request_notifications(event):
    notifications = []

    if source_channel_identifier := event.run.source_channel.identifier:
        # If the request came from Slack, try to send it to the channel it came from.
        # (Only requests from Slack have a `source_channel.identifier` set)
        notifications.append(
            Notification(
                destinations=[slack.channel(source_channel_identifier)]
            )
        )

    # If there is no Slack source channel OR if the message cannot be delivered to the source channel,
    # we will send a notification to the #sym-access channel.
    notifications.append(Notification(destinations=[slack.channel("#sym-access")]))

    return notifications

If a Notification cannot be delivered, the next one in the list will be sent. Each Notification will be tried in order until one succeeds or the end of the list is reached.

In addition to channels or hard-coded individuals, Sym also supports typeahead fields for sending requests to individual users or groups. For more information, see Typeahead fields for Slack Users.

How to Notify a user specified in a prompt form

Sometimes you may want to choose the user to notify when making the request. That is why you can enter user information as a prompt field.

from sym.sdk.annotations import reducer
from sym.sdk.notifications import Notification
from sym.sdk.integrations import slack


@reducer
def get_request_notifications(event):
    destinations = []
    if source_channel_identifier := event.run.source_channel.identifier:
        # If the request came from Slack, try to send it to the channel it came from.
        # (Only requests from Slack have a `source_channel.identifier` set)
        destinations.append(slack.channel(source_channel_identifier))

    # Send to the requester based on a field in the prompt form
    if event.payload.fields["notify_requester"]:
        destinations.append(event.user)

    # If first request is not actioned in 15s or all messages fail to send it will forward to a slack channel and then to a list of users to email, and then expire after another 15s.
    return [
        Notification(destinations=destinations, timeout=15),
        Notification(destinations=[slack.channel("#test-paginate-10")], timeout=15),
        Notification(destinations=[user(email='[email protected]'), user(id="test_id"), ], timeout=15)
    ]