diff --git a/integrations/servicenow-iam/manifest.yaml b/integrations/servicenow-iam/manifest.yaml new file mode 100644 index 0000000..18778d3 --- /dev/null +++ b/integrations/servicenow-iam/manifest.yaml @@ -0,0 +1,140 @@ +id: servicenow_iam +name: ServiceNow IAM +version: 1.0.0 +description: "ServiceNow identity lifecycle (sys_user table) — look up, create and update employee accounts, enable/disable accounts (with lock-out clearing), and discover the available user fields for mapping. Basic or OAuth 2.0 (password grant) authentication." +changelog: "1.0.0 — Initial release: get/create/update user, enable/disable user, user field discovery via sys_dictionary." +category: identity + +# Per-instance configuration. The base URL is the instance root, e.g. +# https://company.service-now.com (scripts append /api/now). +config_schema: + properties: + url: + type: string + description: "ServiceNow instance URL, e.g. https://company.service-now.com" + auth_type: + type: string + description: "Authentication method: basic (username + password) or oauth (OAuth 2.0 password grant via /oauth_token.do — also requires client_id/client_secret)" + default: basic + username: + type: string + description: "ServiceNow username (needs read/write ACLs on the sys_user table)" + password: + type: string + description: "ServiceNow password" + x-soar-sensitive: true + client_id: + type: string + description: "OAuth client ID (oauth auth_type only — from an Application Registry entry)" + client_secret: + type: string + description: "OAuth client secret (oauth auth_type only)" + x-soar-sensitive: true + api_version: + type: string + description: "Optional REST API version segment (e.g. v2). Leave empty to use the unversioned endpoints." + required: + - url + - username + - password + +# Documented for reference; the bundled scripts build the headers themselves. +# Basic: Authorization: Basic base64(username:password) +# OAuth: POST /oauth_token.do (grant_type=password) then Authorization: Bearer +auth: + - id: basic + type: basic + username_field: username + password_field: password + +# User resolution: every account command accepts user_id (sys_id), user_name or +# email — the first one provided wins, in that order. +commands: + - id: get_user + name: servicenow-iam-get-user + description: "Look up a user account by sys_id, username or email." + risk: read + inputs_schema: + properties: + user_id: { type: string, description: "User sys_id" } + user_name: { type: string, description: "Username (user_name field)" } + email: { type: string, description: "Email address" } + fields: { type: string, description: "Comma-separated fields to return (sys_id always included)" } + required: [] + outputs_schema: { properties: {} } + - id: create_user + name: servicenow-iam-create-user + description: "Create a user account. Common fields have named inputs; anything else goes through fields/custom_fields." + inputs_schema: + properties: + user_name: { type: string, description: "Username (user_name field)" } + email: { type: string, description: "Email address" } + first_name: { type: string, description: "First name" } + last_name: { type: string, description: "Last name" } + title: { type: string, description: "Job title" } + department: { type: string, description: "Department (sys_id or exact name)" } + manager: { type: string, description: "Manager (sys_id or exact user name)" } + phone: { type: string, description: "Business phone" } + mobile_phone: { type: string, description: "Mobile phone" } + active: { type: boolean, description: "Account active state (default true)" } + fields: { type: string, description: "Additional sys_user fields as name=value;name2=value2" } + custom_fields: { type: string, description: "Custom fields as name=value;name2=value2 (u_ prefix added when missing)" } + required: [user_name] + outputs_schema: { properties: {} } + - id: update_user + name: servicenow-iam-update-user + description: "Update a user account resolved by sys_id, username or email." + inputs_schema: + properties: + user_id: { type: string, description: "User sys_id" } + user_name: { type: string, description: "Username used to resolve the account when user_id is empty" } + email: { type: string, description: "Email used to resolve the account when user_id and user_name are empty" } + first_name: { type: string, description: "First name" } + last_name: { type: string, description: "Last name" } + title: { type: string, description: "Job title" } + department: { type: string, description: "Department (sys_id or exact name)" } + manager: { type: string, description: "Manager (sys_id or exact user name)" } + phone: { type: string, description: "Business phone" } + mobile_phone: { type: string, description: "Mobile phone" } + fields: { type: string, description: "Additional sys_user fields as name=value;name2=value2" } + custom_fields: { type: string, description: "Custom fields as name=value;name2=value2 (u_ prefix added when missing)" } + required: [] + outputs_schema: { properties: {} } + - id: enable_user + name: servicenow-iam-enable-user + description: "Enable a user account (sets active=true and clears locked_out), resolved by sys_id, username or email." + inputs_schema: + properties: + user_id: { type: string, description: "User sys_id" } + user_name: { type: string, description: "Username used to resolve the account when user_id is empty" } + email: { type: string, description: "Email used to resolve the account when user_id and user_name are empty" } + required: [] + outputs_schema: { properties: {} } + - id: disable_user + name: servicenow-iam-disable-user + description: "Disable a user account (sets active=false), resolved by sys_id, username or email." + inputs_schema: + properties: + user_id: { type: string, description: "User sys_id" } + user_name: { type: string, description: "Username used to resolve the account when user_id is empty" } + email: { type: string, description: "Email used to resolve the account when user_id and user_name are empty" } + required: [] + outputs_schema: { properties: {} } + - id: list_user_fields + name: servicenow-iam-list-user-fields + description: "List the sys_user table fields (name and label) from the sys_dictionary — useful to build field mappings." + risk: read + inputs_schema: + properties: + limit: { type: number, description: "Maximum dictionary entries (default 500)" } + required: [] + outputs_schema: { properties: {} } + + - id: test_connection + name: servicenow-iam-test-connection + description: "Verify connectivity and credentials (used by the Test button)." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } diff --git a/integrations/servicenow-iam/scripts/create_user.py b/integrations/servicenow-iam/scripts/create_user.py new file mode 100644 index 0000000..d19cde2 --- /dev/null +++ b/integrations/servicenow-iam/scripts/create_user.py @@ -0,0 +1,88 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _headers(cfg): + h = {"Accept": "application/json", "Content-Type": "application/json"} + if str(cfg.get("auth_type") or "basic").lower() == "oauth": + data = urllib.parse.urlencode({ + "grant_type": "password", + "client_id": cfg.get("client_id", ""), + "client_secret": cfg.get("client_secret", ""), + "username": cfg.get("username", ""), + "password": cfg.get("password", ""), + }).encode("utf-8") + req = urllib.request.Request( + cfg.get("url", "").rstrip("/") + "/oauth_token.do", data=data, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("OAuth token request failed: " + json.dumps(tok)) + h["Authorization"] = "Bearer " + tok["access_token"] + else: + cred = (cfg.get("username", "") + ":" + cfg.get("password", "")).encode("utf-8") + h["Authorization"] = "Basic " + base64.b64encode(cred).decode("ascii") + return h + + +def request(method, path, params=None, body=None, root="/api/now", versioned=True): + cfg = _cfg() + v = str(cfg.get("api_version") or "").strip().strip("/") + url = cfg.get("url", "").rstrip("/") + root + ("/" + v if v and versioned else "") + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += "?" + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, headers=_headers(cfg), method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def split_fields(s, prefix_custom=False): + out = {} + for part in str(s or "").split(";"): + if "=" not in part: + continue + k, v = part.split("=", 1) + k = k.strip() + if prefix_custom and k and not k.startswith("u_"): + k = "u_" + k + if k: + out[k] = v + return out + + +NAMED_FIELDS = [ + "user_name", "email", "first_name", "last_name", "title", + "department", "manager", "phone", "mobile_phone", +] + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + if not str(inputs.get("user_name") or ""): + raise Exception("user_name is required") + body = {} + for k in NAMED_FIELDS: + v = inputs.get(k) + if v not in (None, ""): + body[k] = v + body["active"] = bool(inputs.get("active", True)) + body.update(split_fields(inputs.get("fields"))) + body.update(split_fields(inputs.get("custom_fields"), prefix_custom=True)) + print(json.dumps(request("POST", "/table/sys_user", body=body))) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/servicenow-iam/scripts/disable_user.py b/integrations/servicenow-iam/scripts/disable_user.py new file mode 100644 index 0000000..772d056 --- /dev/null +++ b/integrations/servicenow-iam/scripts/disable_user.py @@ -0,0 +1,79 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _headers(cfg): + h = {"Accept": "application/json", "Content-Type": "application/json"} + if str(cfg.get("auth_type") or "basic").lower() == "oauth": + data = urllib.parse.urlencode({ + "grant_type": "password", + "client_id": cfg.get("client_id", ""), + "client_secret": cfg.get("client_secret", ""), + "username": cfg.get("username", ""), + "password": cfg.get("password", ""), + }).encode("utf-8") + req = urllib.request.Request( + cfg.get("url", "").rstrip("/") + "/oauth_token.do", data=data, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("OAuth token request failed: " + json.dumps(tok)) + h["Authorization"] = "Bearer " + tok["access_token"] + else: + cred = (cfg.get("username", "") + ":" + cfg.get("password", "")).encode("utf-8") + h["Authorization"] = "Basic " + base64.b64encode(cred).decode("ascii") + return h + + +def request(method, path, params=None, body=None, root="/api/now", versioned=True): + cfg = _cfg() + v = str(cfg.get("api_version") or "").strip().strip("/") + url = cfg.get("url", "").rstrip("/") + root + ("/" + v if v and versioned else "") + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += "?" + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, headers=_headers(cfg), method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def find_user_sys_id(inputs): + uid = str(inputs.get("user_id") or "") + if uid: + return uid + for key in ("user_name", "email"): + val = str(inputs.get(key) or "") + if val: + res = request("GET", "/table/sys_user", params={ + "sysparm_query": key + "=" + val, + "sysparm_limit": 1, + "sysparm_fields": "sys_id", + }) + rows = res.get("result") or [] + if not rows: + raise Exception("user not found (" + key + "=" + val + ")") + return rows[0]["sys_id"] + raise Exception("user_id, user_name or email is required") + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + sys_id = find_user_sys_id(inputs) + body = {"active": False} + print(json.dumps(request("PATCH", "/table/sys_user/" + urllib.parse.quote(sys_id), body=body))) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/servicenow-iam/scripts/enable_user.py b/integrations/servicenow-iam/scripts/enable_user.py new file mode 100644 index 0000000..3273c77 --- /dev/null +++ b/integrations/servicenow-iam/scripts/enable_user.py @@ -0,0 +1,79 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _headers(cfg): + h = {"Accept": "application/json", "Content-Type": "application/json"} + if str(cfg.get("auth_type") or "basic").lower() == "oauth": + data = urllib.parse.urlencode({ + "grant_type": "password", + "client_id": cfg.get("client_id", ""), + "client_secret": cfg.get("client_secret", ""), + "username": cfg.get("username", ""), + "password": cfg.get("password", ""), + }).encode("utf-8") + req = urllib.request.Request( + cfg.get("url", "").rstrip("/") + "/oauth_token.do", data=data, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("OAuth token request failed: " + json.dumps(tok)) + h["Authorization"] = "Bearer " + tok["access_token"] + else: + cred = (cfg.get("username", "") + ":" + cfg.get("password", "")).encode("utf-8") + h["Authorization"] = "Basic " + base64.b64encode(cred).decode("ascii") + return h + + +def request(method, path, params=None, body=None, root="/api/now", versioned=True): + cfg = _cfg() + v = str(cfg.get("api_version") or "").strip().strip("/") + url = cfg.get("url", "").rstrip("/") + root + ("/" + v if v and versioned else "") + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += "?" + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, headers=_headers(cfg), method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def find_user_sys_id(inputs): + uid = str(inputs.get("user_id") or "") + if uid: + return uid + for key in ("user_name", "email"): + val = str(inputs.get(key) or "") + if val: + res = request("GET", "/table/sys_user", params={ + "sysparm_query": key + "=" + val, + "sysparm_limit": 1, + "sysparm_fields": "sys_id", + }) + rows = res.get("result") or [] + if not rows: + raise Exception("user not found (" + key + "=" + val + ")") + return rows[0]["sys_id"] + raise Exception("user_id, user_name or email is required") + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + sys_id = find_user_sys_id(inputs) + body = {"active": True, "locked_out": False} + print(json.dumps(request("PATCH", "/table/sys_user/" + urllib.parse.quote(sys_id), body=body))) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/servicenow-iam/scripts/get_user.py b/integrations/servicenow-iam/scripts/get_user.py new file mode 100644 index 0000000..d5e1183 --- /dev/null +++ b/integrations/servicenow-iam/scripts/get_user.py @@ -0,0 +1,74 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _headers(cfg): + h = {"Accept": "application/json", "Content-Type": "application/json"} + if str(cfg.get("auth_type") or "basic").lower() == "oauth": + data = urllib.parse.urlencode({ + "grant_type": "password", + "client_id": cfg.get("client_id", ""), + "client_secret": cfg.get("client_secret", ""), + "username": cfg.get("username", ""), + "password": cfg.get("password", ""), + }).encode("utf-8") + req = urllib.request.Request( + cfg.get("url", "").rstrip("/") + "/oauth_token.do", data=data, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("OAuth token request failed: " + json.dumps(tok)) + h["Authorization"] = "Bearer " + tok["access_token"] + else: + cred = (cfg.get("username", "") + ":" + cfg.get("password", "")).encode("utf-8") + h["Authorization"] = "Basic " + base64.b64encode(cred).decode("ascii") + return h + + +def request(method, path, params=None, body=None, root="/api/now", versioned=True): + cfg = _cfg() + v = str(cfg.get("api_version") or "").strip().strip("/") + url = cfg.get("url", "").rstrip("/") + root + ("/" + v if v and versioned else "") + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += "?" + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, headers=_headers(cfg), method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + fields = str(inputs.get("fields") or "") + if fields and "sys_id" not in [p.strip() for p in fields.split(",")]: + fields += ",sys_id" + params = {"sysparm_fields": fields or None} + + uid = str(inputs.get("user_id") or "") + if uid: + print(json.dumps(request("GET", "/table/sys_user/" + urllib.parse.quote(uid), params=params))) + return + for key in ("user_name", "email"): + val = str(inputs.get(key) or "") + if val: + params["sysparm_query"] = key + "=" + val + params["sysparm_limit"] = 1 + print(json.dumps(request("GET", "/table/sys_user", params=params))) + return + raise Exception("user_id, user_name or email is required") + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/servicenow-iam/scripts/list_user_fields.py b/integrations/servicenow-iam/scripts/list_user_fields.py new file mode 100644 index 0000000..cf86903 --- /dev/null +++ b/integrations/servicenow-iam/scripts/list_user_fields.py @@ -0,0 +1,69 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _headers(cfg): + h = {"Accept": "application/json", "Content-Type": "application/json"} + if str(cfg.get("auth_type") or "basic").lower() == "oauth": + data = urllib.parse.urlencode({ + "grant_type": "password", + "client_id": cfg.get("client_id", ""), + "client_secret": cfg.get("client_secret", ""), + "username": cfg.get("username", ""), + "password": cfg.get("password", ""), + }).encode("utf-8") + req = urllib.request.Request( + cfg.get("url", "").rstrip("/") + "/oauth_token.do", data=data, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("OAuth token request failed: " + json.dumps(tok)) + h["Authorization"] = "Bearer " + tok["access_token"] + else: + cred = (cfg.get("username", "") + ":" + cfg.get("password", "")).encode("utf-8") + h["Authorization"] = "Basic " + base64.b64encode(cred).decode("ascii") + return h + + +def request(method, path, params=None, body=None, root="/api/now", versioned=True): + cfg = _cfg() + v = str(cfg.get("api_version") or "").strip().strip("/") + url = cfg.get("url", "").rstrip("/") + root + ("/" + v if v and versioned else "") + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += "?" + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, headers=_headers(cfg), method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + res = request("GET", "/table/sys_dictionary", params={ + "sysparm_query": "name=sys_user", + "sysparm_fields": "element,sys_name,internal_type", + "sysparm_limit": int(inputs.get("limit") or 500), + }) + fields = [] + for row in res.get("result") or []: + name = row.get("element") + if name: + fields.append({"name": name, "label": row.get("sys_name"), "type": row.get("internal_type")}) + fields.sort(key=lambda f: f["name"]) + print(json.dumps({"table": "sys_user", "fields": fields})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/servicenow-iam/scripts/test_connection.py b/integrations/servicenow-iam/scripts/test_connection.py new file mode 100644 index 0000000..f1c7251 --- /dev/null +++ b/integrations/servicenow-iam/scripts/test_connection.py @@ -0,0 +1,60 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _headers(cfg): + h = {"Accept": "application/json", "Content-Type": "application/json"} + if str(cfg.get("auth_type") or "basic").lower() == "oauth": + data = urllib.parse.urlencode({ + "grant_type": "password", + "client_id": cfg.get("client_id", ""), + "client_secret": cfg.get("client_secret", ""), + "username": cfg.get("username", ""), + "password": cfg.get("password", ""), + }).encode("utf-8") + req = urllib.request.Request( + cfg.get("url", "").rstrip("/") + "/oauth_token.do", data=data, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("OAuth token request failed: " + json.dumps(tok)) + h["Authorization"] = "Bearer " + tok["access_token"] + else: + cred = (cfg.get("username", "") + ":" + cfg.get("password", "")).encode("utf-8") + h["Authorization"] = "Basic " + base64.b64encode(cred).decode("ascii") + return h + + +def request(method, path, params=None, body=None, root="/api/now", versioned=True): + cfg = _cfg() + v = str(cfg.get("api_version") or "").strip().strip("/") + url = cfg.get("url", "").rstrip("/") + root + ("/" + v if v and versioned else "") + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += "?" + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, headers=_headers(cfg), method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def main(): + res = request("GET", "/table/sys_user", params={"sysparm_limit": 1}) + if "result" not in res: + raise Exception("Unexpected response: " + json.dumps(res)) + print(json.dumps({"ok": True, "table": "sys_user"})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/servicenow-iam/scripts/update_user.py b/integrations/servicenow-iam/scripts/update_user.py new file mode 100644 index 0000000..d681253 --- /dev/null +++ b/integrations/servicenow-iam/scripts/update_user.py @@ -0,0 +1,107 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _headers(cfg): + h = {"Accept": "application/json", "Content-Type": "application/json"} + if str(cfg.get("auth_type") or "basic").lower() == "oauth": + data = urllib.parse.urlencode({ + "grant_type": "password", + "client_id": cfg.get("client_id", ""), + "client_secret": cfg.get("client_secret", ""), + "username": cfg.get("username", ""), + "password": cfg.get("password", ""), + }).encode("utf-8") + req = urllib.request.Request( + cfg.get("url", "").rstrip("/") + "/oauth_token.do", data=data, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("OAuth token request failed: " + json.dumps(tok)) + h["Authorization"] = "Bearer " + tok["access_token"] + else: + cred = (cfg.get("username", "") + ":" + cfg.get("password", "")).encode("utf-8") + h["Authorization"] = "Basic " + base64.b64encode(cred).decode("ascii") + return h + + +def request(method, path, params=None, body=None, root="/api/now", versioned=True): + cfg = _cfg() + v = str(cfg.get("api_version") or "").strip().strip("/") + url = cfg.get("url", "").rstrip("/") + root + ("/" + v if v and versioned else "") + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += "?" + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, headers=_headers(cfg), method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def find_user_sys_id(inputs): + uid = str(inputs.get("user_id") or "") + if uid: + return uid + for key in ("user_name", "email"): + val = str(inputs.get(key) or "") + if val: + res = request("GET", "/table/sys_user", params={ + "sysparm_query": key + "=" + val, + "sysparm_limit": 1, + "sysparm_fields": "sys_id", + }) + rows = res.get("result") or [] + if not rows: + raise Exception("user not found (" + key + "=" + val + ")") + return rows[0]["sys_id"] + raise Exception("user_id, user_name or email is required") + + +def split_fields(s, prefix_custom=False): + out = {} + for part in str(s or "").split(";"): + if "=" not in part: + continue + k, v = part.split("=", 1) + k = k.strip() + if prefix_custom and k and not k.startswith("u_"): + k = "u_" + k + if k: + out[k] = v + return out + + +NAMED_FIELDS = [ + "first_name", "last_name", "title", + "department", "manager", "phone", "mobile_phone", +] + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + sys_id = find_user_sys_id(inputs) + body = {} + for k in NAMED_FIELDS: + v = inputs.get(k) + if v not in (None, ""): + body[k] = v + body.update(split_fields(inputs.get("fields"))) + body.update(split_fields(inputs.get("custom_fields"), prefix_custom=True)) + if not body: + raise Exception("nothing to update") + print(json.dumps(request("PATCH", "/table/sys_user/" + urllib.parse.quote(sys_id), body=body))) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1)