UpGuard Core provides a multi-tiered access control model where users can be assigned roles based around complete isolation via segregated accounts, or allowed to view a subset of nodes via Node Group Membership. This article outlines how an Engineer might set up an automated process to synchronize an external system that manages users and roles, into an UpGuard Core instance using the API.

Overview

User access to nodes and actions on those nodes can be controlled at an account level and a node group level. For more information on user roles, please visit our introduction to user roles at User Access. While user access can be managed via the UI, this guide focuses on how to manage users and roles via the API, so that access can be managed from an external source.

Account Level or Node Group Level Access

Before starting with an integration, you should consider if Account level or Node Group level access (or both) fits best with the access model you are hoping to replicate. Again, detailed information around the differences can be found on our User Access guide.

One major difference between the two is that an Account level grouping allows a user full functional access to a subset of nodes, whereas a Node Group level grouping allows a user limited functionality to a subset of nodes.

Another major difference to consider is that Account level access is completely segregated. That is, no information around nodes, scans, integrations or events can be seen from one organizational account to another. However, with Node Group level access, a user’s view of nodes can be controlled simply by adding or removing their membership to a node group.

One example might be to use Account level access to segregate nodes managed by different teams and then use Node Group membership within those teams to control which functionality is available to roles within each of those teams.

Account Level Synchronization

We will begin by giving a worked example of how to synchronize a list of users into an account. We will be using a CSV file as the source of truth as it is the most generic source we can define. Please substitute the CSV file for any other integration into a system that manages your users and roles.

Here we will use the following format for our CSV file:

"User Email",        "Role"
"homer@snpp.com",    "member"
"lenny@snpp.com",    "member"
"carl@snpp.com" ,    "member"
"smithers@snpp.com", "analyst"
"burns@snpp.com",    "admin"

We have already mapped the users to the UpGuard Account level roles of admin, analyst and member. To synchronize the user list into our account we could use the following ruby code:

require 'csv'
require 'httparty'
require 'json'

API_KEY="1234"
SEC_KEY="5678"
API_BASE="https://me.upguard.com/api/v2"

# returns a map of [email] => [role]
# includes invited users who have not accepted their invite yet (?invited=true)
def get_existing_users
  res = HTTParty.get("#{API_BASE}/users.json?invited=true",
			:headers => { "Authorization" => "Token token=\"#{API_KEY}#{SEC_KEY}\"" }
	            )
  raise res.body if res.code != 200
  
  obj = JSON.parse(res.body)
  users = {}
  obj.each do |user|
    e = user["email"]
    r = user["role"]
    users[e] = r
  end
  return users
end

def update_user_to_role(email, role)
  res = HTTParty.put("#{API_BASE}/users/update_role.json?email=#{email}&role=#{role.downcase}",
                       :headers => { "Authorization" => "Token token=\"#{API_KEY}#{SEC_KEY}\"" }
                    )
  if res.code != 204
    raise res.body
  end
end

def invite_user(email, role)
  res = HTTParty.post("#{API_BASE}/users/invite.json?email=#{email}&role=#{role.downcase}",
                        :headers => { "Authorization" => "Token token=\"#{API_KEY}#{SEC_KEY}\"" }
                     )
  if res.code != 201
    raise res.body
  end
end

existing = get_existing_users

rows = CSV.parse(File.read("input.csv"), headers: true)

rows.each do |row|
  email = row[0]
  role  = row[1]

  if existing.include?(email)
    if existing[email].downcase == role.downcase
      # all good - user already exists and with the correct role
    else
      # user exists, but wrong role ... update
      update_user_to_role(email, role)
    end
  else
    # user does not exist in account ... invite
    invite_user(email, role)
  end

  # remove the user from 'existing' so we know we've catered for them
  existing.delete(email)
end

# by this point, any user left in 'existing' wasn't found in the 'input.csv' file
# ... so remove from the account
existing.each do |email, role|
  res = HTTParty.put("#{API_BASE}/usrs/remove.json?email=#{email}",
                       :headers => { "Authorization" => "Token token=\"#{API_KEY}#{SEC_KEY}\"" }
		    )
  if res.code != 204
    raise res.body
  end
end

The above code should:

  • Add users that appear in the source that were not previously found in the system,
  • Ensure that users already in the system have the correct role, and update if incorrect, and
  • Remove any users that appear in the account, but don't appear in the source of truth.

Node Group Level Synchronization

Member users in an account are restricted to viewing only node groups they have been added as members of. For more information on the concepts behind node group membership, please visit our guide on Member Users and Node Group Membership.

Here we will provide a worked example of how to synchronize user membership against node groups via the API. We will assume we have a number of nodes, which have already been grouped into three node groups:

  • Production - contains all app and database nodes in the production environment,
  • Development - contains all app and database nodes in the dev environment,
  • Databases - contains all prod and dev database nodes.

For example, a production database node will be part of both the Production node group and the Databases node group. We also will assume that our member users can have one or more of these roles, which should grant them access to nodes in a particular node group:

Team Node Group
Production Support Production
Engineering and Development Development
DB Admins Databases

Again, we will use a group of CSV files as the source of truth as it is external platform agnostic. We will use one CSV file per team and list emails of team members, one per line.

# production.csv
lenny@snpp.com
carl@snpp.com

# development.csv
homer@snpp.com

# databases.csv
carl@snpp.com
smithers@snpp.com

Notice here that carl@snpp.com is both in the Production Support team and the DB Admins team.

The following ruby code can be used to synchronize each of these CSV files in turn.

require 'httparty'
require 'json'

API_KEY="1234"
SEC_KEY="5678"
API_BASE="https://me.upguard.com/api/v2"

def get_node_group_id_by_name(name)
  res = HTTParty.get("#{API_BASE}/node_groups/lookup.json?name=#{name}",
                       :headers => { "Authorization" => "Token token=\"#{API_KEY}#{SEC_KEY}\"" }
                    )
  if res.code != 200
    raise res.body
  end
  obj = JSON.parse(res.body)
  return obj["node_group_id"]
end

def get_node_group_users(node_group_id)
  res = HTTParty.get("#{API_BASE}/node_groups/#{node_group_id}/list_users.json",
                       :headers => { "Authorization" => "Token token=\"#{API_KEY}#{SEC_KEY}\"" }
		    )
  if res.code != 200
    raise res.body
  end
  return JSON.parse(res.body)
end

def contains(user_list, email)
  user_list.each do |user|
    return true if user["email"] == email
  end
  return false
end

def find_user_by_email(email)
  res = HTTParty.get("#{API_BASE}/users.json?invited=true",
			:headers => { "Authorization" => "Token token=\"#{API_KEY}#{SEC_KEY}\"" }
	            )
  raise res.body if res.code != 200
  
  obj = JSON.parse(res.body)
  obj.each do |user|
    return user if user['email'] == email    
  end
  return nil
end

def add_user_to_node_group(node_group_id, email)
  user = find_user_by_email(email)
  res = HTTParty.post("#{API_BASE}/node_groups/#{node_group_id}/add_users.json?user_id=#{user['id']}",
                        :headers => { "Authorization" => "Token token=\"#{API_KEY}#{SEC_KEY}\""
                     )
  if res.code != 201
    return res.body
  end
end

def remove_by_email(user_list, email)
  return user_list.delete_if { |x| x['email'] == email }
end

["production", "development", "databases"].each do |source|
  node_group_id = get_node_group_id_by_name(source.downcase)
  existing = get_node_group_users(node_group_id)
  
  content = File.read("#{source}.csv")
  content.split(/\r?\n/).each do |email|
    if contains(existing, email)
      # all good, user should be in node group and is
    else
      # user isn't in node group yet ... so add
      add_user_to_node_group(node_group_id, email)
    end

    # remove 'email' user from 'existing' to check off that we've seen it
    existing = remove_by_email(existing, email)
  end

  # by this point, any user left in 'existing' isn't meant to be in this node group
  # ... so remove
  existing.each do |user|
    res = HTTParty.put("#{API_BASE}/node_groups/#{node_group_id}/remove_user.json?#{user['id']}",
                         :headers => { "Authorization" => "Token token=\"#{API_KEY}#{SEC_KEY}\"" }
                      )
    if res.code != 204
      raise res.body
    end
  end
end

The above code should:

  • Add users into the node group if they are not already present,
  • Confirm any existing users are part of the node group, and
  • Remove any user that no longer appears in the source of truth for each Team/Node Group pairing.

What Next?

The above guide provides a worked example of how to sync users and roles into a single account, or to multiple node groups with an account. If you would like to sync users into multiple accounts then you will need to repeat the Account Level guide for each API key and secret pair attached to the corresponding account.

For more information on using the API, please visit our guide on Using the API.

Tags: node groups