diff --git a/integrations/microsoft-entra-id/manifest.yaml b/integrations/microsoft-entra-id/manifest.yaml new file mode 100644 index 0000000..8c8a722 --- /dev/null +++ b/integrations/microsoft-entra-id/manifest.yaml @@ -0,0 +1,172 @@ +id: microsoft_entra_id +name: Microsoft Entra ID +version: 1.0.0 +description: "Microsoft Entra ID (Microsoft Graph, users) — identity response and lifecycle: look up/list users, create/update/delete, disable and re-enable accounts, revoke sign-in sessions, force password reset, and read groups, manager and authentication methods. Azure AD OAuth 2.0 client-credentials authentication. Stdlib-only, no extra Python dependencies." +changelog: "1.0.0 — Initial release: user get/list/create/update/delete, account disable/enable, session revocation, password reset, and group/manager/auth-method reads." +category: identity + +# Per-instance configuration. Register an Azure AD application, grant it the +# Microsoft Graph application permissions (User.ReadWrite.All, Group.Read.All, +# User.RevokeSessions.All, UserAuthenticationMethod.Read.All, Directory.Read.All) +# and admin-consent them. The scripts request a Microsoft Graph token +# (client-credentials) and call https://graph.microsoft.com/v1.0. +config_schema: + properties: + tenant_id: + type: string + description: "Azure AD tenant (directory) ID" + client_id: + type: string + description: "Application (client) ID" + client_secret: + type: string + description: "Client secret" + x-soar-sensitive: true + required: + - tenant_id + - client_id + - client_secret + +# Every command that targets a user accepts user_id — the object ID or the +# userPrincipalName (email) of the account. +commands: + - id: list_users + name: microsoft-entra-id-list-users + description: "List directory users, optionally filtered." + risk: read + inputs_schema: + properties: + filter: { type: string, description: "OData $filter (e.g. startswith(displayName,'A'))" } + search: { type: string, description: "Free-text $search (e.g. displayName:john)" } + limit: { type: number, description: "Maximum users (default 50)" } + required: [] + outputs_schema: { properties: {} } + - id: get_user + name: microsoft-entra-id-get-user + description: "Get a user's profile by object ID or userPrincipalName." + risk: read + inputs_schema: + properties: + user_id: { type: string, description: "User object ID or userPrincipalName" } + required: [user_id] + outputs_schema: { properties: {} } + - id: create_user + name: microsoft-entra-id-create-user + description: "Create a user account." + inputs_schema: + properties: + user_principal_name: { type: string, description: "userPrincipalName (email), e.g. jdoe@contoso.com" } + display_name: { type: string, description: "Display name" } + mail_nickname: { type: string, description: "Mail nickname (defaults to the UPN local part)" } + password: { type: string, description: "Initial password" } + force_change_password: { type: boolean, description: "Require a password change at next sign-in (default true)" } + account_enabled: { type: boolean, description: "Whether the account is enabled (default true)" } + given_name: { type: string, description: "First name" } + surname: { type: string, description: "Last name" } + job_title: { type: string, description: "Job title" } + department: { type: string, description: "Department" } + required: [user_principal_name, display_name, password] + outputs_schema: { properties: {} } + - id: update_user + name: microsoft-entra-id-update-user + description: "Update a user's profile fields." + inputs_schema: + properties: + user_id: { type: string, description: "User object ID or userPrincipalName" } + display_name: { type: string, description: "Display name" } + given_name: { type: string, description: "First name" } + surname: { type: string, description: "Last name" } + job_title: { type: string, description: "Job title" } + department: { type: string, description: "Department" } + mobile_phone: { type: string, description: "Mobile phone" } + fields_json: { type: string, description: "Raw Graph user fields as a JSON object (advanced; merged last)" } + required: [user_id] + outputs_schema: { properties: {} } + - id: delete_user + name: microsoft-entra-id-delete-user + description: "Delete a user account." + inputs_schema: + properties: + user_id: { type: string, description: "User object ID or userPrincipalName" } + required: [user_id] + outputs_schema: { properties: {} } + - id: disable_account + name: microsoft-entra-id-disable-account + description: "Disable a user account (accountEnabled = false)." + inputs_schema: + properties: + user_id: { type: string, description: "User object ID or userPrincipalName" } + required: [user_id] + outputs_schema: { properties: {} } + - id: enable_account + name: microsoft-entra-id-enable-account + description: "Re-enable a user account (accountEnabled = true)." + inputs_schema: + properties: + user_id: { type: string, description: "User object ID or userPrincipalName" } + required: [user_id] + outputs_schema: { properties: {} } + - id: revoke_sessions + name: microsoft-entra-id-revoke-sessions + description: "Revoke all refresh tokens / sign-in sessions for a user (forces re-authentication)." + inputs_schema: + properties: + user_id: { type: string, description: "User object ID or userPrincipalName" } + required: [user_id] + outputs_schema: { properties: {} } + - id: reset_password + name: microsoft-entra-id-reset-password + description: "Set a user's password (optionally forcing a change at next sign-in)." + inputs_schema: + properties: + user_id: { type: string, description: "User object ID or userPrincipalName" } + password: { type: string, description: "New password" } + force_change: { type: boolean, description: "Require a change at next sign-in (default true)" } + required: [user_id, password] + outputs_schema: { properties: {} } + - id: get_user_groups + name: microsoft-entra-id-get-user-groups + description: "List the groups a user is a member of." + risk: read + inputs_schema: + properties: + user_id: { type: string, description: "User object ID or userPrincipalName" } + limit: { type: number, description: "Maximum groups (default 50)" } + required: [user_id] + outputs_schema: { properties: {} } + - id: get_user_manager + name: microsoft-entra-id-get-user-manager + description: "Get a user's manager." + risk: read + inputs_schema: + properties: + user_id: { type: string, description: "User object ID or userPrincipalName" } + required: [user_id] + outputs_schema: { properties: {} } + - id: assign_manager + name: microsoft-entra-id-assign-manager + description: "Set a user's manager." + inputs_schema: + properties: + user_id: { type: string, description: "User object ID or userPrincipalName" } + manager_id: { type: string, description: "Manager's user object ID or userPrincipalName" } + required: [user_id, manager_id] + outputs_schema: { properties: {} } + - id: get_auth_methods + name: microsoft-entra-id-get-auth-methods + description: "List a user's registered authentication methods." + risk: read + inputs_schema: + properties: + user_id: { type: string, description: "User object ID or userPrincipalName" } + required: [user_id] + outputs_schema: { properties: {} } + + - id: test_connection + name: microsoft-entra-id-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/microsoft-entra-id/scripts/assign_manager.py b/integrations/microsoft-entra-id/scripts/assign_manager.py new file mode 100644 index 0000000..f53246d --- /dev/null +++ b/integrations/microsoft-entra-id/scripts/assign_manager.py @@ -0,0 +1,70 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id") or "") + "/oauth2/v2.0/token" + req = urllib.request.Request(url, 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("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None, extra_headers=None, token=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + (token or _token())} + if data is not None: + headers["Content-Type"] = "application/json" + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=data, headers=headers, 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", "{}")) + user_id = inputs.get("user_id") + if not user_id: + raise Exception("user_id is required") + manager_id = inputs.get("manager_id") + if not manager_id: + raise Exception("manager_id is required") + q = lambda v: urllib.parse.quote(str(v), safe="") + + body = {"@odata.id": "https://graph.microsoft.com/v1.0/users/" + q(manager_id)} + resp = request("PUT", "/users/" + q(user_id) + "/manager/$ref", body=body) + if not resp: + resp = {"ok": True, "user_id": user_id, "manager_id": manager_id} + print(json.dumps(resp)) + + +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/microsoft-entra-id/scripts/create_user.py b/integrations/microsoft-entra-id/scripts/create_user.py new file mode 100644 index 0000000..91b55f2 --- /dev/null +++ b/integrations/microsoft-entra-id/scripts/create_user.py @@ -0,0 +1,92 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id") or "") + "/oauth2/v2.0/token" + req = urllib.request.Request(url, 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("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None, extra_headers=None, token=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + (token or _token())} + if data is not None: + headers["Content-Type"] = "application/json" + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=data, headers=headers, 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", "{}")) + user_principal_name = inputs.get("user_principal_name") + if not user_principal_name: + raise Exception("user_principal_name is required") + display_name = inputs.get("display_name") + if not display_name: + raise Exception("display_name is required") + password = inputs.get("password") + if not password: + raise Exception("password is required") + + mail_nickname = inputs.get("mail_nickname") or str(user_principal_name).split("@")[0] + account_enabled = inputs.get("account_enabled", True) + force_change_password = inputs.get("force_change_password", True) + + body = { + "accountEnabled": bool(account_enabled), + "displayName": display_name, + "userPrincipalName": user_principal_name, + "mailNickname": mail_nickname, + "passwordProfile": { + "password": password, + "forceChangePasswordNextSignIn": bool(force_change_password), + }, + } + if inputs.get("given_name"): + body["givenName"] = inputs.get("given_name") + if inputs.get("surname"): + body["surname"] = inputs.get("surname") + if inputs.get("job_title"): + body["jobTitle"] = inputs.get("job_title") + if inputs.get("department"): + body["department"] = inputs.get("department") + + resp = request("POST", "/users", body=body) + print(json.dumps(resp)) + + +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/microsoft-entra-id/scripts/delete_user.py b/integrations/microsoft-entra-id/scripts/delete_user.py new file mode 100644 index 0000000..7b238f9 --- /dev/null +++ b/integrations/microsoft-entra-id/scripts/delete_user.py @@ -0,0 +1,66 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id") or "") + "/oauth2/v2.0/token" + req = urllib.request.Request(url, 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("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None, extra_headers=None, token=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + (token or _token())} + if data is not None: + headers["Content-Type"] = "application/json" + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=data, headers=headers, 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", "{}")) + user_id = inputs.get("user_id") + if not user_id: + raise Exception("user_id is required") + q = lambda v: urllib.parse.quote(str(v), safe="") + + resp = request("DELETE", "/users/" + q(user_id)) + if not resp: + resp = {"ok": True, "user_id": user_id} + print(json.dumps(resp)) + + +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/microsoft-entra-id/scripts/disable_account.py b/integrations/microsoft-entra-id/scripts/disable_account.py new file mode 100644 index 0000000..5f3409a --- /dev/null +++ b/integrations/microsoft-entra-id/scripts/disable_account.py @@ -0,0 +1,66 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id") or "") + "/oauth2/v2.0/token" + req = urllib.request.Request(url, 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("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None, extra_headers=None, token=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + (token or _token())} + if data is not None: + headers["Content-Type"] = "application/json" + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=data, headers=headers, 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", "{}")) + user_id = inputs.get("user_id") + if not user_id: + raise Exception("user_id is required") + q = lambda v: urllib.parse.quote(str(v), safe="") + + resp = request("PATCH", "/users/" + q(user_id), body={"accountEnabled": False}) + if not resp: + resp = {"ok": True, "user_id": user_id, "account_enabled": False} + print(json.dumps(resp)) + + +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/microsoft-entra-id/scripts/enable_account.py b/integrations/microsoft-entra-id/scripts/enable_account.py new file mode 100644 index 0000000..1006428 --- /dev/null +++ b/integrations/microsoft-entra-id/scripts/enable_account.py @@ -0,0 +1,66 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id") or "") + "/oauth2/v2.0/token" + req = urllib.request.Request(url, 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("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None, extra_headers=None, token=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + (token or _token())} + if data is not None: + headers["Content-Type"] = "application/json" + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=data, headers=headers, 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", "{}")) + user_id = inputs.get("user_id") + if not user_id: + raise Exception("user_id is required") + q = lambda v: urllib.parse.quote(str(v), safe="") + + resp = request("PATCH", "/users/" + q(user_id), body={"accountEnabled": True}) + if not resp: + resp = {"ok": True, "user_id": user_id, "account_enabled": True} + print(json.dumps(resp)) + + +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/microsoft-entra-id/scripts/get_auth_methods.py b/integrations/microsoft-entra-id/scripts/get_auth_methods.py new file mode 100644 index 0000000..7d9f406 --- /dev/null +++ b/integrations/microsoft-entra-id/scripts/get_auth_methods.py @@ -0,0 +1,64 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id") or "") + "/oauth2/v2.0/token" + req = urllib.request.Request(url, 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("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None, extra_headers=None, token=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + (token or _token())} + if data is not None: + headers["Content-Type"] = "application/json" + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=data, headers=headers, 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", "{}")) + user_id = inputs.get("user_id") + if not user_id: + raise Exception("user_id is required") + q = lambda v: urllib.parse.quote(str(v), safe="") + + resp = request("GET", "/users/" + q(user_id) + "/authentication/methods") + print(json.dumps(resp)) + + +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/microsoft-entra-id/scripts/get_user.py b/integrations/microsoft-entra-id/scripts/get_user.py new file mode 100644 index 0000000..0a0dbd8 --- /dev/null +++ b/integrations/microsoft-entra-id/scripts/get_user.py @@ -0,0 +1,64 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id") or "") + "/oauth2/v2.0/token" + req = urllib.request.Request(url, 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("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None, extra_headers=None, token=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + (token or _token())} + if data is not None: + headers["Content-Type"] = "application/json" + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=data, headers=headers, 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", "{}")) + user_id = inputs.get("user_id") + if not user_id: + raise Exception("user_id is required") + q = lambda v: urllib.parse.quote(str(v), safe="") + + resp = request("GET", "/users/" + q(user_id)) + print(json.dumps(resp)) + + +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/microsoft-entra-id/scripts/get_user_groups.py b/integrations/microsoft-entra-id/scripts/get_user_groups.py new file mode 100644 index 0000000..74112bd --- /dev/null +++ b/integrations/microsoft-entra-id/scripts/get_user_groups.py @@ -0,0 +1,65 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id") or "") + "/oauth2/v2.0/token" + req = urllib.request.Request(url, 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("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None, extra_headers=None, token=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + (token or _token())} + if data is not None: + headers["Content-Type"] = "application/json" + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=data, headers=headers, 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", "{}")) + user_id = inputs.get("user_id") + if not user_id: + raise Exception("user_id is required") + limit = inputs.get("limit", 50) + q = lambda v: urllib.parse.quote(str(v), safe="") + + resp = request("GET", "/users/" + q(user_id) + "/memberOf", params={"$top": limit}) + print(json.dumps(resp)) + + +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/microsoft-entra-id/scripts/get_user_manager.py b/integrations/microsoft-entra-id/scripts/get_user_manager.py new file mode 100644 index 0000000..5d6500f --- /dev/null +++ b/integrations/microsoft-entra-id/scripts/get_user_manager.py @@ -0,0 +1,64 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id") or "") + "/oauth2/v2.0/token" + req = urllib.request.Request(url, 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("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None, extra_headers=None, token=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + (token or _token())} + if data is not None: + headers["Content-Type"] = "application/json" + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=data, headers=headers, 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", "{}")) + user_id = inputs.get("user_id") + if not user_id: + raise Exception("user_id is required") + q = lambda v: urllib.parse.quote(str(v), safe="") + + resp = request("GET", "/users/" + q(user_id) + "/manager") + print(json.dumps(resp)) + + +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/microsoft-entra-id/scripts/list_users.py b/integrations/microsoft-entra-id/scripts/list_users.py new file mode 100644 index 0000000..044ec64 --- /dev/null +++ b/integrations/microsoft-entra-id/scripts/list_users.py @@ -0,0 +1,72 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id") or "") + "/oauth2/v2.0/token" + req = urllib.request.Request(url, 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("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None, extra_headers=None, token=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + (token or _token())} + if data is not None: + headers["Content-Type"] = "application/json" + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=data, headers=headers, 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", "{}")) + filt = inputs.get("filter") + search = inputs.get("search") + limit = inputs.get("limit", 50) + + params = {"$top": limit} + extra_headers = None + if filt: + params["$filter"] = filt + extra_headers = {"ConsistencyLevel": "eventual"} + if search: + params["$search"] = "\"" + str(search) + "\"" + extra_headers = {"ConsistencyLevel": "eventual"} + + resp = request("GET", "/users", params=params, extra_headers=extra_headers) + print(json.dumps(resp)) + + +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/microsoft-entra-id/scripts/reset_password.py b/integrations/microsoft-entra-id/scripts/reset_password.py new file mode 100644 index 0000000..1b40075 --- /dev/null +++ b/integrations/microsoft-entra-id/scripts/reset_password.py @@ -0,0 +1,76 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id") or "") + "/oauth2/v2.0/token" + req = urllib.request.Request(url, 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("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None, extra_headers=None, token=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + (token or _token())} + if data is not None: + headers["Content-Type"] = "application/json" + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=data, headers=headers, 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", "{}")) + user_id = inputs.get("user_id") + if not user_id: + raise Exception("user_id is required") + password = inputs.get("password") + if not password: + raise Exception("password is required") + force_change = inputs.get("force_change", True) + q = lambda v: urllib.parse.quote(str(v), safe="") + + body = { + "passwordProfile": { + "password": password, + "forceChangePasswordNextSignIn": bool(force_change), + } + } + resp = request("PATCH", "/users/" + q(user_id), body=body) + if not resp: + resp = {"ok": True, "user_id": user_id} + print(json.dumps(resp)) + + +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/microsoft-entra-id/scripts/revoke_sessions.py b/integrations/microsoft-entra-id/scripts/revoke_sessions.py new file mode 100644 index 0000000..9de41a7 --- /dev/null +++ b/integrations/microsoft-entra-id/scripts/revoke_sessions.py @@ -0,0 +1,64 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id") or "") + "/oauth2/v2.0/token" + req = urllib.request.Request(url, 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("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None, extra_headers=None, token=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + (token or _token())} + if data is not None: + headers["Content-Type"] = "application/json" + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=data, headers=headers, 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", "{}")) + user_id = inputs.get("user_id") + if not user_id: + raise Exception("user_id is required") + q = lambda v: urllib.parse.quote(str(v), safe="") + + resp = request("POST", "/users/" + q(user_id) + "/revokeSignInSessions") + print(json.dumps(resp)) + + +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/microsoft-entra-id/scripts/test_connection.py b/integrations/microsoft-entra-id/scripts/test_connection.py new file mode 100644 index 0000000..a94d45f --- /dev/null +++ b/integrations/microsoft-entra-id/scripts/test_connection.py @@ -0,0 +1,60 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id") or "") + "/oauth2/v2.0/token" + req = urllib.request.Request(url, 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("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None, extra_headers=None, token=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + (token or _token())} + if data is not None: + headers["Content-Type"] = "application/json" + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def main(): + resp = request("GET", "/organization", params={"$select": "id,displayName", "$top": 1}) + if "value" not in resp: + raise Exception("unexpected response") + print(json.dumps({"ok": True})) + + +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/microsoft-entra-id/scripts/update_user.py b/integrations/microsoft-entra-id/scripts/update_user.py new file mode 100644 index 0000000..3b5e2b1 --- /dev/null +++ b/integrations/microsoft-entra-id/scripts/update_user.py @@ -0,0 +1,90 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id") or "") + "/oauth2/v2.0/token" + req = urllib.request.Request(url, 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("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None, extra_headers=None, token=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + (token or _token())} + if data is not None: + headers["Content-Type"] = "application/json" + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=data, headers=headers, 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", "{}")) + user_id = inputs.get("user_id") + if not user_id: + raise Exception("user_id is required") + q = lambda v: urllib.parse.quote(str(v), safe="") + + body = {} + if inputs.get("display_name"): + body["displayName"] = inputs.get("display_name") + if inputs.get("given_name"): + body["givenName"] = inputs.get("given_name") + if inputs.get("surname"): + body["surname"] = inputs.get("surname") + if inputs.get("job_title"): + body["jobTitle"] = inputs.get("job_title") + if inputs.get("department"): + body["department"] = inputs.get("department") + if inputs.get("mobile_phone"): + body["mobilePhone"] = inputs.get("mobile_phone") + + fields_json = inputs.get("fields_json") + if fields_json: + extra = json.loads(fields_json) + if not isinstance(extra, dict): + raise Exception("fields_json must be a JSON object") + body.update(extra) + + if not body: + raise Exception("nothing to update") + + resp = request("PATCH", "/users/" + q(user_id), body=body) + if not resp: + resp = {"ok": True, "user_id": user_id} + print(json.dumps(resp)) + + +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)