|
@@ -0,0 +1,285 @@
|
|
|
+#!/usr/bin/env python
|
|
|
+"""
|
|
|
+Makes the Okta groups and group rules needed to support the Okta + AWS integration.
|
|
|
+A master group has a group rule associated with it. The group rule auto-assigns
|
|
|
+membership in several "subgroups" - each of which maps to a given role in a given
|
|
|
+AWS account. All of this is so that if we assign to you the "foo-role" group in
|
|
|
+Okta, you'll be able to assume "foo-role" in each of the many AWS accounts we have.
|
|
|
+"""
|
|
|
+from __future__ import print_function
|
|
|
+import json
|
|
|
+import logging
|
|
|
+import os
|
|
|
+import sys
|
|
|
+import re
|
|
|
+import requests
|
|
|
+from requests.auth import AuthBase
|
|
|
+
|
|
|
+
|
|
|
+# Configuration:
|
|
|
+# * a list of groups that should exist
|
|
|
+# * a regex for matching up 'child groups'
|
|
|
+# Maybe this should be a configuration file? Perhaps one day
|
|
|
+# but for now I'm happy with it in here.
|
|
|
+LOGLEVEL = logging.DEBUG
|
|
|
+API_URL = 'https://mdr-multipass.okta.com'
|
|
|
+API_KEY = os.environ.get('OKTA_API_TOKEN')
|
|
|
+MASTER_GROUPS = [
|
|
|
+
|
|
|
+ {
|
|
|
+ 'group_name': 'AWS - MDR_Engineer-Readonly Role',
|
|
|
+ 'subgroup_regex': r'^aws(?:-us-gov)?#[^#]+#mdr_engineer_readonly#\d+$'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ 'group_name': 'AWS - Cyber Range / A&I',
|
|
|
+ 'subgroup_regex': r'^aws(?:-us-gov)?#afs-mdr-common-services(?:-gov)?#mdr_developer_readonly#\d+$'
|
|
|
+ }
|
|
|
+
|
|
|
+]
|
|
|
+
|
|
|
+class OktaAuth(AuthBase):
|
|
|
+ """
|
|
|
+ Adds Okta API expected auth header
|
|
|
+ """
|
|
|
+ def __init__(self, api_key):
|
|
|
+ self.api_key = api_key
|
|
|
+
|
|
|
+ def __call__(self, r):
|
|
|
+ r.headers['Authorization'] = 'SSWS {0}'.format(self.api_key)
|
|
|
+ return r
|
|
|
+
|
|
|
+def main(args):
|
|
|
+ """
|
|
|
+ The main
|
|
|
+ """
|
|
|
+ logging.basicConfig(stream=sys.stderr,
|
|
|
+ level=LOGLEVEL,
|
|
|
+ format='%(asctime)s %(levelname)s %(funcName)s %(message)s')
|
|
|
+
|
|
|
+ for group in MASTER_GROUPS:
|
|
|
+ process_group(group)
|
|
|
+
|
|
|
+
|
|
|
+def process_group(group):
|
|
|
+ """
|
|
|
+ Process a group obviously
|
|
|
+ """
|
|
|
+ log = logging.getLogger(__name__)
|
|
|
+
|
|
|
+ payload = {
|
|
|
+ 'q' : group.get('group_name')
|
|
|
+ }
|
|
|
+
|
|
|
+ log.debug("Processing Group %s", group.get('group_name'))
|
|
|
+ r = requests.get('{0}/api/v1/groups'.format(API_URL),
|
|
|
+ auth=OktaAuth(API_KEY),
|
|
|
+ params=payload)
|
|
|
+ log.debug("Response code %d", r.status_code)
|
|
|
+
|
|
|
+ my_group = None
|
|
|
+
|
|
|
+ # we should "always" get a 200 even if we get an empty-ish response
|
|
|
+ # a basic [ ] json doc.
|
|
|
+ if r.status_code == 200:
|
|
|
+ data = r.json()
|
|
|
+ if data:
|
|
|
+ for rec in data:
|
|
|
+ if rec.get('profile').get('name') == group.get('group_name'):
|
|
|
+ my_group = rec
|
|
|
+
|
|
|
+ # Our group does not exist, we need to make it
|
|
|
+
|
|
|
+ payload = {
|
|
|
+ 'profile': {
|
|
|
+ 'name' : group.get('group_name'),
|
|
|
+ 'description': 'AWS SAML Role'
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ headers = {
|
|
|
+ 'Accept': 'application/json',
|
|
|
+ 'Content-Type': 'application/json'
|
|
|
+ }
|
|
|
+
|
|
|
+ if my_group is None:
|
|
|
+ log.info("Creating Group %s", group.get('group_name'))
|
|
|
+ r = requests.post('{0}/api/v1/groups'.format(API_URL),
|
|
|
+ auth=OktaAuth(API_KEY),
|
|
|
+ data=json.dumps(payload),
|
|
|
+ headers=headers)
|
|
|
+ log.debug("Response code: %d", r.status_code)
|
|
|
+ my_group = r.json()
|
|
|
+
|
|
|
+ log.info("Master group id=%s", my_group.get('id'))
|
|
|
+
|
|
|
+ subgroups = find_subgroups(group.get('subgroup_regex'))
|
|
|
+ log.info("Valid Subgroups = %s", repr(subgroups))
|
|
|
+
|
|
|
+ create_subgroup_rule(group.get('group_name'), my_group.get('id'), subgroups)
|
|
|
+
|
|
|
+
|
|
|
+def create_subgroup_rule(rule_name, master_group_id, subgroups):
|
|
|
+ """
|
|
|
+ Create a rule to assign a user to all of the subgroups
|
|
|
+ when they are assigned to the master group. If a rule
|
|
|
+ by the same name exists, it will be destroyed and
|
|
|
+ re-created. (Okta API artifact)
|
|
|
+ """
|
|
|
+
|
|
|
+ log = logging.getLogger(__name__)
|
|
|
+
|
|
|
+ log.debug("Checking for existing rule named %s", rule_name)
|
|
|
+
|
|
|
+ payload = {
|
|
|
+ 'q': rule_name
|
|
|
+ }
|
|
|
+
|
|
|
+ existing_rule_id = None
|
|
|
+
|
|
|
+ r = requests.get('{0}/api/v1/groups/rules'.format(API_URL),
|
|
|
+ auth=OktaAuth(API_KEY),
|
|
|
+ params=payload)
|
|
|
+ log.debug("Response code %d", r.status_code)
|
|
|
+ if r.status_code == 200:
|
|
|
+ rules = r.json()
|
|
|
+ for rule in rules:
|
|
|
+ if rule.get('name') == rule_name:
|
|
|
+ existing_rule_id = rule.get('id')
|
|
|
+ else:
|
|
|
+ raise
|
|
|
+
|
|
|
+ # Need to remove the existing rule to add a new one
|
|
|
+ if existing_rule_id is not None:
|
|
|
+ remove_group_rule(existing_rule_id)
|
|
|
+
|
|
|
+ # Now let's make a new rule whee
|
|
|
+ new_rule = {
|
|
|
+ 'type' : 'group_rule',
|
|
|
+ 'name' : rule_name,
|
|
|
+ 'conditions': {
|
|
|
+ 'expression': {
|
|
|
+ 'type' : 'urn:okta:expression:1.0',
|
|
|
+ 'value': 'isMemberOfAnyGroup("{}")'.format(master_group_id)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ 'actions' : {
|
|
|
+ 'assignUserToGroups': {
|
|
|
+ 'groupIds': subgroups
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ # First deactivate the rule, per Okta API
|
|
|
+ url = '{0}/api/v1/groups/rules'.format(API_URL)
|
|
|
+
|
|
|
+ headers = {
|
|
|
+ 'Accept': 'application/json',
|
|
|
+ 'Content-Type': 'application/json'
|
|
|
+ }
|
|
|
+
|
|
|
+ r = requests.post(url,
|
|
|
+ auth=OktaAuth(API_KEY),
|
|
|
+ headers=headers,
|
|
|
+ data=json.dumps(new_rule))
|
|
|
+ log.debug("Response code: %d", r.status_code)
|
|
|
+
|
|
|
+ if r.status_code != 200:
|
|
|
+ log.error("Response code %d trying to create group rule id=%s", r.rule_name)
|
|
|
+ raise
|
|
|
+
|
|
|
+ new_rule_response=r.json()
|
|
|
+
|
|
|
+ url = '{0}/api/v1/groups/rules/{1}/lifecycle/activate'.format(
|
|
|
+ API_URL,
|
|
|
+ new_rule_response.get('id'))
|
|
|
+
|
|
|
+ r = requests.post(url,
|
|
|
+ auth=OktaAuth(API_KEY),
|
|
|
+ headers=headers,
|
|
|
+ data=json.dumps(new_rule))
|
|
|
+ log.debug("Response code: %d", r.status_code)
|
|
|
+
|
|
|
+
|
|
|
+def remove_group_rule(rule_id):
|
|
|
+
|
|
|
+ log = logging.getLogger(__name__)
|
|
|
+
|
|
|
+
|
|
|
+ # First deactivate the rule, per Okta API
|
|
|
+ url = '{0}/api/v1/groups/rules/{1}/lifecycle/deactivate'.format(
|
|
|
+ API_URL,
|
|
|
+ rule_id)
|
|
|
+
|
|
|
+ headers = {
|
|
|
+ 'Accept': 'application/json',
|
|
|
+ 'Content-Type': 'application/json'
|
|
|
+ }
|
|
|
+
|
|
|
+ r = requests.post(url,
|
|
|
+ auth=OktaAuth(API_KEY),
|
|
|
+ headers=headers)
|
|
|
+ log.debug("Response code: %d", r.status_code)
|
|
|
+
|
|
|
+ if r.status_code != 204:
|
|
|
+ log.error("Response code %d trying to deactivate group rule id=%s", r.status_code, rule_id)
|
|
|
+ raise
|
|
|
+
|
|
|
+ # Now let's try to delete it
|
|
|
+
|
|
|
+ url = '{0}/api/v1/groups/rules/{1}'.format(
|
|
|
+ API_URL,
|
|
|
+ rule_id)
|
|
|
+
|
|
|
+ headers = {
|
|
|
+ 'Accept': 'application/json',
|
|
|
+ 'Content-Type': 'application/json'
|
|
|
+ }
|
|
|
+
|
|
|
+ r = requests.delete(url,
|
|
|
+ auth=OktaAuth(API_KEY),
|
|
|
+ headers=headers)
|
|
|
+ log.debug("Response code: %d", r.status_code)
|
|
|
+
|
|
|
+ if r.status_code != 202:
|
|
|
+ log.error("Response code %d trying to delete group rule id=%s", r.status_code, rule_id)
|
|
|
+ raise
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+def find_subgroups(regex):
|
|
|
+ """
|
|
|
+ Finds all the groups matching a given regex, so that
|
|
|
+ we can attach them to a rule.
|
|
|
+ """
|
|
|
+
|
|
|
+ log = logging.getLogger(__name__)
|
|
|
+ log.debug("Looking for groups matching regex '%s'", regex)
|
|
|
+
|
|
|
+ payload = {
|
|
|
+ 'q' : 'aws'
|
|
|
+ }
|
|
|
+
|
|
|
+ r = requests.get('{0}/api/v1/groups'.format(API_URL),
|
|
|
+ auth=OktaAuth(API_KEY),
|
|
|
+ params=payload)
|
|
|
+ log.debug("Response code %d", r.status_code)
|
|
|
+
|
|
|
+ groups = []
|
|
|
+
|
|
|
+ # we should "always" get a 200 even if we get an empty-ish response
|
|
|
+ # a basic [ ] json doc.
|
|
|
+ if r.status_code == 200:
|
|
|
+ data = r.json()
|
|
|
+ if data:
|
|
|
+ for rec in data:
|
|
|
+ name = rec.get('profile').get('name')
|
|
|
+ groupid = rec.get('id')
|
|
|
+ if re.match(regex, name) is not None:
|
|
|
+ groups.append(groupid)
|
|
|
+ return groups
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ sys.exit(main(sys.argv))
|