Compare commits
2 Commits
07fe4b50a7
...
3608cbb74e
| Author | SHA1 | Date | |
|---|---|---|---|
| 3608cbb74e | |||
| b98d315fe4 |
@@ -0,0 +1,286 @@
|
|||||||
|
id: microsoft_graph_files
|
||||||
|
name: Microsoft Graph Files
|
||||||
|
version: 1.0.0
|
||||||
|
description: "Microsoft Graph file management for O365 (OneDrive / SharePoint / Teams) — browse sites, drives and content, create folders, upload/replace/download files, manage site permissions, read SharePoint lists, and edit Excel worksheets. App-only authentication (client credentials)."
|
||||||
|
changelog: "1.0.0 — Initial release: SharePoint sites/drives/content browsing, folder creation, file delete/upload/replace/download (via URL or base64), site permission management (list/create/update/delete), SharePoint list reading (lists/items/get-item), and Excel worksheet editing (add-row/get-worksheet/update-cell). App-only client-credentials authentication."
|
||||||
|
category: productivity
|
||||||
|
|
||||||
|
# Per-instance configuration. Uses app-only (client credentials) authentication
|
||||||
|
# against Microsoft Graph. Register an Azure AD application with the required
|
||||||
|
# application permissions (e.g. Sites.ReadWrite.All, Files.ReadWrite.All) and admin
|
||||||
|
# consent, then provide the tenant id, client id and client secret.
|
||||||
|
config_schema:
|
||||||
|
properties:
|
||||||
|
host:
|
||||||
|
type: string
|
||||||
|
description: "Microsoft Graph base URL"
|
||||||
|
default: https://graph.microsoft.com
|
||||||
|
login_url:
|
||||||
|
type: string
|
||||||
|
description: "Azure AD login endpoint (change for national clouds, e.g. https://login.microsoftonline.us)"
|
||||||
|
default: https://login.microsoftonline.com
|
||||||
|
tenant_id:
|
||||||
|
type: string
|
||||||
|
description: "Azure AD tenant ID"
|
||||||
|
client_id:
|
||||||
|
type: string
|
||||||
|
description: "Application (client) ID"
|
||||||
|
client_secret:
|
||||||
|
type: string
|
||||||
|
description: "Client secret"
|
||||||
|
x-soar-sensitive: true
|
||||||
|
insecure:
|
||||||
|
type: boolean
|
||||||
|
description: "Trust any TLS certificate (not secure)"
|
||||||
|
default: false
|
||||||
|
required:
|
||||||
|
- tenant_id
|
||||||
|
- client_id
|
||||||
|
- client_secret
|
||||||
|
|
||||||
|
# Documented for reference; the bundled scripts obtain a bearer token themselves
|
||||||
|
# via the client-credentials grant and set Authorization: Bearer <token>.
|
||||||
|
auth:
|
||||||
|
- id: apikey
|
||||||
|
type: api_key
|
||||||
|
in: header
|
||||||
|
name: Authorization
|
||||||
|
value_template: "Bearer {{secret}}"
|
||||||
|
secret_field: client_secret
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- id: test_connection
|
||||||
|
name: msgraph-files-auth-test
|
||||||
|
description: "Verify connectivity and credentials against Microsoft Graph (used by the Test button)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
# ── Sites / drives / content ──────────────────────────────────────────────
|
||||||
|
- id: list_sharepoint_sites
|
||||||
|
name: msgraph-list-sharepoint-sites
|
||||||
|
description: "Search the tenant's SharePoint sites (requires Sites.Read.All)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
keyword: { type: string, description: "Search keyword; defaults to all sites (*)" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: list_drives_in_site
|
||||||
|
name: msgraph-list-drives-in-site
|
||||||
|
description: "List the document libraries (drives) of a site."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
site_id: { type: string, description: "Site ID (use msgraph-list-sharepoint-sites to find it)" }
|
||||||
|
limit: { type: number, description: "Maximum results" }
|
||||||
|
next_page_url: { type: string, description: "The @odata.nextLink URL for the next page" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: list_drive_content
|
||||||
|
name: msgraph-list-drive-content
|
||||||
|
description: "List files and folders in a drive (object_type: drives/groups/sites/users)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
object_type: { type: string, description: "drives, groups, sites or users" }
|
||||||
|
object_type_id: { type: string, description: "The resource ID" }
|
||||||
|
item_id: { type: string, description: "Folder item ID (default root)" }
|
||||||
|
limit: { type: number, description: "Maximum results" }
|
||||||
|
next_page_url: { type: string, description: "The @odata.nextLink URL for the next page" }
|
||||||
|
required: [object_type, object_type_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: create_new_folder
|
||||||
|
name: msgraph-create-new-folder
|
||||||
|
description: "Create a folder under a parent item in a drive."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
object_type: { type: string, description: "drives, groups, sites or users" }
|
||||||
|
object_type_id: { type: string, description: "The resource ID" }
|
||||||
|
parent_id: { type: string, description: "Parent item ID (root or a folder ID)" }
|
||||||
|
folder_name: { type: string, description: "Name of the new folder" }
|
||||||
|
required: [object_type, object_type_id, parent_id, folder_name]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: delete_file
|
||||||
|
name: msgraph-delete-file
|
||||||
|
description: "Delete a drive item (file or folder) by its ID."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
object_type: { type: string, description: "drives, groups, sites or users" }
|
||||||
|
object_type_id: { type: string, description: "The resource ID" }
|
||||||
|
item_id: { type: string, description: "Item ID to delete" }
|
||||||
|
required: [object_type, object_type_id, item_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
# ── File transfer (URL / base64) ──────────────────────────────────────────
|
||||||
|
- id: download_file
|
||||||
|
name: msgraph-download-file
|
||||||
|
description: "Return a drive item's metadata and a pre-authenticated download URL. Set as_base64=true to also return the content base64-encoded (small files only)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
object_type: { type: string, description: "drives, groups, sites or users" }
|
||||||
|
object_type_id: { type: string, description: "The resource ID" }
|
||||||
|
item_id: { type: string, description: "Item ID to download" }
|
||||||
|
as_base64: { type: string, description: "Set true to also include base64 content (small files)" }
|
||||||
|
required: [object_type, object_type_id, item_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: upload_new_file
|
||||||
|
name: msgraph-upload-new-file
|
||||||
|
description: "Upload a new file into a folder. Provide the content via content_base64 or source_url."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
object_type: { type: string, description: "drives, groups, sites or users" }
|
||||||
|
object_type_id: { type: string, description: "The resource ID" }
|
||||||
|
parent_id: { type: string, description: "Parent folder ID" }
|
||||||
|
file_name: { type: string, description: "Name for the uploaded file" }
|
||||||
|
content_base64: { type: string, description: "File content, base64-encoded" }
|
||||||
|
source_url: { type: string, description: "URL to fetch the file content from (alternative to content_base64)" }
|
||||||
|
required: [object_type, object_type_id, parent_id, file_name]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: replace_existing_file
|
||||||
|
name: msgraph-replace-existing-file
|
||||||
|
description: "Replace the content of an existing file. Provide the new content via content_base64 or source_url."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
object_type: { type: string, description: "drives, groups, sites or users" }
|
||||||
|
object_type_id: { type: string, description: "The resource ID" }
|
||||||
|
item_id: { type: string, description: "Item ID of the file to replace" }
|
||||||
|
content_base64: { type: string, description: "New file content, base64-encoded" }
|
||||||
|
source_url: { type: string, description: "URL to fetch the new content from (alternative to content_base64)" }
|
||||||
|
required: [object_type, object_type_id, item_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
# ── Site permissions ──────────────────────────────────────────────────────
|
||||||
|
- id: list_site_permissions
|
||||||
|
name: msgraph-list-site-permissions
|
||||||
|
description: "List application permissions on a site, or a single permission when permission_id is given. Provide site_id or site_name."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
site_id: { type: string, description: "Site ID (or provide site_name)" }
|
||||||
|
site_name: { type: string, description: "Site name (resolved to an ID; or provide site_id)" }
|
||||||
|
permission_id: { type: string, description: "A specific permission ID to retrieve" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: create_site_permissions
|
||||||
|
name: msgraph-create-site-permissions
|
||||||
|
description: "Grant an application permission (read/write/owner) on a site."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
site_id: { type: string, description: "Site ID (or provide site_name)" }
|
||||||
|
site_name: { type: string, description: "Site name (or provide site_id)" }
|
||||||
|
role: { type: string, description: "read, write or owner" }
|
||||||
|
app_id: { type: string, description: "Application (client) ID to grant" }
|
||||||
|
display_name: { type: string, description: "Display name of the application" }
|
||||||
|
required: [role, app_id, display_name]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: update_site_permissions
|
||||||
|
name: msgraph-update-site-permissions
|
||||||
|
description: "Update the role of an existing site permission."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
site_id: { type: string, description: "Site ID (or provide site_name)" }
|
||||||
|
site_name: { type: string, description: "Site name (or provide site_id)" }
|
||||||
|
permission_id: { type: string, description: "Permission ID to update" }
|
||||||
|
role: { type: string, description: "read, write or owner" }
|
||||||
|
required: [permission_id, role]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: delete_site_permissions
|
||||||
|
name: msgraph-delete-site-permissions
|
||||||
|
description: "Delete an application permission from a site."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
site_id: { type: string, description: "Site ID (or provide site_name)" }
|
||||||
|
site_name: { type: string, description: "Site name (or provide site_id)" }
|
||||||
|
permission_id: { type: string, description: "Permission ID to delete" }
|
||||||
|
required: [permission_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
# ── SharePoint lists ──────────────────────────────────────────────────────
|
||||||
|
- id: list_sharepoint_lists
|
||||||
|
name: msgraph-list-sharepoint-lists
|
||||||
|
description: "List the SharePoint lists of a site. Provide site_id or site_name."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
site_id: { type: string, description: "Site ID (or provide site_name)" }
|
||||||
|
site_name: { type: string, description: "Site name (or provide site_id)" }
|
||||||
|
limit: { type: number, description: "Maximum results" }
|
||||||
|
next_page_url: { type: string, description: "The @odata.nextLink URL for the next page" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: list_sharepoint_list_items
|
||||||
|
name: msgraph-list-sharepoint-list-items
|
||||||
|
description: "List items of a SharePoint list (with their fields). Provide list_id or list_name."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
site_id: { type: string, description: "Site ID (or provide site_name)" }
|
||||||
|
site_name: { type: string, description: "Site name (or provide site_id)" }
|
||||||
|
list_id: { type: string, description: "List ID (or provide list_name)" }
|
||||||
|
list_name: { type: string, description: "List name (or provide list_id)" }
|
||||||
|
limit: { type: number, description: "Maximum results" }
|
||||||
|
filter: { type: string, description: "OData $filter expression" }
|
||||||
|
orderby: { type: string, description: "OData $orderby expression" }
|
||||||
|
fields_select: { type: string, description: "Comma-separated list of field columns to expand" }
|
||||||
|
next_page_url: { type: string, description: "The @odata.nextLink URL for the next page" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: get_sharepoint_list_item
|
||||||
|
name: msgraph-get-sharepoint-list-item
|
||||||
|
description: "Get a single SharePoint list item with its fields. Provide list_id or list_name."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
site_id: { type: string, description: "Site ID (or provide site_name)" }
|
||||||
|
site_name: { type: string, description: "Site name (or provide site_id)" }
|
||||||
|
list_id: { type: string, description: "List ID (or provide list_name)" }
|
||||||
|
list_name: { type: string, description: "List name (or provide list_id)" }
|
||||||
|
item_id: { type: string, description: "List item ID" }
|
||||||
|
required: [item_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
# ── Excel ─────────────────────────────────────────────────────────────────
|
||||||
|
- id: excel_add_row
|
||||||
|
name: msgraph-excel-add-row
|
||||||
|
description: "Append a row to an Excel worksheet. 'data' is a JSON object mapping column number (1-based) to value, e.g. {\"1\":\"foo\",\"2\":42}."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
object_type: { type: string, description: "drives, groups, sites or users" }
|
||||||
|
object_type_id: { type: string, description: "The resource ID" }
|
||||||
|
item_id: { type: string, description: "Workbook (xlsx) item ID" }
|
||||||
|
worksheet_name: { type: string, description: "Worksheet name" }
|
||||||
|
data: { type: object, description: "Object mapping 1-based column number to value" }
|
||||||
|
start_row: { type: number, description: "Row to start appending from (default 1)" }
|
||||||
|
required: [object_type_id, item_id, worksheet_name, data]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: excel_get_worksheet
|
||||||
|
name: msgraph-excel-get-worksheet
|
||||||
|
description: "Read a range from an Excel worksheet (formatted text values)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
object_type: { type: string, description: "drives, groups, sites or users" }
|
||||||
|
object_type_id: { type: string, description: "The resource ID" }
|
||||||
|
item_id: { type: string, description: "Workbook (xlsx) item ID" }
|
||||||
|
worksheet_name: { type: string, description: "Worksheet name" }
|
||||||
|
range: { type: string, description: "Range address, e.g. A1:Z500 (default A1:Z500)" }
|
||||||
|
required: [object_type_id, item_id, worksheet_name]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: excel_update_cell
|
||||||
|
name: msgraph-excel-update-cell
|
||||||
|
description: "Overwrite a single Excel cell. 'column' may be a number or 'Col_2'."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
object_type: { type: string, description: "drives, groups, sites or users" }
|
||||||
|
object_type_id: { type: string, description: "The resource ID" }
|
||||||
|
item_id: { type: string, description: "Workbook (xlsx) item ID" }
|
||||||
|
worksheet_name: { type: string, description: "Worksheet name" }
|
||||||
|
row_index: { type: number, description: "1-based row index" }
|
||||||
|
column: { type: string, description: "Column number or 'Col_2'" }
|
||||||
|
value: { type: string, description: "New cell value" }
|
||||||
|
required: [object_type_id, item_id, worksheet_name, row_index, column, value]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
parent = drive_item_base(I["object_type"], I["object_type_id"], I["parent_id"])
|
||||||
|
body = {"name": I["folder_name"], "folder": {}, "@microsoft.graph.conflictBehavior": "rename"}
|
||||||
|
out(graph("POST", parent + "/children", json_body=body))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
site_id = resolve_site_id()
|
||||||
|
body = {
|
||||||
|
"roles": _list(I["role"]),
|
||||||
|
"grantedToIdentities": [{"application": {"id": I["app_id"], "displayName": I["display_name"]}}],
|
||||||
|
}
|
||||||
|
out(graph("POST", "/sites/" + site_id + "/permissions", json_body=body))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
uri = drive_item_base(I["object_type"], I["object_type_id"], I["item_id"])
|
||||||
|
graph("DELETE", uri, resp="response")
|
||||||
|
out({"deleted": I["item_id"]})
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
site_id = resolve_site_id()
|
||||||
|
uri = "/sites/" + site_id + "/permissions/" + I["permission_id"]
|
||||||
|
graph("DELETE", uri, resp="response")
|
||||||
|
out({"deleted": I["permission_id"], "site_id": site_id})
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
base = drive_item_base(I["object_type"], I["object_type_id"], I["item_id"])
|
||||||
|
meta = graph("GET", base)
|
||||||
|
url = meta.get("@microsoft.graph.downloadUrl")
|
||||||
|
result = {"id": meta.get("id"), "name": meta.get("name"), "size": meta.get("size"), "download_url": url}
|
||||||
|
if _bool(I.get("as_base64")) and url:
|
||||||
|
result["content_base64"] = base64.b64encode(fetch_url(url)).decode("ascii")
|
||||||
|
out(result)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
data = I["data"]
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = json.loads(data)
|
||||||
|
if not data:
|
||||||
|
fail("data is empty")
|
||||||
|
return
|
||||||
|
base = worksheet_base(I["object_type"] or "sites", I["object_type_id"], I["item_id"], I["worksheet_name"])
|
||||||
|
used = graph("GET", base + "/usedRange", params={"$select": "rowCount"})
|
||||||
|
row_count = int(used.get("rowCount", 0))
|
||||||
|
start_row = int(I.get("start_row") or 1)
|
||||||
|
target_row = max(start_row, row_count + 1)
|
||||||
|
max_col = max(int(k) for k in data)
|
||||||
|
row_values = [""] * max_col
|
||||||
|
for k, v in data.items():
|
||||||
|
row_values[int(k) - 1] = v
|
||||||
|
addr = "A" + str(target_row) + ":" + col_letter(max_col) + str(target_row)
|
||||||
|
res = graph("PATCH", base + "/range(address='" + addr + "')", json_body={"values": [row_values]})
|
||||||
|
out({"address": res.get("address", addr), "row": target_row, "values": row_values})
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
base = worksheet_base(I.get("object_type") or "sites", I["object_type_id"], I["item_id"], I["worksheet_name"])
|
||||||
|
range_addr = I.get("range") or "A1:Z500"
|
||||||
|
res = graph("GET", base + "/range(address='" + range_addr + "')")
|
||||||
|
out({"address": res.get("address"), "values": res.get("text") or res.get("values")})
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
match = re.search(r"\d+", str(I["column"]))
|
||||||
|
if not match:
|
||||||
|
fail("Invalid column: " + str(I["column"]))
|
||||||
|
return
|
||||||
|
col_num = int(match.group())
|
||||||
|
addr = col_letter(col_num) + str(int(I["row_index"]))
|
||||||
|
base = worksheet_base(I.get("object_type") or "sites", I["object_type_id"], I["item_id"], I["worksheet_name"])
|
||||||
|
graph("PATCH", base + "/range(address='" + addr + "')", json_body={"values": [[I["value"]]]})
|
||||||
|
out({"cell": addr, "value": I["value"]})
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
site_id = resolve_site_id()
|
||||||
|
list_id = resolve_list_id(site_id)
|
||||||
|
uri = "/sites/" + site_id + "/lists/" + list_id + "/items/" + I["item_id"]
|
||||||
|
out(graph("GET", uri, params={"$expand": "fields"}))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
if I.get("next_page_url"):
|
||||||
|
res = graph("GET", I["next_page_url"], params={"$top": I.get("limit")})
|
||||||
|
else:
|
||||||
|
item = I.get("item_id") or "root"
|
||||||
|
uri = drive_item_base(I["object_type"], I["object_type_id"], item) + "/children"
|
||||||
|
res = graph("GET", uri, params={"$top": I.get("limit")})
|
||||||
|
out({"children": res.get("value", []), "next": res.get("@odata.nextLink")})
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
params = {"$top": I.get("limit")}
|
||||||
|
if I.get("next_page_url"):
|
||||||
|
res = graph("GET", I["next_page_url"], params=params)
|
||||||
|
elif I.get("site_id"):
|
||||||
|
res = graph("GET", "/sites/" + I["site_id"] + "/drives", params=params)
|
||||||
|
else:
|
||||||
|
fail("Provide site_id or next_page_url")
|
||||||
|
return
|
||||||
|
out({"drives": res.get("value", []), "next": res.get("@odata.nextLink")})
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
site_id = resolve_site_id()
|
||||||
|
list_id = resolve_list_id(site_id)
|
||||||
|
expand = "fields($select=" + I["fields_select"] + ")" if I.get("fields_select") else "fields"
|
||||||
|
params = {"$top": I.get("limit"), "$filter": I.get("filter"), "$orderby": I.get("orderby"), "$expand": expand}
|
||||||
|
if I.get("next_page_url"):
|
||||||
|
res = graph("GET", I["next_page_url"], params={"$top": I.get("limit")})
|
||||||
|
else:
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists/" + list_id + "/items", params=params)
|
||||||
|
out({"items": res.get("value", []), "next": res.get("@odata.nextLink")})
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
site_id = resolve_site_id()
|
||||||
|
if I.get("next_page_url"):
|
||||||
|
res = graph("GET", I["next_page_url"], params={"$top": I.get("limit")})
|
||||||
|
else:
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": I.get("limit")})
|
||||||
|
out({"lists": res.get("value", []), "next": res.get("@odata.nextLink")})
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
res = graph("GET", "/sites", params={"search": I.get("keyword") or "*"})
|
||||||
|
out({"sites": res.get("value", []), "next": res.get("@odata.nextLink")})
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
site_id = resolve_site_id()
|
||||||
|
pid = I.get("permission_id")
|
||||||
|
uri = "/sites/" + site_id + "/permissions" + ("/" + pid if pid else "")
|
||||||
|
res = graph("GET", uri)
|
||||||
|
out(res if pid else {"permissions": res.get("value", [])})
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
content = input_bytes()
|
||||||
|
uri = drive_item_base(I["object_type"], I["object_type_id"], I["item_id"]) + "/content"
|
||||||
|
out(graph("PUT", uri, data=content))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
graph("GET", "/sites", params={"search": "*", "$top": 1})
|
||||||
|
out({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
site_id = resolve_site_id()
|
||||||
|
uri = "/sites/" + site_id + "/permissions/" + I["permission_id"]
|
||||||
|
out(graph("PATCH", uri, json_body={"roles": _list(I["role"])}))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import json, os, re, ssl, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
GRAPH = (S.get("host") or "https://graph.microsoft.com").rstrip("/")
|
||||||
|
LOGIN = (S.get("login_url") or "https://login.microsoftonline.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v):
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return ssl._create_unverified_context() if _bool(S.get("insecure")) else ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
global _TOKEN
|
||||||
|
if _TOKEN is not None:
|
||||||
|
return _TOKEN
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": S.get("client_id", ""),
|
||||||
|
"client_secret": S.get("client_secret", ""),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": GRAPH + "/.default",
|
||||||
|
}).encode("utf-8")
|
||||||
|
url = LOGIN + "/" + urllib.parse.quote(S.get("tenant_id", "")) + "/oauth2/v2.0/token"
|
||||||
|
req = urllib.request.Request(url, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r:
|
||||||
|
_TOKEN = json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Authentication failed: HTTP " + str(e.code) + " " + e.read().decode("utf-8", "replace"))
|
||||||
|
return _TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def graph(method, path, params=None, json_body=None, data=None, headers=None, resp="json"):
|
||||||
|
url = path if path.startswith("http") else (GRAPH + "/v1.0" + path)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
|
||||||
|
h = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
|
||||||
|
body = None
|
||||||
|
if json_body is not None:
|
||||||
|
body = json.dumps(json_body).encode("utf-8")
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
h["Content-Type"] = "application/octet-stream"
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if resp == "response":
|
||||||
|
return {"status": r.status}
|
||||||
|
if resp == "content":
|
||||||
|
return raw
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise Exception("Graph HTTP " + str(e.code) + ": " + e.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url):
|
||||||
|
"""Fetch raw bytes from an arbitrary URL (no Graph auth header)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx()) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def input_bytes():
|
||||||
|
"""Resolve upload content from content_base64 or source_url."""
|
||||||
|
if I.get("content_base64"):
|
||||||
|
return base64.b64decode(I["content_base64"])
|
||||||
|
if I.get("source_url"):
|
||||||
|
return fetch_url(I["source_url"])
|
||||||
|
fail("Provide either content_base64 or source_url")
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(n):
|
||||||
|
s = ""
|
||||||
|
n = int(n)
|
||||||
|
while n > 0:
|
||||||
|
n, rem = divmod(n - 1, 26)
|
||||||
|
s = chr(65 + rem) + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_site_id():
|
||||||
|
if I.get("site_id"):
|
||||||
|
return I["site_id"]
|
||||||
|
name = I.get("site_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide site_id or site_name")
|
||||||
|
res = graph("GET", "/sites", params={"search": name})
|
||||||
|
value = res.get("value", [])
|
||||||
|
if not value:
|
||||||
|
fail("Site not found: " + name)
|
||||||
|
return value[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_id(site_id):
|
||||||
|
if I.get("list_id"):
|
||||||
|
return I["list_id"]
|
||||||
|
name = I.get("list_name")
|
||||||
|
if not name:
|
||||||
|
fail("Provide list_id or list_name")
|
||||||
|
res = graph("GET", "/sites/" + site_id + "/lists", params={"$top": 999})
|
||||||
|
for lst in res.get("value", []):
|
||||||
|
if (lst.get("displayName") or lst.get("name") or "").lower() == name.lower():
|
||||||
|
return lst["id"]
|
||||||
|
fail("List not found: " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_item_base(object_type, object_type_id, item_id):
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id
|
||||||
|
|
||||||
|
|
||||||
|
def worksheet_base(object_type, object_type_id, item_id, worksheet):
|
||||||
|
ws = urllib.parse.quote(worksheet)
|
||||||
|
if object_type == "drives":
|
||||||
|
return "/drives/" + object_type_id + "/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
return "/" + object_type + "/" + object_type_id + "/drive/items/" + item_id + "/workbook/worksheets/" + ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
content = input_bytes()
|
||||||
|
name = urllib.parse.quote(I["file_name"])
|
||||||
|
base = drive_item_base(I["object_type"], I["object_type_id"], I["parent_id"])
|
||||||
|
uri = base + ":/" + name + ":/content"
|
||||||
|
out(graph("PUT", uri, data=content))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
id: openldap
|
||||||
|
name: OpenLDAP
|
||||||
|
version: 1.0.0
|
||||||
|
description: "LDAP authentication for OpenLDAP or Active Directory — simple bind, fetch groups, authenticate-and-resolve-roles, and a generic LDAP entries search. Vendor is auto-detected. Runs on a remote engine inside the LDAP network. Requires the Python 'ldap3' library on the engine host (pip install ldap3)."
|
||||||
|
changelog: "1.0.0 — Initial release: ad-authenticate (simple bind), ad-groups (fetch all or specific groups), ad-authenticate-and-roles (bind + return the user's groups and attributes), ad-entries-search (generic LDAP search). Supports OpenLDAP and Active Directory with automatic vendor detection."
|
||||||
|
category: identity
|
||||||
|
|
||||||
|
# Per-instance configuration. Binds to an LDAP server (OpenLDAP or Active
|
||||||
|
# Directory) over plain LDAP (389), LDAPS (636) or Start TLS. Run this integration
|
||||||
|
# on a remote engine that can reach the directory. The bundled scripts require the
|
||||||
|
# Python 'ldap3' library on the engine host: pip install ldap3
|
||||||
|
config_schema:
|
||||||
|
properties:
|
||||||
|
ldap_server_vendor:
|
||||||
|
type: string
|
||||||
|
description: "LDAP vendor: OpenLDAP, Active Directory, or Auto (detect automatically). Default Auto."
|
||||||
|
default: Auto
|
||||||
|
host:
|
||||||
|
type: string
|
||||||
|
description: "LDAP server IP or host name (e.g. 192.168.0.1 or ldap.company.com)"
|
||||||
|
port:
|
||||||
|
type: string
|
||||||
|
description: "Port. Default 389 (LDAP / Start TLS) or 636 (LDAPS)."
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: "Bind user DN (e.g. cn=admin,ou=users,dc=domain,dc=com)"
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
description: "Bind password"
|
||||||
|
x-soar-sensitive: true
|
||||||
|
base_dn:
|
||||||
|
type: string
|
||||||
|
description: "Base DN (e.g. dc=domain,dc=com)"
|
||||||
|
connection_type:
|
||||||
|
type: string
|
||||||
|
description: "Connection security: None, SSL (LDAPS), or Start TLS. Default None."
|
||||||
|
default: None
|
||||||
|
ssl_version:
|
||||||
|
type: string
|
||||||
|
description: "SSL/TLS protocol: None, TLS, TLSv1, TLSv1_1, TLSv1_2, or TLS_CLIENT. Default None."
|
||||||
|
default: None
|
||||||
|
insecure:
|
||||||
|
type: boolean
|
||||||
|
description: "Trust any TLS certificate (not secure)"
|
||||||
|
default: false
|
||||||
|
page_size:
|
||||||
|
type: string
|
||||||
|
description: "LDAP paging size for searches"
|
||||||
|
default: "500"
|
||||||
|
fetch_groups:
|
||||||
|
type: boolean
|
||||||
|
description: "Auto-populate groups in ad-groups when no specific group is requested"
|
||||||
|
default: true
|
||||||
|
group_filter_class:
|
||||||
|
type: string
|
||||||
|
description: "Groups object class (OpenLDAP only)"
|
||||||
|
default: posixGroup
|
||||||
|
group_identifier_attribute:
|
||||||
|
type: string
|
||||||
|
description: "Groups unique identifier attribute (OpenLDAP only)"
|
||||||
|
default: gidNumber
|
||||||
|
member_identifier_attribute:
|
||||||
|
type: string
|
||||||
|
description: "Group membership identifier attribute (OpenLDAP only)"
|
||||||
|
default: memberUid
|
||||||
|
user_filter_class:
|
||||||
|
type: string
|
||||||
|
description: "User object class (OpenLDAP only)"
|
||||||
|
default: posixAccount
|
||||||
|
user_identifier_attribute:
|
||||||
|
type: string
|
||||||
|
description: "User unique identifier attribute (OpenLDAP only)"
|
||||||
|
default: uid
|
||||||
|
custom_attributes:
|
||||||
|
type: string
|
||||||
|
description: "Extra user attributes added to the user filter in ad-authenticate-and-roles, as 'attrA=valA,attrB=valB' (OpenLDAP only)"
|
||||||
|
required:
|
||||||
|
- ldap_server_vendor
|
||||||
|
- host
|
||||||
|
- username
|
||||||
|
- password
|
||||||
|
- base_dn
|
||||||
|
- connection_type
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- id: test_connection
|
||||||
|
name: ad-test-connection
|
||||||
|
description: "Bind to the LDAP server with the configured credentials (used by the Test button)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: ad_authenticate
|
||||||
|
name: ad-authenticate
|
||||||
|
description: "Perform a simple bind on the LDAP server with the given username and password."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
username: { type: string, description: "Username for simple authentication" }
|
||||||
|
password: { type: string, description: "Password for simple authentication", x-soar-sensitive: true }
|
||||||
|
required: [username, password]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: ad_groups
|
||||||
|
name: ad-groups
|
||||||
|
description: "Fetch LDAP groups under the base DN, or a specific set of groups (DNs delimited by '#')."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
specific-groups: { type: string, description: "Group object names/DNs to fetch, delimited by a number sign (#)" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: ad_authenticate_and_roles
|
||||||
|
name: ad-authenticate-and-roles
|
||||||
|
description: "Simple bind on the LDAP server and return the authenticated user's groups and selected attributes."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
username: { type: string, description: "Username for simple authentication" }
|
||||||
|
password: { type: string, description: "Password for simple authentication", x-soar-sensitive: true }
|
||||||
|
attribute-mail: { type: string, description: "Mail attribute to return (default mail)" }
|
||||||
|
attribute-name: { type: string, description: "Name attribute to return (default name)" }
|
||||||
|
attribute-phone: { type: string, description: "Phone attribute to return (default mobile)" }
|
||||||
|
attribute-name-pull: { type: string, description: "Return the name attribute (default true)" }
|
||||||
|
attribute-mail-pull: { type: string, description: "Return the mail attribute (default true)" }
|
||||||
|
attribute-phone-pull: { type: string, description: "Return the phone attribute (default false)" }
|
||||||
|
required: [username, password]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: ad_entries_search
|
||||||
|
name: ad-entries-search
|
||||||
|
description: "Generic LDAP search. Combine raw search_filter with cn/uid/object_class/description filters (joined with OR), choose scope and attributes, and page the results."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
search_base: { type: string, description: "Where the search starts (defaults to the instance base_dn)" }
|
||||||
|
search_filter: { type: string, description: "Raw LDAP filter; joined with the other filter args using OR" }
|
||||||
|
search_scope: { type: string, description: "BASE, LEVEL or SUBTREE (default SUBTREE)" }
|
||||||
|
attributes: { type: string, description: "none, all_user_attributes, all_operational_attributes, all, or a CSV list (default all)" }
|
||||||
|
cn: { type: string, description: "CSV list of CNs to filter by (joined with AND)" }
|
||||||
|
uid: { type: string, description: "CSV list of UIDs to filter by (joined with AND)" }
|
||||||
|
object_class: { type: string, description: "CSV list of objectClasses to filter by (joined with AND)" }
|
||||||
|
description: { type: string, description: "CSV list of descriptions to filter by (joined with AND)" }
|
||||||
|
page: { type: number, description: "Page number to return" }
|
||||||
|
page_size: { type: number, description: "Entries per page (max 2000, default 50)" }
|
||||||
|
limit: { type: number, description: "Maximum entries to return when not paging (default 50)" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
import json, os, sys
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ssl
|
||||||
|
from ldap3 import (
|
||||||
|
ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, AUTO_BIND_NO_TLS,
|
||||||
|
AUTO_BIND_TLS_BEFORE_BIND, BASE, SUBTREE, Connection, Server, Tls,
|
||||||
|
)
|
||||||
|
from ldap3.core.exceptions import (
|
||||||
|
LDAPBindError, LDAPException, LDAPInvalidDnError, LDAPInvalidPortError,
|
||||||
|
LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError,
|
||||||
|
)
|
||||||
|
from ldap3.utils.dn import parse_dn
|
||||||
|
except ImportError as _e:
|
||||||
|
print(json.dumps({
|
||||||
|
"error": "The 'ldap3' Python library is required for the OpenLDAP integration. "
|
||||||
|
"Install it on the execution host (engine): pip install ldap3",
|
||||||
|
"detail": str(_e),
|
||||||
|
}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
MAX_PAGE_SIZE = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def arg_to_list(v, separator=","):
|
||||||
|
if not v:
|
||||||
|
return []
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(separator) if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def arg_to_bool(v):
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return v
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def arg_to_num(v):
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
return int(v)
|
||||||
|
|
||||||
|
|
||||||
|
def list_arg_to_ldap_filter(arg, prefix):
|
||||||
|
items = arg_to_list(arg)
|
||||||
|
joined = "".join(f"({prefix}={item})" for item in items)
|
||||||
|
if len(items) > 1:
|
||||||
|
return f"(&{joined})"
|
||||||
|
return joined if items else ""
|
||||||
|
|
||||||
|
|
||||||
|
def create_entries_search_filter(args):
|
||||||
|
cn = list_arg_to_ldap_filter(args.get("cn", ""), "cn")
|
||||||
|
description = list_arg_to_ldap_filter(args.get("description", ""), "description")
|
||||||
|
object_class = list_arg_to_ldap_filter(args.get("object_class", ""), "objectClass")
|
||||||
|
uid = list_arg_to_ldap_filter(args.get("uid", ""), "uid")
|
||||||
|
search_filter = args.get("search_filter", "")
|
||||||
|
if not any([cn, description, object_class, uid, search_filter]):
|
||||||
|
return "(objectClass=*)"
|
||||||
|
return f"(|{cn}{description}{object_class}{uid}{search_filter})"
|
||||||
|
|
||||||
|
|
||||||
|
def get_search_attributes(attributes):
|
||||||
|
if attributes == "all":
|
||||||
|
return [ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES]
|
||||||
|
return {
|
||||||
|
"none": None,
|
||||||
|
"all_user_attributes": ALL_ATTRIBUTES,
|
||||||
|
"all_operational_attributes": ALL_OPERATIONAL_ATTRIBUTES,
|
||||||
|
}.get(attributes, arg_to_list(attributes))
|
||||||
|
|
||||||
|
|
||||||
|
def entries_paged_search(connection, search_params, page, page_size):
|
||||||
|
if page == 1:
|
||||||
|
return connection.search(**search_params, paged_size=page_size)
|
||||||
|
results_to_skip = page_size * (page - 1)
|
||||||
|
connection.search(**search_params, paged_size=results_to_skip)
|
||||||
|
cookie = connection.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
|
||||||
|
return connection.search(**search_params, paged_size=page_size, paged_cookie=cookie)
|
||||||
|
|
||||||
|
|
||||||
|
class LdapClient:
|
||||||
|
"""LDAP authentication client supporting OpenLDAP and Active Directory."""
|
||||||
|
|
||||||
|
OPENLDAP = "OpenLDAP"
|
||||||
|
ACTIVE_DIRECTORY = "Active Directory"
|
||||||
|
AUTO = "Auto"
|
||||||
|
GROUPS_TOKEN = "primaryGroupToken"
|
||||||
|
GROUPS_MEMBER = "memberOf"
|
||||||
|
GROUPS_PRIMARY_ID = "primaryGroupID"
|
||||||
|
TIMEOUT = 120
|
||||||
|
CIPHERS_STRING = (
|
||||||
|
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:DH+AESGCM:"
|
||||||
|
"ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
|
||||||
|
)
|
||||||
|
SSL_VERSIONS = {
|
||||||
|
"None": None,
|
||||||
|
"TLS": ssl.PROTOCOL_TLS,
|
||||||
|
"TLSv1": ssl.PROTOCOL_TLSv1,
|
||||||
|
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
|
||||||
|
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
|
||||||
|
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, cfg):
|
||||||
|
self._host = cfg.get("host")
|
||||||
|
self._port = int(cfg["port"]) if cfg.get("port") else None
|
||||||
|
self._username = cfg.get("username", "")
|
||||||
|
self._password = cfg.get("password", "")
|
||||||
|
self._base_dn = (cfg.get("base_dn") or "").strip()
|
||||||
|
self._connection_type = (cfg.get("connection_type") or "none").lower()
|
||||||
|
self._ssl_version = cfg.get("ssl_version", "None")
|
||||||
|
self._fetch_groups = arg_to_bool(cfg.get("fetch_groups", True))
|
||||||
|
self._verify = not arg_to_bool(cfg.get("insecure", False))
|
||||||
|
self._ldap_server = self._initialize_ldap_server()
|
||||||
|
self._ldap_server_vendor = cfg.get("ldap_server_vendor", self.AUTO)
|
||||||
|
if self._ldap_server_vendor == self.AUTO:
|
||||||
|
self._determine_ldap_vendor_automatically()
|
||||||
|
self._page_size = int(cfg.get("page_size") or 500)
|
||||||
|
self._groups_filter_class = (cfg.get("group_filter_class") or "posixGroup").strip()
|
||||||
|
self._group_identifier_attribute = (cfg.get("group_identifier_attribute") or "gidNumber").strip()
|
||||||
|
self._member_identifier_attribute = (cfg.get("member_identifier_attribute") or "memberUid").strip()
|
||||||
|
self._user_filter_class = cfg.get("user_filter_class") or "posixAccount"
|
||||||
|
self._user_identifier_attribute = cfg.get("user_identifier_attribute") or "uid"
|
||||||
|
self._custom_attributes = cfg.get("custom_attributes") or ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GROUPS_OBJECT_CLASS(self):
|
||||||
|
return self._groups_filter_class
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GROUPS_IDENTIFIER_ATTRIBUTE(self):
|
||||||
|
return self._group_identifier_attribute
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE(self):
|
||||||
|
return self._member_identifier_attribute
|
||||||
|
|
||||||
|
@property
|
||||||
|
def USER_OBJECT_CLASS(self):
|
||||||
|
return self._user_filter_class
|
||||||
|
|
||||||
|
@property
|
||||||
|
def USER_IDENTIFIER_ATTRIBUTE(self):
|
||||||
|
return self._user_identifier_attribute
|
||||||
|
|
||||||
|
@property
|
||||||
|
def CUSTOM_ATTRIBUTE(self):
|
||||||
|
return self._custom_attributes
|
||||||
|
|
||||||
|
def _get_ssl_version(self):
|
||||||
|
return self.SSL_VERSIONS.get(self._ssl_version)
|
||||||
|
|
||||||
|
def _get_tls_object(self):
|
||||||
|
if self._verify:
|
||||||
|
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=self._get_ssl_version())
|
||||||
|
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, version=self._get_ssl_version(), ciphers=self.CIPHERS_STRING)
|
||||||
|
|
||||||
|
def _initialize_ldap_server(self):
|
||||||
|
if self._connection_type == "ssl":
|
||||||
|
return Server(host=self._host, port=self._port, use_ssl=True, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT)
|
||||||
|
if self._connection_type == "start tls":
|
||||||
|
return Server(host=self._host, port=self._port, use_ssl=False, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT)
|
||||||
|
return Server(host=self._host, port=self._port, connect_timeout=self.TIMEOUT)
|
||||||
|
|
||||||
|
def _determine_ldap_vendor_automatically(self):
|
||||||
|
try:
|
||||||
|
with Connection(self._ldap_server) as conn:
|
||||||
|
conn.search(search_base="", search_filter="(objectClass=*)", search_scope=BASE, attributes=[ALL_ATTRIBUTES])
|
||||||
|
entry = conn.entries[0]
|
||||||
|
if "objectClass" in entry and "OpenLDAProotDSE" in entry["objectClass"].value:
|
||||||
|
self._ldap_server_vendor = self.OPENLDAP
|
||||||
|
else:
|
||||||
|
self._ldap_server_vendor = self.ACTIVE_DIRECTORY
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Could not determine the LDAP vendor automatically. Select the vendor manually. Error: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ldap_group_entries(ldap_group_entries, groups_identifier_attribute):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"DN": ldap_group.get("dn"),
|
||||||
|
"Attributes": [{
|
||||||
|
"Name": LdapClient.GROUPS_TOKEN,
|
||||||
|
"Values": [str(ldap_group.get("attributes", {}).get(groups_identifier_attribute))],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
for ldap_group in ldap_group_entries
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ldap_group_entries_and_referrals(ldap_group_entries):
|
||||||
|
referrals = []
|
||||||
|
entries = []
|
||||||
|
for ldap_group in ldap_group_entries:
|
||||||
|
group_type = ldap_group.get("type")
|
||||||
|
if group_type == "searchResRef":
|
||||||
|
referrals.extend(ldap_group.get("uri") or [])
|
||||||
|
elif group_type == "searchResEntry":
|
||||||
|
entries.append({
|
||||||
|
"DN": ldap_group.get("dn"),
|
||||||
|
"Attributes": [{
|
||||||
|
"Name": LdapClient.GROUPS_TOKEN,
|
||||||
|
"Values": [str(ldap_group.get("attributes", {}).get(LdapClient.GROUPS_TOKEN))],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
return referrals, entries
|
||||||
|
|
||||||
|
def _parse_and_authenticate_ldap_group_entries_and_referrals(self, ldap_group_entries, password):
|
||||||
|
referrals = []
|
||||||
|
entries = []
|
||||||
|
for entry in ldap_group_entries:
|
||||||
|
entry_type = entry.get("type")
|
||||||
|
if entry_type == "searchResRef":
|
||||||
|
referrals.extend(entry.get("uri") or [])
|
||||||
|
elif entry_type == "searchResEntry":
|
||||||
|
entry_dn = entry.get("dn", "")
|
||||||
|
entry_attributes = entry.get("attributes", {})
|
||||||
|
relevant = []
|
||||||
|
for attr in entry_attributes:
|
||||||
|
attr_value = entry_attributes.get(attr, [])
|
||||||
|
if attr_value:
|
||||||
|
if not isinstance(attr_value, list):
|
||||||
|
attr_value = [str(attr_value)]
|
||||||
|
relevant.append({"Name": attr, "Values": attr_value})
|
||||||
|
entries.append({"DN": entry_dn, "Attributes": relevant})
|
||||||
|
self.authenticate_ldap_user(entry_dn, password)
|
||||||
|
return referrals, entries
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ldap_users_groups_entries(ldap_group_entries):
|
||||||
|
return [ldap_group.get("dn") for ldap_group in ldap_group_entries]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_entry_for_user(user_groups, user_data, mail_attribute, name_attribute, phone_attribute):
|
||||||
|
attributes = [
|
||||||
|
{"Name": LdapClient.GROUPS_MEMBER, "Values": user_groups},
|
||||||
|
{"Name": LdapClient.GROUPS_PRIMARY_ID, "Values": user_data["gid_number"]},
|
||||||
|
]
|
||||||
|
if "name" in user_data:
|
||||||
|
attributes.append({"Name": name_attribute, "Values": [user_data["name"]]})
|
||||||
|
if "email" in user_data:
|
||||||
|
attributes.append({"Name": mail_attribute, "Values": [user_data["email"]]})
|
||||||
|
if "mobile" in user_data:
|
||||||
|
attributes.append({"Name": phone_attribute, "Values": [user_data["mobile"]]})
|
||||||
|
return {"DN": user_data["dn"], "Attributes": attributes}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_valid_dn(dn, user_identifier_attribute):
|
||||||
|
try:
|
||||||
|
parsed_dn = parse_dn(dn, strip=False)
|
||||||
|
for attribute_and_value in parsed_dn:
|
||||||
|
if attribute_and_value[0].lower() == user_identifier_attribute.lower():
|
||||||
|
return True, attribute_and_value[1]
|
||||||
|
raise Exception(f"The {user_identifier_attribute} attribute was not found in the user DN: {dn}")
|
||||||
|
except LDAPInvalidDnError:
|
||||||
|
return False, dn
|
||||||
|
|
||||||
|
def _get_formatted_custom_attributes(self):
|
||||||
|
if not self.CUSTOM_ATTRIBUTE:
|
||||||
|
return ""
|
||||||
|
formatted = ""
|
||||||
|
for att in self.CUSTOM_ATTRIBUTE.split(","):
|
||||||
|
if len(att.split("=")) != 2:
|
||||||
|
raise Exception(f'User defined attributes must be of the form "attrA=valA,attrB=valB,...", but got: {self.CUSTOM_ATTRIBUTE}')
|
||||||
|
formatted += f"({att})"
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
def _get_ldap_groups_entries_and_referrals_ad(self, ldap_conn, search_filter):
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=search_filter,
|
||||||
|
attributes=[LdapClient.GROUPS_TOKEN], paged_size=self._page_size, generator=False,
|
||||||
|
)
|
||||||
|
return LdapClient._parse_ldap_group_entries_and_referrals(ldap_group_entries)
|
||||||
|
|
||||||
|
def _fetch_all_groups(self):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
if self._ldap_server_vendor == self.ACTIVE_DIRECTORY:
|
||||||
|
referrals, entries = self._get_ldap_groups_entries_and_referrals_ad(
|
||||||
|
ldap_conn=ldap_conn, search_filter="(&(objectClass=group)(objectCategory=group))")
|
||||||
|
return {"Controls": None, "Referrals": referrals, "Entries": entries}
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})",
|
||||||
|
attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size)
|
||||||
|
return {
|
||||||
|
"Controls": None,
|
||||||
|
"Referrals": ldap_conn.result.get("referrals"),
|
||||||
|
"Entries": LdapClient._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fetch_specific_groups(self, specific_groups):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
dn_list = [group.strip() for group in arg_to_list(specific_groups, separator="#")]
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
if self._ldap_server_vendor == self.ACTIVE_DIRECTORY:
|
||||||
|
dns_filter = "".join(f"(distinguishedName={dn})" for dn in dn_list)
|
||||||
|
search_filter = f"(&(objectClass=group)(objectCategory=group)(|{dns_filter}))"
|
||||||
|
referrals, entries = self._get_ldap_groups_entries_and_referrals_ad(
|
||||||
|
ldap_conn=ldap_conn, search_filter=search_filter)
|
||||||
|
return {"Controls": None, "Referrals": referrals, "Entries": entries}
|
||||||
|
parsed = []
|
||||||
|
for dn in dn_list:
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})",
|
||||||
|
attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size, search_scope=BASE)
|
||||||
|
parsed.append(self._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE))
|
||||||
|
return {"Controls": None, "Referrals": ldap_conn.result.get("referrals"), "Entries": parsed}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_ad_username(logon_name):
|
||||||
|
if "\\" in logon_name:
|
||||||
|
return logon_name.split("\\")[1]
|
||||||
|
if "@" in logon_name:
|
||||||
|
return logon_name.split("@")[0]
|
||||||
|
return logon_name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_wildcards_in_user_logon(logon_name):
|
||||||
|
for wildcard in ("*", "?"):
|
||||||
|
if wildcard in logon_name:
|
||||||
|
raise Exception(
|
||||||
|
f"Authentication failed - wildcards were detected in the user logon name "
|
||||||
|
f"(input username: '{logon_name}'). Wildcards are not permitted for user authentication.")
|
||||||
|
|
||||||
|
def _get_auto_bind_value(self):
|
||||||
|
if self._connection_type == "start tls":
|
||||||
|
return AUTO_BIND_TLS_BEFORE_BIND
|
||||||
|
return AUTO_BIND_NO_TLS
|
||||||
|
|
||||||
|
def get_ldap_groups(self, specific_group=""):
|
||||||
|
if not self._fetch_groups and not specific_group:
|
||||||
|
return {"Controls": None, "Referrals": None, "Entries": []}
|
||||||
|
return self._fetch_specific_groups(specific_group) if not self._fetch_groups else self._fetch_all_groups()
|
||||||
|
|
||||||
|
def authenticate_ldap_user(self, username, password):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
ldap_conn = Connection(server=self._ldap_server, user=username, password=password, auto_bind=auto_bind)
|
||||||
|
if ldap_conn.bound:
|
||||||
|
ldap_conn.unbind()
|
||||||
|
return "Done"
|
||||||
|
raise Exception(f"Authentication connection failed (server type: {self._ldap_server_vendor})")
|
||||||
|
|
||||||
|
def search_user_data(self, username, attributes, search_user_by_dn=False):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
if search_user_by_dn:
|
||||||
|
search_filter = f"(&(objectClass={self.USER_OBJECT_CLASS})" + self._get_formatted_custom_attributes() + ")"
|
||||||
|
ldap_conn.search(search_base=username, search_filter=search_filter, size_limit=1,
|
||||||
|
attributes=attributes, search_scope=BASE)
|
||||||
|
else:
|
||||||
|
custom_attributes = self._get_formatted_custom_attributes()
|
||||||
|
search_filter = (f"(&(objectClass={self.USER_OBJECT_CLASS})"
|
||||||
|
f"({self.USER_IDENTIFIER_ATTRIBUTE}={username}){custom_attributes})")
|
||||||
|
ldap_conn.search(search_base=self._base_dn, search_filter=search_filter, size_limit=1, attributes=attributes)
|
||||||
|
if not ldap_conn.entries:
|
||||||
|
raise Exception("LDAP user not found")
|
||||||
|
entry = ldap_conn.entries[0]
|
||||||
|
referrals = ldap_conn.result.get("referrals")
|
||||||
|
if self.GROUPS_IDENTIFIER_ATTRIBUTE not in entry or not entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value:
|
||||||
|
raise Exception(f"User's {self.GROUPS_IDENTIFIER_ATTRIBUTE} not found")
|
||||||
|
return entry, referrals
|
||||||
|
|
||||||
|
def get_user_data(self, username, pull_name, pull_mail, pull_phone,
|
||||||
|
name_attribute, mail_attribute, phone_attribute, search_user_by_dn=False):
|
||||||
|
attributes = [self.GROUPS_IDENTIFIER_ATTRIBUTE]
|
||||||
|
if pull_name:
|
||||||
|
attributes.append(name_attribute)
|
||||||
|
if pull_mail:
|
||||||
|
attributes.append(mail_attribute)
|
||||||
|
if pull_phone:
|
||||||
|
attributes.append(phone_attribute)
|
||||||
|
entry, referrals = self.search_user_data(username, attributes, search_user_by_dn)
|
||||||
|
user_data = {
|
||||||
|
"dn": entry.entry_dn,
|
||||||
|
"gid_number": [str(entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value)],
|
||||||
|
"referrals": referrals,
|
||||||
|
}
|
||||||
|
if name_attribute in entry and entry[name_attribute].value:
|
||||||
|
user_data["name"] = entry[name_attribute].value
|
||||||
|
if mail_attribute in entry and entry[mail_attribute].value:
|
||||||
|
user_data["email"] = entry[mail_attribute].value
|
||||||
|
if phone_attribute in entry and entry[phone_attribute].value:
|
||||||
|
user_data["mobile"] = entry[phone_attribute].value
|
||||||
|
return user_data
|
||||||
|
|
||||||
|
def get_user_groups(self, user_identifier):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
search_filter = (f"(&(objectClass={self.GROUPS_OBJECT_CLASS})"
|
||||||
|
f"({self.GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE}={user_identifier}))")
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=search_filter,
|
||||||
|
attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size)
|
||||||
|
return LdapClient._parse_ldap_users_groups_entries(ldap_group_entries)
|
||||||
|
|
||||||
|
def authenticate_and_roles_openldap(self, username, password, pull_name=True, pull_mail=True, pull_phone=False,
|
||||||
|
mail_attribute="mail", name_attribute="name", phone_attribute="mobile"):
|
||||||
|
search_user_by_dn, user_identifier = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE)
|
||||||
|
user_data = self.get_user_data(
|
||||||
|
username=username, search_user_by_dn=search_user_by_dn, pull_name=pull_name, pull_mail=pull_mail,
|
||||||
|
pull_phone=pull_phone, mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)
|
||||||
|
self.authenticate_ldap_user(user_data["dn"], password)
|
||||||
|
user_groups = self.get_user_groups(user_identifier)
|
||||||
|
return {
|
||||||
|
"Controls": None,
|
||||||
|
"Referrals": user_data["referrals"],
|
||||||
|
"Entries": [LdapClient._build_entry_for_user(
|
||||||
|
user_groups=user_groups, user_data=user_data,
|
||||||
|
mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)],
|
||||||
|
}
|
||||||
|
|
||||||
|
def authenticate_and_roles_active_directory(self, username, password, pull_name=True, pull_mail=True, pull_phone=False,
|
||||||
|
mail_attribute="mail", name_attribute="name", phone_attribute="mobile"):
|
||||||
|
ad_username = self._get_ad_username(username)
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
attributes = [self.GROUPS_MEMBER, self.GROUPS_PRIMARY_ID]
|
||||||
|
if pull_name:
|
||||||
|
attributes.append(name_attribute)
|
||||||
|
if pull_mail:
|
||||||
|
attributes.append(mail_attribute)
|
||||||
|
if pull_phone:
|
||||||
|
attributes.append(phone_attribute)
|
||||||
|
search_filter = f"(|(sAMAccountName={ad_username})(userPrincipalName={username}))"
|
||||||
|
ldap_conn_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=search_filter,
|
||||||
|
attributes=attributes, paged_size=self._page_size, generator=False)
|
||||||
|
referrals, entries = self._parse_and_authenticate_ldap_group_entries_and_referrals(
|
||||||
|
ldap_group_entries=ldap_conn_entries, password=password)
|
||||||
|
if not entries:
|
||||||
|
raise Exception("LDAP user not found")
|
||||||
|
return {"Controls": [], "Referrals": referrals, "Entries": entries}
|
||||||
|
|
||||||
|
def authenticate_and_roles(self, username, password, pull_name=True, pull_mail=True, pull_phone=False,
|
||||||
|
mail_attribute="mail", name_attribute="name", phone_attribute="mobile"):
|
||||||
|
self._has_wildcards_in_user_logon(username)
|
||||||
|
if self._ldap_server_vendor == self.ACTIVE_DIRECTORY:
|
||||||
|
return self.authenticate_and_roles_active_directory(
|
||||||
|
username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone,
|
||||||
|
mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)
|
||||||
|
return self.authenticate_and_roles_openldap(
|
||||||
|
username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone,
|
||||||
|
mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)
|
||||||
|
|
||||||
|
def entries_search_command(self, args):
|
||||||
|
search_params = {
|
||||||
|
"search_base": args.get("search_base") or self._base_dn,
|
||||||
|
"search_scope": args.get("search_scope") or SUBTREE,
|
||||||
|
"search_filter": create_entries_search_filter(args),
|
||||||
|
"attributes": get_search_attributes(args.get("attributes", "all")),
|
||||||
|
}
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
page = arg_to_num(args.get("page"))
|
||||||
|
if page:
|
||||||
|
page_size = int(args.get("page_size", 50))
|
||||||
|
if page_size > MAX_PAGE_SIZE:
|
||||||
|
raise Exception("The page size must be less than or equal to 2000")
|
||||||
|
else:
|
||||||
|
page = 1
|
||||||
|
page_size = int(args.get("limit", 50))
|
||||||
|
entries_paged_search(connection=ldap_conn, search_params=search_params, page=page, page_size=page_size)
|
||||||
|
outputs = [
|
||||||
|
{**json.loads(entry.entry_to_json()).get("attributes", {}), "dn": json.loads(entry.entry_to_json()).get("dn")}
|
||||||
|
for entry in ldap_conn.entries
|
||||||
|
]
|
||||||
|
return {"results": outputs, "count": len(outputs)}
|
||||||
|
|
||||||
|
def ad_authenticate(self, username, password):
|
||||||
|
self._has_wildcards_in_user_logon(username)
|
||||||
|
if self._ldap_server_vendor == self.OPENLDAP:
|
||||||
|
search_user_by_dn, _ = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE)
|
||||||
|
entry, _ = self.search_user_data(username, [self.GROUPS_IDENTIFIER_ATTRIBUTE], search_user_by_dn)
|
||||||
|
username = entry.entry_dn
|
||||||
|
return self.authenticate_ldap_user(username, password)
|
||||||
|
|
||||||
|
def test_module(self):
|
||||||
|
self._get_formatted_custom_attributes()
|
||||||
|
if self._ldap_server_vendor == self.OPENLDAP:
|
||||||
|
try:
|
||||||
|
parse_dn(self._username)
|
||||||
|
except LDAPInvalidDnError:
|
||||||
|
raise Exception("Invalid credentials input. The bind user must be a full DN.")
|
||||||
|
self.authenticate_ldap_user(username=self._username, password=self._password)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
client = LdapClient(S)
|
||||||
|
result = client.ad_authenticate(I["username"], I["password"])
|
||||||
|
out({"result": result, "vendor": client._ldap_server_vendor})
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except LDAPBindError as e:
|
||||||
|
fail("Authentication connection failed. Additional details: " + str(e))
|
||||||
|
except (LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError) as e:
|
||||||
|
msg = "Failed to connect to the LDAP server. Additional details: " + str(e)
|
||||||
|
if not arg_to_bool(S.get("insecure", False)):
|
||||||
|
msg += ' Try the "Trust any certificate" option.'
|
||||||
|
fail(msg)
|
||||||
|
except LDAPInvalidPortError:
|
||||||
|
fail("Invalid LDAP server input. The server must be of the form: ip or ldap://ip")
|
||||||
|
except LDAPException as e:
|
||||||
|
fail("LDAP error: " + str(e))
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
import json, os, sys
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ssl
|
||||||
|
from ldap3 import (
|
||||||
|
ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, AUTO_BIND_NO_TLS,
|
||||||
|
AUTO_BIND_TLS_BEFORE_BIND, BASE, SUBTREE, Connection, Server, Tls,
|
||||||
|
)
|
||||||
|
from ldap3.core.exceptions import (
|
||||||
|
LDAPBindError, LDAPException, LDAPInvalidDnError, LDAPInvalidPortError,
|
||||||
|
LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError,
|
||||||
|
)
|
||||||
|
from ldap3.utils.dn import parse_dn
|
||||||
|
except ImportError as _e:
|
||||||
|
print(json.dumps({
|
||||||
|
"error": "The 'ldap3' Python library is required for the OpenLDAP integration. "
|
||||||
|
"Install it on the execution host (engine): pip install ldap3",
|
||||||
|
"detail": str(_e),
|
||||||
|
}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
MAX_PAGE_SIZE = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def arg_to_list(v, separator=","):
|
||||||
|
if not v:
|
||||||
|
return []
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(separator) if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def arg_to_bool(v):
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return v
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def arg_to_num(v):
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
return int(v)
|
||||||
|
|
||||||
|
|
||||||
|
def list_arg_to_ldap_filter(arg, prefix):
|
||||||
|
items = arg_to_list(arg)
|
||||||
|
joined = "".join(f"({prefix}={item})" for item in items)
|
||||||
|
if len(items) > 1:
|
||||||
|
return f"(&{joined})"
|
||||||
|
return joined if items else ""
|
||||||
|
|
||||||
|
|
||||||
|
def create_entries_search_filter(args):
|
||||||
|
cn = list_arg_to_ldap_filter(args.get("cn", ""), "cn")
|
||||||
|
description = list_arg_to_ldap_filter(args.get("description", ""), "description")
|
||||||
|
object_class = list_arg_to_ldap_filter(args.get("object_class", ""), "objectClass")
|
||||||
|
uid = list_arg_to_ldap_filter(args.get("uid", ""), "uid")
|
||||||
|
search_filter = args.get("search_filter", "")
|
||||||
|
if not any([cn, description, object_class, uid, search_filter]):
|
||||||
|
return "(objectClass=*)"
|
||||||
|
return f"(|{cn}{description}{object_class}{uid}{search_filter})"
|
||||||
|
|
||||||
|
|
||||||
|
def get_search_attributes(attributes):
|
||||||
|
if attributes == "all":
|
||||||
|
return [ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES]
|
||||||
|
return {
|
||||||
|
"none": None,
|
||||||
|
"all_user_attributes": ALL_ATTRIBUTES,
|
||||||
|
"all_operational_attributes": ALL_OPERATIONAL_ATTRIBUTES,
|
||||||
|
}.get(attributes, arg_to_list(attributes))
|
||||||
|
|
||||||
|
|
||||||
|
def entries_paged_search(connection, search_params, page, page_size):
|
||||||
|
if page == 1:
|
||||||
|
return connection.search(**search_params, paged_size=page_size)
|
||||||
|
results_to_skip = page_size * (page - 1)
|
||||||
|
connection.search(**search_params, paged_size=results_to_skip)
|
||||||
|
cookie = connection.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
|
||||||
|
return connection.search(**search_params, paged_size=page_size, paged_cookie=cookie)
|
||||||
|
|
||||||
|
|
||||||
|
class LdapClient:
|
||||||
|
"""LDAP authentication client supporting OpenLDAP and Active Directory."""
|
||||||
|
|
||||||
|
OPENLDAP = "OpenLDAP"
|
||||||
|
ACTIVE_DIRECTORY = "Active Directory"
|
||||||
|
AUTO = "Auto"
|
||||||
|
GROUPS_TOKEN = "primaryGroupToken"
|
||||||
|
GROUPS_MEMBER = "memberOf"
|
||||||
|
GROUPS_PRIMARY_ID = "primaryGroupID"
|
||||||
|
TIMEOUT = 120
|
||||||
|
CIPHERS_STRING = (
|
||||||
|
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:DH+AESGCM:"
|
||||||
|
"ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
|
||||||
|
)
|
||||||
|
SSL_VERSIONS = {
|
||||||
|
"None": None,
|
||||||
|
"TLS": ssl.PROTOCOL_TLS,
|
||||||
|
"TLSv1": ssl.PROTOCOL_TLSv1,
|
||||||
|
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
|
||||||
|
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
|
||||||
|
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, cfg):
|
||||||
|
self._host = cfg.get("host")
|
||||||
|
self._port = int(cfg["port"]) if cfg.get("port") else None
|
||||||
|
self._username = cfg.get("username", "")
|
||||||
|
self._password = cfg.get("password", "")
|
||||||
|
self._base_dn = (cfg.get("base_dn") or "").strip()
|
||||||
|
self._connection_type = (cfg.get("connection_type") or "none").lower()
|
||||||
|
self._ssl_version = cfg.get("ssl_version", "None")
|
||||||
|
self._fetch_groups = arg_to_bool(cfg.get("fetch_groups", True))
|
||||||
|
self._verify = not arg_to_bool(cfg.get("insecure", False))
|
||||||
|
self._ldap_server = self._initialize_ldap_server()
|
||||||
|
self._ldap_server_vendor = cfg.get("ldap_server_vendor", self.AUTO)
|
||||||
|
if self._ldap_server_vendor == self.AUTO:
|
||||||
|
self._determine_ldap_vendor_automatically()
|
||||||
|
self._page_size = int(cfg.get("page_size") or 500)
|
||||||
|
self._groups_filter_class = (cfg.get("group_filter_class") or "posixGroup").strip()
|
||||||
|
self._group_identifier_attribute = (cfg.get("group_identifier_attribute") or "gidNumber").strip()
|
||||||
|
self._member_identifier_attribute = (cfg.get("member_identifier_attribute") or "memberUid").strip()
|
||||||
|
self._user_filter_class = cfg.get("user_filter_class") or "posixAccount"
|
||||||
|
self._user_identifier_attribute = cfg.get("user_identifier_attribute") or "uid"
|
||||||
|
self._custom_attributes = cfg.get("custom_attributes") or ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GROUPS_OBJECT_CLASS(self):
|
||||||
|
return self._groups_filter_class
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GROUPS_IDENTIFIER_ATTRIBUTE(self):
|
||||||
|
return self._group_identifier_attribute
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE(self):
|
||||||
|
return self._member_identifier_attribute
|
||||||
|
|
||||||
|
@property
|
||||||
|
def USER_OBJECT_CLASS(self):
|
||||||
|
return self._user_filter_class
|
||||||
|
|
||||||
|
@property
|
||||||
|
def USER_IDENTIFIER_ATTRIBUTE(self):
|
||||||
|
return self._user_identifier_attribute
|
||||||
|
|
||||||
|
@property
|
||||||
|
def CUSTOM_ATTRIBUTE(self):
|
||||||
|
return self._custom_attributes
|
||||||
|
|
||||||
|
def _get_ssl_version(self):
|
||||||
|
return self.SSL_VERSIONS.get(self._ssl_version)
|
||||||
|
|
||||||
|
def _get_tls_object(self):
|
||||||
|
if self._verify:
|
||||||
|
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=self._get_ssl_version())
|
||||||
|
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, version=self._get_ssl_version(), ciphers=self.CIPHERS_STRING)
|
||||||
|
|
||||||
|
def _initialize_ldap_server(self):
|
||||||
|
if self._connection_type == "ssl":
|
||||||
|
return Server(host=self._host, port=self._port, use_ssl=True, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT)
|
||||||
|
if self._connection_type == "start tls":
|
||||||
|
return Server(host=self._host, port=self._port, use_ssl=False, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT)
|
||||||
|
return Server(host=self._host, port=self._port, connect_timeout=self.TIMEOUT)
|
||||||
|
|
||||||
|
def _determine_ldap_vendor_automatically(self):
|
||||||
|
try:
|
||||||
|
with Connection(self._ldap_server) as conn:
|
||||||
|
conn.search(search_base="", search_filter="(objectClass=*)", search_scope=BASE, attributes=[ALL_ATTRIBUTES])
|
||||||
|
entry = conn.entries[0]
|
||||||
|
if "objectClass" in entry and "OpenLDAProotDSE" in entry["objectClass"].value:
|
||||||
|
self._ldap_server_vendor = self.OPENLDAP
|
||||||
|
else:
|
||||||
|
self._ldap_server_vendor = self.ACTIVE_DIRECTORY
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Could not determine the LDAP vendor automatically. Select the vendor manually. Error: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ldap_group_entries(ldap_group_entries, groups_identifier_attribute):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"DN": ldap_group.get("dn"),
|
||||||
|
"Attributes": [{
|
||||||
|
"Name": LdapClient.GROUPS_TOKEN,
|
||||||
|
"Values": [str(ldap_group.get("attributes", {}).get(groups_identifier_attribute))],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
for ldap_group in ldap_group_entries
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ldap_group_entries_and_referrals(ldap_group_entries):
|
||||||
|
referrals = []
|
||||||
|
entries = []
|
||||||
|
for ldap_group in ldap_group_entries:
|
||||||
|
group_type = ldap_group.get("type")
|
||||||
|
if group_type == "searchResRef":
|
||||||
|
referrals.extend(ldap_group.get("uri") or [])
|
||||||
|
elif group_type == "searchResEntry":
|
||||||
|
entries.append({
|
||||||
|
"DN": ldap_group.get("dn"),
|
||||||
|
"Attributes": [{
|
||||||
|
"Name": LdapClient.GROUPS_TOKEN,
|
||||||
|
"Values": [str(ldap_group.get("attributes", {}).get(LdapClient.GROUPS_TOKEN))],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
return referrals, entries
|
||||||
|
|
||||||
|
def _parse_and_authenticate_ldap_group_entries_and_referrals(self, ldap_group_entries, password):
|
||||||
|
referrals = []
|
||||||
|
entries = []
|
||||||
|
for entry in ldap_group_entries:
|
||||||
|
entry_type = entry.get("type")
|
||||||
|
if entry_type == "searchResRef":
|
||||||
|
referrals.extend(entry.get("uri") or [])
|
||||||
|
elif entry_type == "searchResEntry":
|
||||||
|
entry_dn = entry.get("dn", "")
|
||||||
|
entry_attributes = entry.get("attributes", {})
|
||||||
|
relevant = []
|
||||||
|
for attr in entry_attributes:
|
||||||
|
attr_value = entry_attributes.get(attr, [])
|
||||||
|
if attr_value:
|
||||||
|
if not isinstance(attr_value, list):
|
||||||
|
attr_value = [str(attr_value)]
|
||||||
|
relevant.append({"Name": attr, "Values": attr_value})
|
||||||
|
entries.append({"DN": entry_dn, "Attributes": relevant})
|
||||||
|
self.authenticate_ldap_user(entry_dn, password)
|
||||||
|
return referrals, entries
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ldap_users_groups_entries(ldap_group_entries):
|
||||||
|
return [ldap_group.get("dn") for ldap_group in ldap_group_entries]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_entry_for_user(user_groups, user_data, mail_attribute, name_attribute, phone_attribute):
|
||||||
|
attributes = [
|
||||||
|
{"Name": LdapClient.GROUPS_MEMBER, "Values": user_groups},
|
||||||
|
{"Name": LdapClient.GROUPS_PRIMARY_ID, "Values": user_data["gid_number"]},
|
||||||
|
]
|
||||||
|
if "name" in user_data:
|
||||||
|
attributes.append({"Name": name_attribute, "Values": [user_data["name"]]})
|
||||||
|
if "email" in user_data:
|
||||||
|
attributes.append({"Name": mail_attribute, "Values": [user_data["email"]]})
|
||||||
|
if "mobile" in user_data:
|
||||||
|
attributes.append({"Name": phone_attribute, "Values": [user_data["mobile"]]})
|
||||||
|
return {"DN": user_data["dn"], "Attributes": attributes}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_valid_dn(dn, user_identifier_attribute):
|
||||||
|
try:
|
||||||
|
parsed_dn = parse_dn(dn, strip=False)
|
||||||
|
for attribute_and_value in parsed_dn:
|
||||||
|
if attribute_and_value[0].lower() == user_identifier_attribute.lower():
|
||||||
|
return True, attribute_and_value[1]
|
||||||
|
raise Exception(f"The {user_identifier_attribute} attribute was not found in the user DN: {dn}")
|
||||||
|
except LDAPInvalidDnError:
|
||||||
|
return False, dn
|
||||||
|
|
||||||
|
def _get_formatted_custom_attributes(self):
|
||||||
|
if not self.CUSTOM_ATTRIBUTE:
|
||||||
|
return ""
|
||||||
|
formatted = ""
|
||||||
|
for att in self.CUSTOM_ATTRIBUTE.split(","):
|
||||||
|
if len(att.split("=")) != 2:
|
||||||
|
raise Exception(f'User defined attributes must be of the form "attrA=valA,attrB=valB,...", but got: {self.CUSTOM_ATTRIBUTE}')
|
||||||
|
formatted += f"({att})"
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
def _get_ldap_groups_entries_and_referrals_ad(self, ldap_conn, search_filter):
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=search_filter,
|
||||||
|
attributes=[LdapClient.GROUPS_TOKEN], paged_size=self._page_size, generator=False,
|
||||||
|
)
|
||||||
|
return LdapClient._parse_ldap_group_entries_and_referrals(ldap_group_entries)
|
||||||
|
|
||||||
|
def _fetch_all_groups(self):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
if self._ldap_server_vendor == self.ACTIVE_DIRECTORY:
|
||||||
|
referrals, entries = self._get_ldap_groups_entries_and_referrals_ad(
|
||||||
|
ldap_conn=ldap_conn, search_filter="(&(objectClass=group)(objectCategory=group))")
|
||||||
|
return {"Controls": None, "Referrals": referrals, "Entries": entries}
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})",
|
||||||
|
attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size)
|
||||||
|
return {
|
||||||
|
"Controls": None,
|
||||||
|
"Referrals": ldap_conn.result.get("referrals"),
|
||||||
|
"Entries": LdapClient._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fetch_specific_groups(self, specific_groups):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
dn_list = [group.strip() for group in arg_to_list(specific_groups, separator="#")]
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
if self._ldap_server_vendor == self.ACTIVE_DIRECTORY:
|
||||||
|
dns_filter = "".join(f"(distinguishedName={dn})" for dn in dn_list)
|
||||||
|
search_filter = f"(&(objectClass=group)(objectCategory=group)(|{dns_filter}))"
|
||||||
|
referrals, entries = self._get_ldap_groups_entries_and_referrals_ad(
|
||||||
|
ldap_conn=ldap_conn, search_filter=search_filter)
|
||||||
|
return {"Controls": None, "Referrals": referrals, "Entries": entries}
|
||||||
|
parsed = []
|
||||||
|
for dn in dn_list:
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})",
|
||||||
|
attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size, search_scope=BASE)
|
||||||
|
parsed.append(self._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE))
|
||||||
|
return {"Controls": None, "Referrals": ldap_conn.result.get("referrals"), "Entries": parsed}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_ad_username(logon_name):
|
||||||
|
if "\\" in logon_name:
|
||||||
|
return logon_name.split("\\")[1]
|
||||||
|
if "@" in logon_name:
|
||||||
|
return logon_name.split("@")[0]
|
||||||
|
return logon_name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_wildcards_in_user_logon(logon_name):
|
||||||
|
for wildcard in ("*", "?"):
|
||||||
|
if wildcard in logon_name:
|
||||||
|
raise Exception(
|
||||||
|
f"Authentication failed - wildcards were detected in the user logon name "
|
||||||
|
f"(input username: '{logon_name}'). Wildcards are not permitted for user authentication.")
|
||||||
|
|
||||||
|
def _get_auto_bind_value(self):
|
||||||
|
if self._connection_type == "start tls":
|
||||||
|
return AUTO_BIND_TLS_BEFORE_BIND
|
||||||
|
return AUTO_BIND_NO_TLS
|
||||||
|
|
||||||
|
def get_ldap_groups(self, specific_group=""):
|
||||||
|
if not self._fetch_groups and not specific_group:
|
||||||
|
return {"Controls": None, "Referrals": None, "Entries": []}
|
||||||
|
return self._fetch_specific_groups(specific_group) if not self._fetch_groups else self._fetch_all_groups()
|
||||||
|
|
||||||
|
def authenticate_ldap_user(self, username, password):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
ldap_conn = Connection(server=self._ldap_server, user=username, password=password, auto_bind=auto_bind)
|
||||||
|
if ldap_conn.bound:
|
||||||
|
ldap_conn.unbind()
|
||||||
|
return "Done"
|
||||||
|
raise Exception(f"Authentication connection failed (server type: {self._ldap_server_vendor})")
|
||||||
|
|
||||||
|
def search_user_data(self, username, attributes, search_user_by_dn=False):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
if search_user_by_dn:
|
||||||
|
search_filter = f"(&(objectClass={self.USER_OBJECT_CLASS})" + self._get_formatted_custom_attributes() + ")"
|
||||||
|
ldap_conn.search(search_base=username, search_filter=search_filter, size_limit=1,
|
||||||
|
attributes=attributes, search_scope=BASE)
|
||||||
|
else:
|
||||||
|
custom_attributes = self._get_formatted_custom_attributes()
|
||||||
|
search_filter = (f"(&(objectClass={self.USER_OBJECT_CLASS})"
|
||||||
|
f"({self.USER_IDENTIFIER_ATTRIBUTE}={username}){custom_attributes})")
|
||||||
|
ldap_conn.search(search_base=self._base_dn, search_filter=search_filter, size_limit=1, attributes=attributes)
|
||||||
|
if not ldap_conn.entries:
|
||||||
|
raise Exception("LDAP user not found")
|
||||||
|
entry = ldap_conn.entries[0]
|
||||||
|
referrals = ldap_conn.result.get("referrals")
|
||||||
|
if self.GROUPS_IDENTIFIER_ATTRIBUTE not in entry or not entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value:
|
||||||
|
raise Exception(f"User's {self.GROUPS_IDENTIFIER_ATTRIBUTE} not found")
|
||||||
|
return entry, referrals
|
||||||
|
|
||||||
|
def get_user_data(self, username, pull_name, pull_mail, pull_phone,
|
||||||
|
name_attribute, mail_attribute, phone_attribute, search_user_by_dn=False):
|
||||||
|
attributes = [self.GROUPS_IDENTIFIER_ATTRIBUTE]
|
||||||
|
if pull_name:
|
||||||
|
attributes.append(name_attribute)
|
||||||
|
if pull_mail:
|
||||||
|
attributes.append(mail_attribute)
|
||||||
|
if pull_phone:
|
||||||
|
attributes.append(phone_attribute)
|
||||||
|
entry, referrals = self.search_user_data(username, attributes, search_user_by_dn)
|
||||||
|
user_data = {
|
||||||
|
"dn": entry.entry_dn,
|
||||||
|
"gid_number": [str(entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value)],
|
||||||
|
"referrals": referrals,
|
||||||
|
}
|
||||||
|
if name_attribute in entry and entry[name_attribute].value:
|
||||||
|
user_data["name"] = entry[name_attribute].value
|
||||||
|
if mail_attribute in entry and entry[mail_attribute].value:
|
||||||
|
user_data["email"] = entry[mail_attribute].value
|
||||||
|
if phone_attribute in entry and entry[phone_attribute].value:
|
||||||
|
user_data["mobile"] = entry[phone_attribute].value
|
||||||
|
return user_data
|
||||||
|
|
||||||
|
def get_user_groups(self, user_identifier):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
search_filter = (f"(&(objectClass={self.GROUPS_OBJECT_CLASS})"
|
||||||
|
f"({self.GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE}={user_identifier}))")
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=search_filter,
|
||||||
|
attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size)
|
||||||
|
return LdapClient._parse_ldap_users_groups_entries(ldap_group_entries)
|
||||||
|
|
||||||
|
def authenticate_and_roles_openldap(self, username, password, pull_name=True, pull_mail=True, pull_phone=False,
|
||||||
|
mail_attribute="mail", name_attribute="name", phone_attribute="mobile"):
|
||||||
|
search_user_by_dn, user_identifier = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE)
|
||||||
|
user_data = self.get_user_data(
|
||||||
|
username=username, search_user_by_dn=search_user_by_dn, pull_name=pull_name, pull_mail=pull_mail,
|
||||||
|
pull_phone=pull_phone, mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)
|
||||||
|
self.authenticate_ldap_user(user_data["dn"], password)
|
||||||
|
user_groups = self.get_user_groups(user_identifier)
|
||||||
|
return {
|
||||||
|
"Controls": None,
|
||||||
|
"Referrals": user_data["referrals"],
|
||||||
|
"Entries": [LdapClient._build_entry_for_user(
|
||||||
|
user_groups=user_groups, user_data=user_data,
|
||||||
|
mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)],
|
||||||
|
}
|
||||||
|
|
||||||
|
def authenticate_and_roles_active_directory(self, username, password, pull_name=True, pull_mail=True, pull_phone=False,
|
||||||
|
mail_attribute="mail", name_attribute="name", phone_attribute="mobile"):
|
||||||
|
ad_username = self._get_ad_username(username)
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
attributes = [self.GROUPS_MEMBER, self.GROUPS_PRIMARY_ID]
|
||||||
|
if pull_name:
|
||||||
|
attributes.append(name_attribute)
|
||||||
|
if pull_mail:
|
||||||
|
attributes.append(mail_attribute)
|
||||||
|
if pull_phone:
|
||||||
|
attributes.append(phone_attribute)
|
||||||
|
search_filter = f"(|(sAMAccountName={ad_username})(userPrincipalName={username}))"
|
||||||
|
ldap_conn_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=search_filter,
|
||||||
|
attributes=attributes, paged_size=self._page_size, generator=False)
|
||||||
|
referrals, entries = self._parse_and_authenticate_ldap_group_entries_and_referrals(
|
||||||
|
ldap_group_entries=ldap_conn_entries, password=password)
|
||||||
|
if not entries:
|
||||||
|
raise Exception("LDAP user not found")
|
||||||
|
return {"Controls": [], "Referrals": referrals, "Entries": entries}
|
||||||
|
|
||||||
|
def authenticate_and_roles(self, username, password, pull_name=True, pull_mail=True, pull_phone=False,
|
||||||
|
mail_attribute="mail", name_attribute="name", phone_attribute="mobile"):
|
||||||
|
self._has_wildcards_in_user_logon(username)
|
||||||
|
if self._ldap_server_vendor == self.ACTIVE_DIRECTORY:
|
||||||
|
return self.authenticate_and_roles_active_directory(
|
||||||
|
username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone,
|
||||||
|
mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)
|
||||||
|
return self.authenticate_and_roles_openldap(
|
||||||
|
username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone,
|
||||||
|
mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)
|
||||||
|
|
||||||
|
def entries_search_command(self, args):
|
||||||
|
search_params = {
|
||||||
|
"search_base": args.get("search_base") or self._base_dn,
|
||||||
|
"search_scope": args.get("search_scope") or SUBTREE,
|
||||||
|
"search_filter": create_entries_search_filter(args),
|
||||||
|
"attributes": get_search_attributes(args.get("attributes", "all")),
|
||||||
|
}
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
page = arg_to_num(args.get("page"))
|
||||||
|
if page:
|
||||||
|
page_size = int(args.get("page_size", 50))
|
||||||
|
if page_size > MAX_PAGE_SIZE:
|
||||||
|
raise Exception("The page size must be less than or equal to 2000")
|
||||||
|
else:
|
||||||
|
page = 1
|
||||||
|
page_size = int(args.get("limit", 50))
|
||||||
|
entries_paged_search(connection=ldap_conn, search_params=search_params, page=page, page_size=page_size)
|
||||||
|
outputs = [
|
||||||
|
{**json.loads(entry.entry_to_json()).get("attributes", {}), "dn": json.loads(entry.entry_to_json()).get("dn")}
|
||||||
|
for entry in ldap_conn.entries
|
||||||
|
]
|
||||||
|
return {"results": outputs, "count": len(outputs)}
|
||||||
|
|
||||||
|
def ad_authenticate(self, username, password):
|
||||||
|
self._has_wildcards_in_user_logon(username)
|
||||||
|
if self._ldap_server_vendor == self.OPENLDAP:
|
||||||
|
search_user_by_dn, _ = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE)
|
||||||
|
entry, _ = self.search_user_data(username, [self.GROUPS_IDENTIFIER_ATTRIBUTE], search_user_by_dn)
|
||||||
|
username = entry.entry_dn
|
||||||
|
return self.authenticate_ldap_user(username, password)
|
||||||
|
|
||||||
|
def test_module(self):
|
||||||
|
self._get_formatted_custom_attributes()
|
||||||
|
if self._ldap_server_vendor == self.OPENLDAP:
|
||||||
|
try:
|
||||||
|
parse_dn(self._username)
|
||||||
|
except LDAPInvalidDnError:
|
||||||
|
raise Exception("Invalid credentials input. The bind user must be a full DN.")
|
||||||
|
self.authenticate_ldap_user(username=self._username, password=self._password)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
client = LdapClient(S)
|
||||||
|
result = client.authenticate_and_roles(
|
||||||
|
username=I["username"],
|
||||||
|
password=I["password"],
|
||||||
|
pull_name=arg_to_bool(I.get("attribute-name-pull", True)),
|
||||||
|
pull_mail=arg_to_bool(I.get("attribute-mail-pull", True)),
|
||||||
|
pull_phone=arg_to_bool(I.get("attribute-phone-pull", False)),
|
||||||
|
mail_attribute=I.get("attribute-mail") or "mail",
|
||||||
|
name_attribute=I.get("attribute-name") or "name",
|
||||||
|
phone_attribute=I.get("attribute-phone") or "mobile",
|
||||||
|
)
|
||||||
|
out(result)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except LDAPBindError as e:
|
||||||
|
fail("Authentication connection failed. Additional details: " + str(e))
|
||||||
|
except (LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError) as e:
|
||||||
|
msg = "Failed to connect to the LDAP server. Additional details: " + str(e)
|
||||||
|
if not arg_to_bool(S.get("insecure", False)):
|
||||||
|
msg += ' Try the "Trust any certificate" option.'
|
||||||
|
fail(msg)
|
||||||
|
except LDAPInvalidPortError:
|
||||||
|
fail("Invalid LDAP server input. The server must be of the form: ip or ldap://ip")
|
||||||
|
except LDAPException as e:
|
||||||
|
fail("LDAP error: " + str(e))
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
import json, os, sys
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ssl
|
||||||
|
from ldap3 import (
|
||||||
|
ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, AUTO_BIND_NO_TLS,
|
||||||
|
AUTO_BIND_TLS_BEFORE_BIND, BASE, SUBTREE, Connection, Server, Tls,
|
||||||
|
)
|
||||||
|
from ldap3.core.exceptions import (
|
||||||
|
LDAPBindError, LDAPException, LDAPInvalidDnError, LDAPInvalidPortError,
|
||||||
|
LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError,
|
||||||
|
)
|
||||||
|
from ldap3.utils.dn import parse_dn
|
||||||
|
except ImportError as _e:
|
||||||
|
print(json.dumps({
|
||||||
|
"error": "The 'ldap3' Python library is required for the OpenLDAP integration. "
|
||||||
|
"Install it on the execution host (engine): pip install ldap3",
|
||||||
|
"detail": str(_e),
|
||||||
|
}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
MAX_PAGE_SIZE = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def arg_to_list(v, separator=","):
|
||||||
|
if not v:
|
||||||
|
return []
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(separator) if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def arg_to_bool(v):
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return v
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def arg_to_num(v):
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
return int(v)
|
||||||
|
|
||||||
|
|
||||||
|
def list_arg_to_ldap_filter(arg, prefix):
|
||||||
|
items = arg_to_list(arg)
|
||||||
|
joined = "".join(f"({prefix}={item})" for item in items)
|
||||||
|
if len(items) > 1:
|
||||||
|
return f"(&{joined})"
|
||||||
|
return joined if items else ""
|
||||||
|
|
||||||
|
|
||||||
|
def create_entries_search_filter(args):
|
||||||
|
cn = list_arg_to_ldap_filter(args.get("cn", ""), "cn")
|
||||||
|
description = list_arg_to_ldap_filter(args.get("description", ""), "description")
|
||||||
|
object_class = list_arg_to_ldap_filter(args.get("object_class", ""), "objectClass")
|
||||||
|
uid = list_arg_to_ldap_filter(args.get("uid", ""), "uid")
|
||||||
|
search_filter = args.get("search_filter", "")
|
||||||
|
if not any([cn, description, object_class, uid, search_filter]):
|
||||||
|
return "(objectClass=*)"
|
||||||
|
return f"(|{cn}{description}{object_class}{uid}{search_filter})"
|
||||||
|
|
||||||
|
|
||||||
|
def get_search_attributes(attributes):
|
||||||
|
if attributes == "all":
|
||||||
|
return [ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES]
|
||||||
|
return {
|
||||||
|
"none": None,
|
||||||
|
"all_user_attributes": ALL_ATTRIBUTES,
|
||||||
|
"all_operational_attributes": ALL_OPERATIONAL_ATTRIBUTES,
|
||||||
|
}.get(attributes, arg_to_list(attributes))
|
||||||
|
|
||||||
|
|
||||||
|
def entries_paged_search(connection, search_params, page, page_size):
|
||||||
|
if page == 1:
|
||||||
|
return connection.search(**search_params, paged_size=page_size)
|
||||||
|
results_to_skip = page_size * (page - 1)
|
||||||
|
connection.search(**search_params, paged_size=results_to_skip)
|
||||||
|
cookie = connection.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
|
||||||
|
return connection.search(**search_params, paged_size=page_size, paged_cookie=cookie)
|
||||||
|
|
||||||
|
|
||||||
|
class LdapClient:
|
||||||
|
"""LDAP authentication client supporting OpenLDAP and Active Directory."""
|
||||||
|
|
||||||
|
OPENLDAP = "OpenLDAP"
|
||||||
|
ACTIVE_DIRECTORY = "Active Directory"
|
||||||
|
AUTO = "Auto"
|
||||||
|
GROUPS_TOKEN = "primaryGroupToken"
|
||||||
|
GROUPS_MEMBER = "memberOf"
|
||||||
|
GROUPS_PRIMARY_ID = "primaryGroupID"
|
||||||
|
TIMEOUT = 120
|
||||||
|
CIPHERS_STRING = (
|
||||||
|
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:DH+AESGCM:"
|
||||||
|
"ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
|
||||||
|
)
|
||||||
|
SSL_VERSIONS = {
|
||||||
|
"None": None,
|
||||||
|
"TLS": ssl.PROTOCOL_TLS,
|
||||||
|
"TLSv1": ssl.PROTOCOL_TLSv1,
|
||||||
|
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
|
||||||
|
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
|
||||||
|
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, cfg):
|
||||||
|
self._host = cfg.get("host")
|
||||||
|
self._port = int(cfg["port"]) if cfg.get("port") else None
|
||||||
|
self._username = cfg.get("username", "")
|
||||||
|
self._password = cfg.get("password", "")
|
||||||
|
self._base_dn = (cfg.get("base_dn") or "").strip()
|
||||||
|
self._connection_type = (cfg.get("connection_type") or "none").lower()
|
||||||
|
self._ssl_version = cfg.get("ssl_version", "None")
|
||||||
|
self._fetch_groups = arg_to_bool(cfg.get("fetch_groups", True))
|
||||||
|
self._verify = not arg_to_bool(cfg.get("insecure", False))
|
||||||
|
self._ldap_server = self._initialize_ldap_server()
|
||||||
|
self._ldap_server_vendor = cfg.get("ldap_server_vendor", self.AUTO)
|
||||||
|
if self._ldap_server_vendor == self.AUTO:
|
||||||
|
self._determine_ldap_vendor_automatically()
|
||||||
|
self._page_size = int(cfg.get("page_size") or 500)
|
||||||
|
self._groups_filter_class = (cfg.get("group_filter_class") or "posixGroup").strip()
|
||||||
|
self._group_identifier_attribute = (cfg.get("group_identifier_attribute") or "gidNumber").strip()
|
||||||
|
self._member_identifier_attribute = (cfg.get("member_identifier_attribute") or "memberUid").strip()
|
||||||
|
self._user_filter_class = cfg.get("user_filter_class") or "posixAccount"
|
||||||
|
self._user_identifier_attribute = cfg.get("user_identifier_attribute") or "uid"
|
||||||
|
self._custom_attributes = cfg.get("custom_attributes") or ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GROUPS_OBJECT_CLASS(self):
|
||||||
|
return self._groups_filter_class
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GROUPS_IDENTIFIER_ATTRIBUTE(self):
|
||||||
|
return self._group_identifier_attribute
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE(self):
|
||||||
|
return self._member_identifier_attribute
|
||||||
|
|
||||||
|
@property
|
||||||
|
def USER_OBJECT_CLASS(self):
|
||||||
|
return self._user_filter_class
|
||||||
|
|
||||||
|
@property
|
||||||
|
def USER_IDENTIFIER_ATTRIBUTE(self):
|
||||||
|
return self._user_identifier_attribute
|
||||||
|
|
||||||
|
@property
|
||||||
|
def CUSTOM_ATTRIBUTE(self):
|
||||||
|
return self._custom_attributes
|
||||||
|
|
||||||
|
def _get_ssl_version(self):
|
||||||
|
return self.SSL_VERSIONS.get(self._ssl_version)
|
||||||
|
|
||||||
|
def _get_tls_object(self):
|
||||||
|
if self._verify:
|
||||||
|
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=self._get_ssl_version())
|
||||||
|
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, version=self._get_ssl_version(), ciphers=self.CIPHERS_STRING)
|
||||||
|
|
||||||
|
def _initialize_ldap_server(self):
|
||||||
|
if self._connection_type == "ssl":
|
||||||
|
return Server(host=self._host, port=self._port, use_ssl=True, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT)
|
||||||
|
if self._connection_type == "start tls":
|
||||||
|
return Server(host=self._host, port=self._port, use_ssl=False, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT)
|
||||||
|
return Server(host=self._host, port=self._port, connect_timeout=self.TIMEOUT)
|
||||||
|
|
||||||
|
def _determine_ldap_vendor_automatically(self):
|
||||||
|
try:
|
||||||
|
with Connection(self._ldap_server) as conn:
|
||||||
|
conn.search(search_base="", search_filter="(objectClass=*)", search_scope=BASE, attributes=[ALL_ATTRIBUTES])
|
||||||
|
entry = conn.entries[0]
|
||||||
|
if "objectClass" in entry and "OpenLDAProotDSE" in entry["objectClass"].value:
|
||||||
|
self._ldap_server_vendor = self.OPENLDAP
|
||||||
|
else:
|
||||||
|
self._ldap_server_vendor = self.ACTIVE_DIRECTORY
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Could not determine the LDAP vendor automatically. Select the vendor manually. Error: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ldap_group_entries(ldap_group_entries, groups_identifier_attribute):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"DN": ldap_group.get("dn"),
|
||||||
|
"Attributes": [{
|
||||||
|
"Name": LdapClient.GROUPS_TOKEN,
|
||||||
|
"Values": [str(ldap_group.get("attributes", {}).get(groups_identifier_attribute))],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
for ldap_group in ldap_group_entries
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ldap_group_entries_and_referrals(ldap_group_entries):
|
||||||
|
referrals = []
|
||||||
|
entries = []
|
||||||
|
for ldap_group in ldap_group_entries:
|
||||||
|
group_type = ldap_group.get("type")
|
||||||
|
if group_type == "searchResRef":
|
||||||
|
referrals.extend(ldap_group.get("uri") or [])
|
||||||
|
elif group_type == "searchResEntry":
|
||||||
|
entries.append({
|
||||||
|
"DN": ldap_group.get("dn"),
|
||||||
|
"Attributes": [{
|
||||||
|
"Name": LdapClient.GROUPS_TOKEN,
|
||||||
|
"Values": [str(ldap_group.get("attributes", {}).get(LdapClient.GROUPS_TOKEN))],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
return referrals, entries
|
||||||
|
|
||||||
|
def _parse_and_authenticate_ldap_group_entries_and_referrals(self, ldap_group_entries, password):
|
||||||
|
referrals = []
|
||||||
|
entries = []
|
||||||
|
for entry in ldap_group_entries:
|
||||||
|
entry_type = entry.get("type")
|
||||||
|
if entry_type == "searchResRef":
|
||||||
|
referrals.extend(entry.get("uri") or [])
|
||||||
|
elif entry_type == "searchResEntry":
|
||||||
|
entry_dn = entry.get("dn", "")
|
||||||
|
entry_attributes = entry.get("attributes", {})
|
||||||
|
relevant = []
|
||||||
|
for attr in entry_attributes:
|
||||||
|
attr_value = entry_attributes.get(attr, [])
|
||||||
|
if attr_value:
|
||||||
|
if not isinstance(attr_value, list):
|
||||||
|
attr_value = [str(attr_value)]
|
||||||
|
relevant.append({"Name": attr, "Values": attr_value})
|
||||||
|
entries.append({"DN": entry_dn, "Attributes": relevant})
|
||||||
|
self.authenticate_ldap_user(entry_dn, password)
|
||||||
|
return referrals, entries
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ldap_users_groups_entries(ldap_group_entries):
|
||||||
|
return [ldap_group.get("dn") for ldap_group in ldap_group_entries]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_entry_for_user(user_groups, user_data, mail_attribute, name_attribute, phone_attribute):
|
||||||
|
attributes = [
|
||||||
|
{"Name": LdapClient.GROUPS_MEMBER, "Values": user_groups},
|
||||||
|
{"Name": LdapClient.GROUPS_PRIMARY_ID, "Values": user_data["gid_number"]},
|
||||||
|
]
|
||||||
|
if "name" in user_data:
|
||||||
|
attributes.append({"Name": name_attribute, "Values": [user_data["name"]]})
|
||||||
|
if "email" in user_data:
|
||||||
|
attributes.append({"Name": mail_attribute, "Values": [user_data["email"]]})
|
||||||
|
if "mobile" in user_data:
|
||||||
|
attributes.append({"Name": phone_attribute, "Values": [user_data["mobile"]]})
|
||||||
|
return {"DN": user_data["dn"], "Attributes": attributes}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_valid_dn(dn, user_identifier_attribute):
|
||||||
|
try:
|
||||||
|
parsed_dn = parse_dn(dn, strip=False)
|
||||||
|
for attribute_and_value in parsed_dn:
|
||||||
|
if attribute_and_value[0].lower() == user_identifier_attribute.lower():
|
||||||
|
return True, attribute_and_value[1]
|
||||||
|
raise Exception(f"The {user_identifier_attribute} attribute was not found in the user DN: {dn}")
|
||||||
|
except LDAPInvalidDnError:
|
||||||
|
return False, dn
|
||||||
|
|
||||||
|
def _get_formatted_custom_attributes(self):
|
||||||
|
if not self.CUSTOM_ATTRIBUTE:
|
||||||
|
return ""
|
||||||
|
formatted = ""
|
||||||
|
for att in self.CUSTOM_ATTRIBUTE.split(","):
|
||||||
|
if len(att.split("=")) != 2:
|
||||||
|
raise Exception(f'User defined attributes must be of the form "attrA=valA,attrB=valB,...", but got: {self.CUSTOM_ATTRIBUTE}')
|
||||||
|
formatted += f"({att})"
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
def _get_ldap_groups_entries_and_referrals_ad(self, ldap_conn, search_filter):
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=search_filter,
|
||||||
|
attributes=[LdapClient.GROUPS_TOKEN], paged_size=self._page_size, generator=False,
|
||||||
|
)
|
||||||
|
return LdapClient._parse_ldap_group_entries_and_referrals(ldap_group_entries)
|
||||||
|
|
||||||
|
def _fetch_all_groups(self):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
if self._ldap_server_vendor == self.ACTIVE_DIRECTORY:
|
||||||
|
referrals, entries = self._get_ldap_groups_entries_and_referrals_ad(
|
||||||
|
ldap_conn=ldap_conn, search_filter="(&(objectClass=group)(objectCategory=group))")
|
||||||
|
return {"Controls": None, "Referrals": referrals, "Entries": entries}
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})",
|
||||||
|
attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size)
|
||||||
|
return {
|
||||||
|
"Controls": None,
|
||||||
|
"Referrals": ldap_conn.result.get("referrals"),
|
||||||
|
"Entries": LdapClient._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fetch_specific_groups(self, specific_groups):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
dn_list = [group.strip() for group in arg_to_list(specific_groups, separator="#")]
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
if self._ldap_server_vendor == self.ACTIVE_DIRECTORY:
|
||||||
|
dns_filter = "".join(f"(distinguishedName={dn})" for dn in dn_list)
|
||||||
|
search_filter = f"(&(objectClass=group)(objectCategory=group)(|{dns_filter}))"
|
||||||
|
referrals, entries = self._get_ldap_groups_entries_and_referrals_ad(
|
||||||
|
ldap_conn=ldap_conn, search_filter=search_filter)
|
||||||
|
return {"Controls": None, "Referrals": referrals, "Entries": entries}
|
||||||
|
parsed = []
|
||||||
|
for dn in dn_list:
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})",
|
||||||
|
attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size, search_scope=BASE)
|
||||||
|
parsed.append(self._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE))
|
||||||
|
return {"Controls": None, "Referrals": ldap_conn.result.get("referrals"), "Entries": parsed}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_ad_username(logon_name):
|
||||||
|
if "\\" in logon_name:
|
||||||
|
return logon_name.split("\\")[1]
|
||||||
|
if "@" in logon_name:
|
||||||
|
return logon_name.split("@")[0]
|
||||||
|
return logon_name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_wildcards_in_user_logon(logon_name):
|
||||||
|
for wildcard in ("*", "?"):
|
||||||
|
if wildcard in logon_name:
|
||||||
|
raise Exception(
|
||||||
|
f"Authentication failed - wildcards were detected in the user logon name "
|
||||||
|
f"(input username: '{logon_name}'). Wildcards are not permitted for user authentication.")
|
||||||
|
|
||||||
|
def _get_auto_bind_value(self):
|
||||||
|
if self._connection_type == "start tls":
|
||||||
|
return AUTO_BIND_TLS_BEFORE_BIND
|
||||||
|
return AUTO_BIND_NO_TLS
|
||||||
|
|
||||||
|
def get_ldap_groups(self, specific_group=""):
|
||||||
|
if not self._fetch_groups and not specific_group:
|
||||||
|
return {"Controls": None, "Referrals": None, "Entries": []}
|
||||||
|
return self._fetch_specific_groups(specific_group) if not self._fetch_groups else self._fetch_all_groups()
|
||||||
|
|
||||||
|
def authenticate_ldap_user(self, username, password):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
ldap_conn = Connection(server=self._ldap_server, user=username, password=password, auto_bind=auto_bind)
|
||||||
|
if ldap_conn.bound:
|
||||||
|
ldap_conn.unbind()
|
||||||
|
return "Done"
|
||||||
|
raise Exception(f"Authentication connection failed (server type: {self._ldap_server_vendor})")
|
||||||
|
|
||||||
|
def search_user_data(self, username, attributes, search_user_by_dn=False):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
if search_user_by_dn:
|
||||||
|
search_filter = f"(&(objectClass={self.USER_OBJECT_CLASS})" + self._get_formatted_custom_attributes() + ")"
|
||||||
|
ldap_conn.search(search_base=username, search_filter=search_filter, size_limit=1,
|
||||||
|
attributes=attributes, search_scope=BASE)
|
||||||
|
else:
|
||||||
|
custom_attributes = self._get_formatted_custom_attributes()
|
||||||
|
search_filter = (f"(&(objectClass={self.USER_OBJECT_CLASS})"
|
||||||
|
f"({self.USER_IDENTIFIER_ATTRIBUTE}={username}){custom_attributes})")
|
||||||
|
ldap_conn.search(search_base=self._base_dn, search_filter=search_filter, size_limit=1, attributes=attributes)
|
||||||
|
if not ldap_conn.entries:
|
||||||
|
raise Exception("LDAP user not found")
|
||||||
|
entry = ldap_conn.entries[0]
|
||||||
|
referrals = ldap_conn.result.get("referrals")
|
||||||
|
if self.GROUPS_IDENTIFIER_ATTRIBUTE not in entry or not entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value:
|
||||||
|
raise Exception(f"User's {self.GROUPS_IDENTIFIER_ATTRIBUTE} not found")
|
||||||
|
return entry, referrals
|
||||||
|
|
||||||
|
def get_user_data(self, username, pull_name, pull_mail, pull_phone,
|
||||||
|
name_attribute, mail_attribute, phone_attribute, search_user_by_dn=False):
|
||||||
|
attributes = [self.GROUPS_IDENTIFIER_ATTRIBUTE]
|
||||||
|
if pull_name:
|
||||||
|
attributes.append(name_attribute)
|
||||||
|
if pull_mail:
|
||||||
|
attributes.append(mail_attribute)
|
||||||
|
if pull_phone:
|
||||||
|
attributes.append(phone_attribute)
|
||||||
|
entry, referrals = self.search_user_data(username, attributes, search_user_by_dn)
|
||||||
|
user_data = {
|
||||||
|
"dn": entry.entry_dn,
|
||||||
|
"gid_number": [str(entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value)],
|
||||||
|
"referrals": referrals,
|
||||||
|
}
|
||||||
|
if name_attribute in entry and entry[name_attribute].value:
|
||||||
|
user_data["name"] = entry[name_attribute].value
|
||||||
|
if mail_attribute in entry and entry[mail_attribute].value:
|
||||||
|
user_data["email"] = entry[mail_attribute].value
|
||||||
|
if phone_attribute in entry and entry[phone_attribute].value:
|
||||||
|
user_data["mobile"] = entry[phone_attribute].value
|
||||||
|
return user_data
|
||||||
|
|
||||||
|
def get_user_groups(self, user_identifier):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
search_filter = (f"(&(objectClass={self.GROUPS_OBJECT_CLASS})"
|
||||||
|
f"({self.GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE}={user_identifier}))")
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=search_filter,
|
||||||
|
attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size)
|
||||||
|
return LdapClient._parse_ldap_users_groups_entries(ldap_group_entries)
|
||||||
|
|
||||||
|
def authenticate_and_roles_openldap(self, username, password, pull_name=True, pull_mail=True, pull_phone=False,
|
||||||
|
mail_attribute="mail", name_attribute="name", phone_attribute="mobile"):
|
||||||
|
search_user_by_dn, user_identifier = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE)
|
||||||
|
user_data = self.get_user_data(
|
||||||
|
username=username, search_user_by_dn=search_user_by_dn, pull_name=pull_name, pull_mail=pull_mail,
|
||||||
|
pull_phone=pull_phone, mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)
|
||||||
|
self.authenticate_ldap_user(user_data["dn"], password)
|
||||||
|
user_groups = self.get_user_groups(user_identifier)
|
||||||
|
return {
|
||||||
|
"Controls": None,
|
||||||
|
"Referrals": user_data["referrals"],
|
||||||
|
"Entries": [LdapClient._build_entry_for_user(
|
||||||
|
user_groups=user_groups, user_data=user_data,
|
||||||
|
mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)],
|
||||||
|
}
|
||||||
|
|
||||||
|
def authenticate_and_roles_active_directory(self, username, password, pull_name=True, pull_mail=True, pull_phone=False,
|
||||||
|
mail_attribute="mail", name_attribute="name", phone_attribute="mobile"):
|
||||||
|
ad_username = self._get_ad_username(username)
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
attributes = [self.GROUPS_MEMBER, self.GROUPS_PRIMARY_ID]
|
||||||
|
if pull_name:
|
||||||
|
attributes.append(name_attribute)
|
||||||
|
if pull_mail:
|
||||||
|
attributes.append(mail_attribute)
|
||||||
|
if pull_phone:
|
||||||
|
attributes.append(phone_attribute)
|
||||||
|
search_filter = f"(|(sAMAccountName={ad_username})(userPrincipalName={username}))"
|
||||||
|
ldap_conn_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=search_filter,
|
||||||
|
attributes=attributes, paged_size=self._page_size, generator=False)
|
||||||
|
referrals, entries = self._parse_and_authenticate_ldap_group_entries_and_referrals(
|
||||||
|
ldap_group_entries=ldap_conn_entries, password=password)
|
||||||
|
if not entries:
|
||||||
|
raise Exception("LDAP user not found")
|
||||||
|
return {"Controls": [], "Referrals": referrals, "Entries": entries}
|
||||||
|
|
||||||
|
def authenticate_and_roles(self, username, password, pull_name=True, pull_mail=True, pull_phone=False,
|
||||||
|
mail_attribute="mail", name_attribute="name", phone_attribute="mobile"):
|
||||||
|
self._has_wildcards_in_user_logon(username)
|
||||||
|
if self._ldap_server_vendor == self.ACTIVE_DIRECTORY:
|
||||||
|
return self.authenticate_and_roles_active_directory(
|
||||||
|
username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone,
|
||||||
|
mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)
|
||||||
|
return self.authenticate_and_roles_openldap(
|
||||||
|
username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone,
|
||||||
|
mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)
|
||||||
|
|
||||||
|
def entries_search_command(self, args):
|
||||||
|
search_params = {
|
||||||
|
"search_base": args.get("search_base") or self._base_dn,
|
||||||
|
"search_scope": args.get("search_scope") or SUBTREE,
|
||||||
|
"search_filter": create_entries_search_filter(args),
|
||||||
|
"attributes": get_search_attributes(args.get("attributes", "all")),
|
||||||
|
}
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
page = arg_to_num(args.get("page"))
|
||||||
|
if page:
|
||||||
|
page_size = int(args.get("page_size", 50))
|
||||||
|
if page_size > MAX_PAGE_SIZE:
|
||||||
|
raise Exception("The page size must be less than or equal to 2000")
|
||||||
|
else:
|
||||||
|
page = 1
|
||||||
|
page_size = int(args.get("limit", 50))
|
||||||
|
entries_paged_search(connection=ldap_conn, search_params=search_params, page=page, page_size=page_size)
|
||||||
|
outputs = [
|
||||||
|
{**json.loads(entry.entry_to_json()).get("attributes", {}), "dn": json.loads(entry.entry_to_json()).get("dn")}
|
||||||
|
for entry in ldap_conn.entries
|
||||||
|
]
|
||||||
|
return {"results": outputs, "count": len(outputs)}
|
||||||
|
|
||||||
|
def ad_authenticate(self, username, password):
|
||||||
|
self._has_wildcards_in_user_logon(username)
|
||||||
|
if self._ldap_server_vendor == self.OPENLDAP:
|
||||||
|
search_user_by_dn, _ = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE)
|
||||||
|
entry, _ = self.search_user_data(username, [self.GROUPS_IDENTIFIER_ATTRIBUTE], search_user_by_dn)
|
||||||
|
username = entry.entry_dn
|
||||||
|
return self.authenticate_ldap_user(username, password)
|
||||||
|
|
||||||
|
def test_module(self):
|
||||||
|
self._get_formatted_custom_attributes()
|
||||||
|
if self._ldap_server_vendor == self.OPENLDAP:
|
||||||
|
try:
|
||||||
|
parse_dn(self._username)
|
||||||
|
except LDAPInvalidDnError:
|
||||||
|
raise Exception("Invalid credentials input. The bind user must be a full DN.")
|
||||||
|
self.authenticate_ldap_user(username=self._username, password=self._password)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
client = LdapClient(S)
|
||||||
|
out(client.entries_search_command(I))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except LDAPBindError as e:
|
||||||
|
fail("Authentication connection failed. Additional details: " + str(e))
|
||||||
|
except (LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError) as e:
|
||||||
|
msg = "Failed to connect to the LDAP server. Additional details: " + str(e)
|
||||||
|
if not arg_to_bool(S.get("insecure", False)):
|
||||||
|
msg += ' Try the "Trust any certificate" option.'
|
||||||
|
fail(msg)
|
||||||
|
except LDAPInvalidPortError:
|
||||||
|
fail("Invalid LDAP server input. The server must be of the form: ip or ldap://ip")
|
||||||
|
except LDAPException as e:
|
||||||
|
fail("LDAP error: " + str(e))
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
import json, os, sys
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ssl
|
||||||
|
from ldap3 import (
|
||||||
|
ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, AUTO_BIND_NO_TLS,
|
||||||
|
AUTO_BIND_TLS_BEFORE_BIND, BASE, SUBTREE, Connection, Server, Tls,
|
||||||
|
)
|
||||||
|
from ldap3.core.exceptions import (
|
||||||
|
LDAPBindError, LDAPException, LDAPInvalidDnError, LDAPInvalidPortError,
|
||||||
|
LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError,
|
||||||
|
)
|
||||||
|
from ldap3.utils.dn import parse_dn
|
||||||
|
except ImportError as _e:
|
||||||
|
print(json.dumps({
|
||||||
|
"error": "The 'ldap3' Python library is required for the OpenLDAP integration. "
|
||||||
|
"Install it on the execution host (engine): pip install ldap3",
|
||||||
|
"detail": str(_e),
|
||||||
|
}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
MAX_PAGE_SIZE = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def arg_to_list(v, separator=","):
|
||||||
|
if not v:
|
||||||
|
return []
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(separator) if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def arg_to_bool(v):
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return v
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def arg_to_num(v):
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
return int(v)
|
||||||
|
|
||||||
|
|
||||||
|
def list_arg_to_ldap_filter(arg, prefix):
|
||||||
|
items = arg_to_list(arg)
|
||||||
|
joined = "".join(f"({prefix}={item})" for item in items)
|
||||||
|
if len(items) > 1:
|
||||||
|
return f"(&{joined})"
|
||||||
|
return joined if items else ""
|
||||||
|
|
||||||
|
|
||||||
|
def create_entries_search_filter(args):
|
||||||
|
cn = list_arg_to_ldap_filter(args.get("cn", ""), "cn")
|
||||||
|
description = list_arg_to_ldap_filter(args.get("description", ""), "description")
|
||||||
|
object_class = list_arg_to_ldap_filter(args.get("object_class", ""), "objectClass")
|
||||||
|
uid = list_arg_to_ldap_filter(args.get("uid", ""), "uid")
|
||||||
|
search_filter = args.get("search_filter", "")
|
||||||
|
if not any([cn, description, object_class, uid, search_filter]):
|
||||||
|
return "(objectClass=*)"
|
||||||
|
return f"(|{cn}{description}{object_class}{uid}{search_filter})"
|
||||||
|
|
||||||
|
|
||||||
|
def get_search_attributes(attributes):
|
||||||
|
if attributes == "all":
|
||||||
|
return [ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES]
|
||||||
|
return {
|
||||||
|
"none": None,
|
||||||
|
"all_user_attributes": ALL_ATTRIBUTES,
|
||||||
|
"all_operational_attributes": ALL_OPERATIONAL_ATTRIBUTES,
|
||||||
|
}.get(attributes, arg_to_list(attributes))
|
||||||
|
|
||||||
|
|
||||||
|
def entries_paged_search(connection, search_params, page, page_size):
|
||||||
|
if page == 1:
|
||||||
|
return connection.search(**search_params, paged_size=page_size)
|
||||||
|
results_to_skip = page_size * (page - 1)
|
||||||
|
connection.search(**search_params, paged_size=results_to_skip)
|
||||||
|
cookie = connection.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
|
||||||
|
return connection.search(**search_params, paged_size=page_size, paged_cookie=cookie)
|
||||||
|
|
||||||
|
|
||||||
|
class LdapClient:
|
||||||
|
"""LDAP authentication client supporting OpenLDAP and Active Directory."""
|
||||||
|
|
||||||
|
OPENLDAP = "OpenLDAP"
|
||||||
|
ACTIVE_DIRECTORY = "Active Directory"
|
||||||
|
AUTO = "Auto"
|
||||||
|
GROUPS_TOKEN = "primaryGroupToken"
|
||||||
|
GROUPS_MEMBER = "memberOf"
|
||||||
|
GROUPS_PRIMARY_ID = "primaryGroupID"
|
||||||
|
TIMEOUT = 120
|
||||||
|
CIPHERS_STRING = (
|
||||||
|
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:DH+AESGCM:"
|
||||||
|
"ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
|
||||||
|
)
|
||||||
|
SSL_VERSIONS = {
|
||||||
|
"None": None,
|
||||||
|
"TLS": ssl.PROTOCOL_TLS,
|
||||||
|
"TLSv1": ssl.PROTOCOL_TLSv1,
|
||||||
|
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
|
||||||
|
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
|
||||||
|
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, cfg):
|
||||||
|
self._host = cfg.get("host")
|
||||||
|
self._port = int(cfg["port"]) if cfg.get("port") else None
|
||||||
|
self._username = cfg.get("username", "")
|
||||||
|
self._password = cfg.get("password", "")
|
||||||
|
self._base_dn = (cfg.get("base_dn") or "").strip()
|
||||||
|
self._connection_type = (cfg.get("connection_type") or "none").lower()
|
||||||
|
self._ssl_version = cfg.get("ssl_version", "None")
|
||||||
|
self._fetch_groups = arg_to_bool(cfg.get("fetch_groups", True))
|
||||||
|
self._verify = not arg_to_bool(cfg.get("insecure", False))
|
||||||
|
self._ldap_server = self._initialize_ldap_server()
|
||||||
|
self._ldap_server_vendor = cfg.get("ldap_server_vendor", self.AUTO)
|
||||||
|
if self._ldap_server_vendor == self.AUTO:
|
||||||
|
self._determine_ldap_vendor_automatically()
|
||||||
|
self._page_size = int(cfg.get("page_size") or 500)
|
||||||
|
self._groups_filter_class = (cfg.get("group_filter_class") or "posixGroup").strip()
|
||||||
|
self._group_identifier_attribute = (cfg.get("group_identifier_attribute") or "gidNumber").strip()
|
||||||
|
self._member_identifier_attribute = (cfg.get("member_identifier_attribute") or "memberUid").strip()
|
||||||
|
self._user_filter_class = cfg.get("user_filter_class") or "posixAccount"
|
||||||
|
self._user_identifier_attribute = cfg.get("user_identifier_attribute") or "uid"
|
||||||
|
self._custom_attributes = cfg.get("custom_attributes") or ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GROUPS_OBJECT_CLASS(self):
|
||||||
|
return self._groups_filter_class
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GROUPS_IDENTIFIER_ATTRIBUTE(self):
|
||||||
|
return self._group_identifier_attribute
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE(self):
|
||||||
|
return self._member_identifier_attribute
|
||||||
|
|
||||||
|
@property
|
||||||
|
def USER_OBJECT_CLASS(self):
|
||||||
|
return self._user_filter_class
|
||||||
|
|
||||||
|
@property
|
||||||
|
def USER_IDENTIFIER_ATTRIBUTE(self):
|
||||||
|
return self._user_identifier_attribute
|
||||||
|
|
||||||
|
@property
|
||||||
|
def CUSTOM_ATTRIBUTE(self):
|
||||||
|
return self._custom_attributes
|
||||||
|
|
||||||
|
def _get_ssl_version(self):
|
||||||
|
return self.SSL_VERSIONS.get(self._ssl_version)
|
||||||
|
|
||||||
|
def _get_tls_object(self):
|
||||||
|
if self._verify:
|
||||||
|
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=self._get_ssl_version())
|
||||||
|
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, version=self._get_ssl_version(), ciphers=self.CIPHERS_STRING)
|
||||||
|
|
||||||
|
def _initialize_ldap_server(self):
|
||||||
|
if self._connection_type == "ssl":
|
||||||
|
return Server(host=self._host, port=self._port, use_ssl=True, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT)
|
||||||
|
if self._connection_type == "start tls":
|
||||||
|
return Server(host=self._host, port=self._port, use_ssl=False, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT)
|
||||||
|
return Server(host=self._host, port=self._port, connect_timeout=self.TIMEOUT)
|
||||||
|
|
||||||
|
def _determine_ldap_vendor_automatically(self):
|
||||||
|
try:
|
||||||
|
with Connection(self._ldap_server) as conn:
|
||||||
|
conn.search(search_base="", search_filter="(objectClass=*)", search_scope=BASE, attributes=[ALL_ATTRIBUTES])
|
||||||
|
entry = conn.entries[0]
|
||||||
|
if "objectClass" in entry and "OpenLDAProotDSE" in entry["objectClass"].value:
|
||||||
|
self._ldap_server_vendor = self.OPENLDAP
|
||||||
|
else:
|
||||||
|
self._ldap_server_vendor = self.ACTIVE_DIRECTORY
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Could not determine the LDAP vendor automatically. Select the vendor manually. Error: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ldap_group_entries(ldap_group_entries, groups_identifier_attribute):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"DN": ldap_group.get("dn"),
|
||||||
|
"Attributes": [{
|
||||||
|
"Name": LdapClient.GROUPS_TOKEN,
|
||||||
|
"Values": [str(ldap_group.get("attributes", {}).get(groups_identifier_attribute))],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
for ldap_group in ldap_group_entries
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ldap_group_entries_and_referrals(ldap_group_entries):
|
||||||
|
referrals = []
|
||||||
|
entries = []
|
||||||
|
for ldap_group in ldap_group_entries:
|
||||||
|
group_type = ldap_group.get("type")
|
||||||
|
if group_type == "searchResRef":
|
||||||
|
referrals.extend(ldap_group.get("uri") or [])
|
||||||
|
elif group_type == "searchResEntry":
|
||||||
|
entries.append({
|
||||||
|
"DN": ldap_group.get("dn"),
|
||||||
|
"Attributes": [{
|
||||||
|
"Name": LdapClient.GROUPS_TOKEN,
|
||||||
|
"Values": [str(ldap_group.get("attributes", {}).get(LdapClient.GROUPS_TOKEN))],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
return referrals, entries
|
||||||
|
|
||||||
|
def _parse_and_authenticate_ldap_group_entries_and_referrals(self, ldap_group_entries, password):
|
||||||
|
referrals = []
|
||||||
|
entries = []
|
||||||
|
for entry in ldap_group_entries:
|
||||||
|
entry_type = entry.get("type")
|
||||||
|
if entry_type == "searchResRef":
|
||||||
|
referrals.extend(entry.get("uri") or [])
|
||||||
|
elif entry_type == "searchResEntry":
|
||||||
|
entry_dn = entry.get("dn", "")
|
||||||
|
entry_attributes = entry.get("attributes", {})
|
||||||
|
relevant = []
|
||||||
|
for attr in entry_attributes:
|
||||||
|
attr_value = entry_attributes.get(attr, [])
|
||||||
|
if attr_value:
|
||||||
|
if not isinstance(attr_value, list):
|
||||||
|
attr_value = [str(attr_value)]
|
||||||
|
relevant.append({"Name": attr, "Values": attr_value})
|
||||||
|
entries.append({"DN": entry_dn, "Attributes": relevant})
|
||||||
|
self.authenticate_ldap_user(entry_dn, password)
|
||||||
|
return referrals, entries
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ldap_users_groups_entries(ldap_group_entries):
|
||||||
|
return [ldap_group.get("dn") for ldap_group in ldap_group_entries]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_entry_for_user(user_groups, user_data, mail_attribute, name_attribute, phone_attribute):
|
||||||
|
attributes = [
|
||||||
|
{"Name": LdapClient.GROUPS_MEMBER, "Values": user_groups},
|
||||||
|
{"Name": LdapClient.GROUPS_PRIMARY_ID, "Values": user_data["gid_number"]},
|
||||||
|
]
|
||||||
|
if "name" in user_data:
|
||||||
|
attributes.append({"Name": name_attribute, "Values": [user_data["name"]]})
|
||||||
|
if "email" in user_data:
|
||||||
|
attributes.append({"Name": mail_attribute, "Values": [user_data["email"]]})
|
||||||
|
if "mobile" in user_data:
|
||||||
|
attributes.append({"Name": phone_attribute, "Values": [user_data["mobile"]]})
|
||||||
|
return {"DN": user_data["dn"], "Attributes": attributes}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_valid_dn(dn, user_identifier_attribute):
|
||||||
|
try:
|
||||||
|
parsed_dn = parse_dn(dn, strip=False)
|
||||||
|
for attribute_and_value in parsed_dn:
|
||||||
|
if attribute_and_value[0].lower() == user_identifier_attribute.lower():
|
||||||
|
return True, attribute_and_value[1]
|
||||||
|
raise Exception(f"The {user_identifier_attribute} attribute was not found in the user DN: {dn}")
|
||||||
|
except LDAPInvalidDnError:
|
||||||
|
return False, dn
|
||||||
|
|
||||||
|
def _get_formatted_custom_attributes(self):
|
||||||
|
if not self.CUSTOM_ATTRIBUTE:
|
||||||
|
return ""
|
||||||
|
formatted = ""
|
||||||
|
for att in self.CUSTOM_ATTRIBUTE.split(","):
|
||||||
|
if len(att.split("=")) != 2:
|
||||||
|
raise Exception(f'User defined attributes must be of the form "attrA=valA,attrB=valB,...", but got: {self.CUSTOM_ATTRIBUTE}')
|
||||||
|
formatted += f"({att})"
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
def _get_ldap_groups_entries_and_referrals_ad(self, ldap_conn, search_filter):
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=search_filter,
|
||||||
|
attributes=[LdapClient.GROUPS_TOKEN], paged_size=self._page_size, generator=False,
|
||||||
|
)
|
||||||
|
return LdapClient._parse_ldap_group_entries_and_referrals(ldap_group_entries)
|
||||||
|
|
||||||
|
def _fetch_all_groups(self):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
if self._ldap_server_vendor == self.ACTIVE_DIRECTORY:
|
||||||
|
referrals, entries = self._get_ldap_groups_entries_and_referrals_ad(
|
||||||
|
ldap_conn=ldap_conn, search_filter="(&(objectClass=group)(objectCategory=group))")
|
||||||
|
return {"Controls": None, "Referrals": referrals, "Entries": entries}
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})",
|
||||||
|
attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size)
|
||||||
|
return {
|
||||||
|
"Controls": None,
|
||||||
|
"Referrals": ldap_conn.result.get("referrals"),
|
||||||
|
"Entries": LdapClient._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fetch_specific_groups(self, specific_groups):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
dn_list = [group.strip() for group in arg_to_list(specific_groups, separator="#")]
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
if self._ldap_server_vendor == self.ACTIVE_DIRECTORY:
|
||||||
|
dns_filter = "".join(f"(distinguishedName={dn})" for dn in dn_list)
|
||||||
|
search_filter = f"(&(objectClass=group)(objectCategory=group)(|{dns_filter}))"
|
||||||
|
referrals, entries = self._get_ldap_groups_entries_and_referrals_ad(
|
||||||
|
ldap_conn=ldap_conn, search_filter=search_filter)
|
||||||
|
return {"Controls": None, "Referrals": referrals, "Entries": entries}
|
||||||
|
parsed = []
|
||||||
|
for dn in dn_list:
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})",
|
||||||
|
attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size, search_scope=BASE)
|
||||||
|
parsed.append(self._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE))
|
||||||
|
return {"Controls": None, "Referrals": ldap_conn.result.get("referrals"), "Entries": parsed}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_ad_username(logon_name):
|
||||||
|
if "\\" in logon_name:
|
||||||
|
return logon_name.split("\\")[1]
|
||||||
|
if "@" in logon_name:
|
||||||
|
return logon_name.split("@")[0]
|
||||||
|
return logon_name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_wildcards_in_user_logon(logon_name):
|
||||||
|
for wildcard in ("*", "?"):
|
||||||
|
if wildcard in logon_name:
|
||||||
|
raise Exception(
|
||||||
|
f"Authentication failed - wildcards were detected in the user logon name "
|
||||||
|
f"(input username: '{logon_name}'). Wildcards are not permitted for user authentication.")
|
||||||
|
|
||||||
|
def _get_auto_bind_value(self):
|
||||||
|
if self._connection_type == "start tls":
|
||||||
|
return AUTO_BIND_TLS_BEFORE_BIND
|
||||||
|
return AUTO_BIND_NO_TLS
|
||||||
|
|
||||||
|
def get_ldap_groups(self, specific_group=""):
|
||||||
|
if not self._fetch_groups and not specific_group:
|
||||||
|
return {"Controls": None, "Referrals": None, "Entries": []}
|
||||||
|
return self._fetch_specific_groups(specific_group) if not self._fetch_groups else self._fetch_all_groups()
|
||||||
|
|
||||||
|
def authenticate_ldap_user(self, username, password):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
ldap_conn = Connection(server=self._ldap_server, user=username, password=password, auto_bind=auto_bind)
|
||||||
|
if ldap_conn.bound:
|
||||||
|
ldap_conn.unbind()
|
||||||
|
return "Done"
|
||||||
|
raise Exception(f"Authentication connection failed (server type: {self._ldap_server_vendor})")
|
||||||
|
|
||||||
|
def search_user_data(self, username, attributes, search_user_by_dn=False):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
if search_user_by_dn:
|
||||||
|
search_filter = f"(&(objectClass={self.USER_OBJECT_CLASS})" + self._get_formatted_custom_attributes() + ")"
|
||||||
|
ldap_conn.search(search_base=username, search_filter=search_filter, size_limit=1,
|
||||||
|
attributes=attributes, search_scope=BASE)
|
||||||
|
else:
|
||||||
|
custom_attributes = self._get_formatted_custom_attributes()
|
||||||
|
search_filter = (f"(&(objectClass={self.USER_OBJECT_CLASS})"
|
||||||
|
f"({self.USER_IDENTIFIER_ATTRIBUTE}={username}){custom_attributes})")
|
||||||
|
ldap_conn.search(search_base=self._base_dn, search_filter=search_filter, size_limit=1, attributes=attributes)
|
||||||
|
if not ldap_conn.entries:
|
||||||
|
raise Exception("LDAP user not found")
|
||||||
|
entry = ldap_conn.entries[0]
|
||||||
|
referrals = ldap_conn.result.get("referrals")
|
||||||
|
if self.GROUPS_IDENTIFIER_ATTRIBUTE not in entry or not entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value:
|
||||||
|
raise Exception(f"User's {self.GROUPS_IDENTIFIER_ATTRIBUTE} not found")
|
||||||
|
return entry, referrals
|
||||||
|
|
||||||
|
def get_user_data(self, username, pull_name, pull_mail, pull_phone,
|
||||||
|
name_attribute, mail_attribute, phone_attribute, search_user_by_dn=False):
|
||||||
|
attributes = [self.GROUPS_IDENTIFIER_ATTRIBUTE]
|
||||||
|
if pull_name:
|
||||||
|
attributes.append(name_attribute)
|
||||||
|
if pull_mail:
|
||||||
|
attributes.append(mail_attribute)
|
||||||
|
if pull_phone:
|
||||||
|
attributes.append(phone_attribute)
|
||||||
|
entry, referrals = self.search_user_data(username, attributes, search_user_by_dn)
|
||||||
|
user_data = {
|
||||||
|
"dn": entry.entry_dn,
|
||||||
|
"gid_number": [str(entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value)],
|
||||||
|
"referrals": referrals,
|
||||||
|
}
|
||||||
|
if name_attribute in entry and entry[name_attribute].value:
|
||||||
|
user_data["name"] = entry[name_attribute].value
|
||||||
|
if mail_attribute in entry and entry[mail_attribute].value:
|
||||||
|
user_data["email"] = entry[mail_attribute].value
|
||||||
|
if phone_attribute in entry and entry[phone_attribute].value:
|
||||||
|
user_data["mobile"] = entry[phone_attribute].value
|
||||||
|
return user_data
|
||||||
|
|
||||||
|
def get_user_groups(self, user_identifier):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
search_filter = (f"(&(objectClass={self.GROUPS_OBJECT_CLASS})"
|
||||||
|
f"({self.GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE}={user_identifier}))")
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=search_filter,
|
||||||
|
attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size)
|
||||||
|
return LdapClient._parse_ldap_users_groups_entries(ldap_group_entries)
|
||||||
|
|
||||||
|
def authenticate_and_roles_openldap(self, username, password, pull_name=True, pull_mail=True, pull_phone=False,
|
||||||
|
mail_attribute="mail", name_attribute="name", phone_attribute="mobile"):
|
||||||
|
search_user_by_dn, user_identifier = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE)
|
||||||
|
user_data = self.get_user_data(
|
||||||
|
username=username, search_user_by_dn=search_user_by_dn, pull_name=pull_name, pull_mail=pull_mail,
|
||||||
|
pull_phone=pull_phone, mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)
|
||||||
|
self.authenticate_ldap_user(user_data["dn"], password)
|
||||||
|
user_groups = self.get_user_groups(user_identifier)
|
||||||
|
return {
|
||||||
|
"Controls": None,
|
||||||
|
"Referrals": user_data["referrals"],
|
||||||
|
"Entries": [LdapClient._build_entry_for_user(
|
||||||
|
user_groups=user_groups, user_data=user_data,
|
||||||
|
mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)],
|
||||||
|
}
|
||||||
|
|
||||||
|
def authenticate_and_roles_active_directory(self, username, password, pull_name=True, pull_mail=True, pull_phone=False,
|
||||||
|
mail_attribute="mail", name_attribute="name", phone_attribute="mobile"):
|
||||||
|
ad_username = self._get_ad_username(username)
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
attributes = [self.GROUPS_MEMBER, self.GROUPS_PRIMARY_ID]
|
||||||
|
if pull_name:
|
||||||
|
attributes.append(name_attribute)
|
||||||
|
if pull_mail:
|
||||||
|
attributes.append(mail_attribute)
|
||||||
|
if pull_phone:
|
||||||
|
attributes.append(phone_attribute)
|
||||||
|
search_filter = f"(|(sAMAccountName={ad_username})(userPrincipalName={username}))"
|
||||||
|
ldap_conn_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=search_filter,
|
||||||
|
attributes=attributes, paged_size=self._page_size, generator=False)
|
||||||
|
referrals, entries = self._parse_and_authenticate_ldap_group_entries_and_referrals(
|
||||||
|
ldap_group_entries=ldap_conn_entries, password=password)
|
||||||
|
if not entries:
|
||||||
|
raise Exception("LDAP user not found")
|
||||||
|
return {"Controls": [], "Referrals": referrals, "Entries": entries}
|
||||||
|
|
||||||
|
def authenticate_and_roles(self, username, password, pull_name=True, pull_mail=True, pull_phone=False,
|
||||||
|
mail_attribute="mail", name_attribute="name", phone_attribute="mobile"):
|
||||||
|
self._has_wildcards_in_user_logon(username)
|
||||||
|
if self._ldap_server_vendor == self.ACTIVE_DIRECTORY:
|
||||||
|
return self.authenticate_and_roles_active_directory(
|
||||||
|
username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone,
|
||||||
|
mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)
|
||||||
|
return self.authenticate_and_roles_openldap(
|
||||||
|
username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone,
|
||||||
|
mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)
|
||||||
|
|
||||||
|
def entries_search_command(self, args):
|
||||||
|
search_params = {
|
||||||
|
"search_base": args.get("search_base") or self._base_dn,
|
||||||
|
"search_scope": args.get("search_scope") or SUBTREE,
|
||||||
|
"search_filter": create_entries_search_filter(args),
|
||||||
|
"attributes": get_search_attributes(args.get("attributes", "all")),
|
||||||
|
}
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
page = arg_to_num(args.get("page"))
|
||||||
|
if page:
|
||||||
|
page_size = int(args.get("page_size", 50))
|
||||||
|
if page_size > MAX_PAGE_SIZE:
|
||||||
|
raise Exception("The page size must be less than or equal to 2000")
|
||||||
|
else:
|
||||||
|
page = 1
|
||||||
|
page_size = int(args.get("limit", 50))
|
||||||
|
entries_paged_search(connection=ldap_conn, search_params=search_params, page=page, page_size=page_size)
|
||||||
|
outputs = [
|
||||||
|
{**json.loads(entry.entry_to_json()).get("attributes", {}), "dn": json.loads(entry.entry_to_json()).get("dn")}
|
||||||
|
for entry in ldap_conn.entries
|
||||||
|
]
|
||||||
|
return {"results": outputs, "count": len(outputs)}
|
||||||
|
|
||||||
|
def ad_authenticate(self, username, password):
|
||||||
|
self._has_wildcards_in_user_logon(username)
|
||||||
|
if self._ldap_server_vendor == self.OPENLDAP:
|
||||||
|
search_user_by_dn, _ = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE)
|
||||||
|
entry, _ = self.search_user_data(username, [self.GROUPS_IDENTIFIER_ATTRIBUTE], search_user_by_dn)
|
||||||
|
username = entry.entry_dn
|
||||||
|
return self.authenticate_ldap_user(username, password)
|
||||||
|
|
||||||
|
def test_module(self):
|
||||||
|
self._get_formatted_custom_attributes()
|
||||||
|
if self._ldap_server_vendor == self.OPENLDAP:
|
||||||
|
try:
|
||||||
|
parse_dn(self._username)
|
||||||
|
except LDAPInvalidDnError:
|
||||||
|
raise Exception("Invalid credentials input. The bind user must be a full DN.")
|
||||||
|
self.authenticate_ldap_user(username=self._username, password=self._password)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
client = LdapClient(S)
|
||||||
|
out(client.get_ldap_groups(I.get("specific-groups") or ""))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except LDAPBindError as e:
|
||||||
|
fail("Authentication connection failed. Additional details: " + str(e))
|
||||||
|
except (LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError) as e:
|
||||||
|
msg = "Failed to connect to the LDAP server. Additional details: " + str(e)
|
||||||
|
if not arg_to_bool(S.get("insecure", False)):
|
||||||
|
msg += ' Try the "Trust any certificate" option.'
|
||||||
|
fail(msg)
|
||||||
|
except LDAPInvalidPortError:
|
||||||
|
fail("Invalid LDAP server input. The server must be of the form: ip or ldap://ip")
|
||||||
|
except LDAPException as e:
|
||||||
|
fail("LDAP error: " + str(e))
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
import json, os, sys
|
||||||
|
|
||||||
|
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def out(value):
|
||||||
|
print(json.dumps(value, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, **extra):
|
||||||
|
payload = {"error": message}
|
||||||
|
payload.update(extra)
|
||||||
|
print(json.dumps(payload, default=str))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ssl
|
||||||
|
from ldap3 import (
|
||||||
|
ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, AUTO_BIND_NO_TLS,
|
||||||
|
AUTO_BIND_TLS_BEFORE_BIND, BASE, SUBTREE, Connection, Server, Tls,
|
||||||
|
)
|
||||||
|
from ldap3.core.exceptions import (
|
||||||
|
LDAPBindError, LDAPException, LDAPInvalidDnError, LDAPInvalidPortError,
|
||||||
|
LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError,
|
||||||
|
)
|
||||||
|
from ldap3.utils.dn import parse_dn
|
||||||
|
except ImportError as _e:
|
||||||
|
print(json.dumps({
|
||||||
|
"error": "The 'ldap3' Python library is required for the OpenLDAP integration. "
|
||||||
|
"Install it on the execution host (engine): pip install ldap3",
|
||||||
|
"detail": str(_e),
|
||||||
|
}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
MAX_PAGE_SIZE = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def arg_to_list(v, separator=","):
|
||||||
|
if not v:
|
||||||
|
return []
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return [x.strip() for x in str(v).split(separator) if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def arg_to_bool(v):
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return v
|
||||||
|
return str(v).lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def arg_to_num(v):
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
return int(v)
|
||||||
|
|
||||||
|
|
||||||
|
def list_arg_to_ldap_filter(arg, prefix):
|
||||||
|
items = arg_to_list(arg)
|
||||||
|
joined = "".join(f"({prefix}={item})" for item in items)
|
||||||
|
if len(items) > 1:
|
||||||
|
return f"(&{joined})"
|
||||||
|
return joined if items else ""
|
||||||
|
|
||||||
|
|
||||||
|
def create_entries_search_filter(args):
|
||||||
|
cn = list_arg_to_ldap_filter(args.get("cn", ""), "cn")
|
||||||
|
description = list_arg_to_ldap_filter(args.get("description", ""), "description")
|
||||||
|
object_class = list_arg_to_ldap_filter(args.get("object_class", ""), "objectClass")
|
||||||
|
uid = list_arg_to_ldap_filter(args.get("uid", ""), "uid")
|
||||||
|
search_filter = args.get("search_filter", "")
|
||||||
|
if not any([cn, description, object_class, uid, search_filter]):
|
||||||
|
return "(objectClass=*)"
|
||||||
|
return f"(|{cn}{description}{object_class}{uid}{search_filter})"
|
||||||
|
|
||||||
|
|
||||||
|
def get_search_attributes(attributes):
|
||||||
|
if attributes == "all":
|
||||||
|
return [ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES]
|
||||||
|
return {
|
||||||
|
"none": None,
|
||||||
|
"all_user_attributes": ALL_ATTRIBUTES,
|
||||||
|
"all_operational_attributes": ALL_OPERATIONAL_ATTRIBUTES,
|
||||||
|
}.get(attributes, arg_to_list(attributes))
|
||||||
|
|
||||||
|
|
||||||
|
def entries_paged_search(connection, search_params, page, page_size):
|
||||||
|
if page == 1:
|
||||||
|
return connection.search(**search_params, paged_size=page_size)
|
||||||
|
results_to_skip = page_size * (page - 1)
|
||||||
|
connection.search(**search_params, paged_size=results_to_skip)
|
||||||
|
cookie = connection.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
|
||||||
|
return connection.search(**search_params, paged_size=page_size, paged_cookie=cookie)
|
||||||
|
|
||||||
|
|
||||||
|
class LdapClient:
|
||||||
|
"""LDAP authentication client supporting OpenLDAP and Active Directory."""
|
||||||
|
|
||||||
|
OPENLDAP = "OpenLDAP"
|
||||||
|
ACTIVE_DIRECTORY = "Active Directory"
|
||||||
|
AUTO = "Auto"
|
||||||
|
GROUPS_TOKEN = "primaryGroupToken"
|
||||||
|
GROUPS_MEMBER = "memberOf"
|
||||||
|
GROUPS_PRIMARY_ID = "primaryGroupID"
|
||||||
|
TIMEOUT = 120
|
||||||
|
CIPHERS_STRING = (
|
||||||
|
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:DH+AESGCM:"
|
||||||
|
"ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
|
||||||
|
)
|
||||||
|
SSL_VERSIONS = {
|
||||||
|
"None": None,
|
||||||
|
"TLS": ssl.PROTOCOL_TLS,
|
||||||
|
"TLSv1": ssl.PROTOCOL_TLSv1,
|
||||||
|
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
|
||||||
|
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
|
||||||
|
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, cfg):
|
||||||
|
self._host = cfg.get("host")
|
||||||
|
self._port = int(cfg["port"]) if cfg.get("port") else None
|
||||||
|
self._username = cfg.get("username", "")
|
||||||
|
self._password = cfg.get("password", "")
|
||||||
|
self._base_dn = (cfg.get("base_dn") or "").strip()
|
||||||
|
self._connection_type = (cfg.get("connection_type") or "none").lower()
|
||||||
|
self._ssl_version = cfg.get("ssl_version", "None")
|
||||||
|
self._fetch_groups = arg_to_bool(cfg.get("fetch_groups", True))
|
||||||
|
self._verify = not arg_to_bool(cfg.get("insecure", False))
|
||||||
|
self._ldap_server = self._initialize_ldap_server()
|
||||||
|
self._ldap_server_vendor = cfg.get("ldap_server_vendor", self.AUTO)
|
||||||
|
if self._ldap_server_vendor == self.AUTO:
|
||||||
|
self._determine_ldap_vendor_automatically()
|
||||||
|
self._page_size = int(cfg.get("page_size") or 500)
|
||||||
|
self._groups_filter_class = (cfg.get("group_filter_class") or "posixGroup").strip()
|
||||||
|
self._group_identifier_attribute = (cfg.get("group_identifier_attribute") or "gidNumber").strip()
|
||||||
|
self._member_identifier_attribute = (cfg.get("member_identifier_attribute") or "memberUid").strip()
|
||||||
|
self._user_filter_class = cfg.get("user_filter_class") or "posixAccount"
|
||||||
|
self._user_identifier_attribute = cfg.get("user_identifier_attribute") or "uid"
|
||||||
|
self._custom_attributes = cfg.get("custom_attributes") or ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GROUPS_OBJECT_CLASS(self):
|
||||||
|
return self._groups_filter_class
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GROUPS_IDENTIFIER_ATTRIBUTE(self):
|
||||||
|
return self._group_identifier_attribute
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE(self):
|
||||||
|
return self._member_identifier_attribute
|
||||||
|
|
||||||
|
@property
|
||||||
|
def USER_OBJECT_CLASS(self):
|
||||||
|
return self._user_filter_class
|
||||||
|
|
||||||
|
@property
|
||||||
|
def USER_IDENTIFIER_ATTRIBUTE(self):
|
||||||
|
return self._user_identifier_attribute
|
||||||
|
|
||||||
|
@property
|
||||||
|
def CUSTOM_ATTRIBUTE(self):
|
||||||
|
return self._custom_attributes
|
||||||
|
|
||||||
|
def _get_ssl_version(self):
|
||||||
|
return self.SSL_VERSIONS.get(self._ssl_version)
|
||||||
|
|
||||||
|
def _get_tls_object(self):
|
||||||
|
if self._verify:
|
||||||
|
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=self._get_ssl_version())
|
||||||
|
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, version=self._get_ssl_version(), ciphers=self.CIPHERS_STRING)
|
||||||
|
|
||||||
|
def _initialize_ldap_server(self):
|
||||||
|
if self._connection_type == "ssl":
|
||||||
|
return Server(host=self._host, port=self._port, use_ssl=True, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT)
|
||||||
|
if self._connection_type == "start tls":
|
||||||
|
return Server(host=self._host, port=self._port, use_ssl=False, tls=self._get_tls_object(), connect_timeout=self.TIMEOUT)
|
||||||
|
return Server(host=self._host, port=self._port, connect_timeout=self.TIMEOUT)
|
||||||
|
|
||||||
|
def _determine_ldap_vendor_automatically(self):
|
||||||
|
try:
|
||||||
|
with Connection(self._ldap_server) as conn:
|
||||||
|
conn.search(search_base="", search_filter="(objectClass=*)", search_scope=BASE, attributes=[ALL_ATTRIBUTES])
|
||||||
|
entry = conn.entries[0]
|
||||||
|
if "objectClass" in entry and "OpenLDAProotDSE" in entry["objectClass"].value:
|
||||||
|
self._ldap_server_vendor = self.OPENLDAP
|
||||||
|
else:
|
||||||
|
self._ldap_server_vendor = self.ACTIVE_DIRECTORY
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Could not determine the LDAP vendor automatically. Select the vendor manually. Error: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ldap_group_entries(ldap_group_entries, groups_identifier_attribute):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"DN": ldap_group.get("dn"),
|
||||||
|
"Attributes": [{
|
||||||
|
"Name": LdapClient.GROUPS_TOKEN,
|
||||||
|
"Values": [str(ldap_group.get("attributes", {}).get(groups_identifier_attribute))],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
for ldap_group in ldap_group_entries
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ldap_group_entries_and_referrals(ldap_group_entries):
|
||||||
|
referrals = []
|
||||||
|
entries = []
|
||||||
|
for ldap_group in ldap_group_entries:
|
||||||
|
group_type = ldap_group.get("type")
|
||||||
|
if group_type == "searchResRef":
|
||||||
|
referrals.extend(ldap_group.get("uri") or [])
|
||||||
|
elif group_type == "searchResEntry":
|
||||||
|
entries.append({
|
||||||
|
"DN": ldap_group.get("dn"),
|
||||||
|
"Attributes": [{
|
||||||
|
"Name": LdapClient.GROUPS_TOKEN,
|
||||||
|
"Values": [str(ldap_group.get("attributes", {}).get(LdapClient.GROUPS_TOKEN))],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
return referrals, entries
|
||||||
|
|
||||||
|
def _parse_and_authenticate_ldap_group_entries_and_referrals(self, ldap_group_entries, password):
|
||||||
|
referrals = []
|
||||||
|
entries = []
|
||||||
|
for entry in ldap_group_entries:
|
||||||
|
entry_type = entry.get("type")
|
||||||
|
if entry_type == "searchResRef":
|
||||||
|
referrals.extend(entry.get("uri") or [])
|
||||||
|
elif entry_type == "searchResEntry":
|
||||||
|
entry_dn = entry.get("dn", "")
|
||||||
|
entry_attributes = entry.get("attributes", {})
|
||||||
|
relevant = []
|
||||||
|
for attr in entry_attributes:
|
||||||
|
attr_value = entry_attributes.get(attr, [])
|
||||||
|
if attr_value:
|
||||||
|
if not isinstance(attr_value, list):
|
||||||
|
attr_value = [str(attr_value)]
|
||||||
|
relevant.append({"Name": attr, "Values": attr_value})
|
||||||
|
entries.append({"DN": entry_dn, "Attributes": relevant})
|
||||||
|
self.authenticate_ldap_user(entry_dn, password)
|
||||||
|
return referrals, entries
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ldap_users_groups_entries(ldap_group_entries):
|
||||||
|
return [ldap_group.get("dn") for ldap_group in ldap_group_entries]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_entry_for_user(user_groups, user_data, mail_attribute, name_attribute, phone_attribute):
|
||||||
|
attributes = [
|
||||||
|
{"Name": LdapClient.GROUPS_MEMBER, "Values": user_groups},
|
||||||
|
{"Name": LdapClient.GROUPS_PRIMARY_ID, "Values": user_data["gid_number"]},
|
||||||
|
]
|
||||||
|
if "name" in user_data:
|
||||||
|
attributes.append({"Name": name_attribute, "Values": [user_data["name"]]})
|
||||||
|
if "email" in user_data:
|
||||||
|
attributes.append({"Name": mail_attribute, "Values": [user_data["email"]]})
|
||||||
|
if "mobile" in user_data:
|
||||||
|
attributes.append({"Name": phone_attribute, "Values": [user_data["mobile"]]})
|
||||||
|
return {"DN": user_data["dn"], "Attributes": attributes}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_valid_dn(dn, user_identifier_attribute):
|
||||||
|
try:
|
||||||
|
parsed_dn = parse_dn(dn, strip=False)
|
||||||
|
for attribute_and_value in parsed_dn:
|
||||||
|
if attribute_and_value[0].lower() == user_identifier_attribute.lower():
|
||||||
|
return True, attribute_and_value[1]
|
||||||
|
raise Exception(f"The {user_identifier_attribute} attribute was not found in the user DN: {dn}")
|
||||||
|
except LDAPInvalidDnError:
|
||||||
|
return False, dn
|
||||||
|
|
||||||
|
def _get_formatted_custom_attributes(self):
|
||||||
|
if not self.CUSTOM_ATTRIBUTE:
|
||||||
|
return ""
|
||||||
|
formatted = ""
|
||||||
|
for att in self.CUSTOM_ATTRIBUTE.split(","):
|
||||||
|
if len(att.split("=")) != 2:
|
||||||
|
raise Exception(f'User defined attributes must be of the form "attrA=valA,attrB=valB,...", but got: {self.CUSTOM_ATTRIBUTE}')
|
||||||
|
formatted += f"({att})"
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
def _get_ldap_groups_entries_and_referrals_ad(self, ldap_conn, search_filter):
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=search_filter,
|
||||||
|
attributes=[LdapClient.GROUPS_TOKEN], paged_size=self._page_size, generator=False,
|
||||||
|
)
|
||||||
|
return LdapClient._parse_ldap_group_entries_and_referrals(ldap_group_entries)
|
||||||
|
|
||||||
|
def _fetch_all_groups(self):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
if self._ldap_server_vendor == self.ACTIVE_DIRECTORY:
|
||||||
|
referrals, entries = self._get_ldap_groups_entries_and_referrals_ad(
|
||||||
|
ldap_conn=ldap_conn, search_filter="(&(objectClass=group)(objectCategory=group))")
|
||||||
|
return {"Controls": None, "Referrals": referrals, "Entries": entries}
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})",
|
||||||
|
attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size)
|
||||||
|
return {
|
||||||
|
"Controls": None,
|
||||||
|
"Referrals": ldap_conn.result.get("referrals"),
|
||||||
|
"Entries": LdapClient._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fetch_specific_groups(self, specific_groups):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
dn_list = [group.strip() for group in arg_to_list(specific_groups, separator="#")]
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
if self._ldap_server_vendor == self.ACTIVE_DIRECTORY:
|
||||||
|
dns_filter = "".join(f"(distinguishedName={dn})" for dn in dn_list)
|
||||||
|
search_filter = f"(&(objectClass=group)(objectCategory=group)(|{dns_filter}))"
|
||||||
|
referrals, entries = self._get_ldap_groups_entries_and_referrals_ad(
|
||||||
|
ldap_conn=ldap_conn, search_filter=search_filter)
|
||||||
|
return {"Controls": None, "Referrals": referrals, "Entries": entries}
|
||||||
|
parsed = []
|
||||||
|
for dn in dn_list:
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=dn, search_filter=f"(objectClass={self.GROUPS_OBJECT_CLASS})",
|
||||||
|
attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size, search_scope=BASE)
|
||||||
|
parsed.append(self._parse_ldap_group_entries(ldap_group_entries, self.GROUPS_IDENTIFIER_ATTRIBUTE))
|
||||||
|
return {"Controls": None, "Referrals": ldap_conn.result.get("referrals"), "Entries": parsed}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_ad_username(logon_name):
|
||||||
|
if "\\" in logon_name:
|
||||||
|
return logon_name.split("\\")[1]
|
||||||
|
if "@" in logon_name:
|
||||||
|
return logon_name.split("@")[0]
|
||||||
|
return logon_name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_wildcards_in_user_logon(logon_name):
|
||||||
|
for wildcard in ("*", "?"):
|
||||||
|
if wildcard in logon_name:
|
||||||
|
raise Exception(
|
||||||
|
f"Authentication failed - wildcards were detected in the user logon name "
|
||||||
|
f"(input username: '{logon_name}'). Wildcards are not permitted for user authentication.")
|
||||||
|
|
||||||
|
def _get_auto_bind_value(self):
|
||||||
|
if self._connection_type == "start tls":
|
||||||
|
return AUTO_BIND_TLS_BEFORE_BIND
|
||||||
|
return AUTO_BIND_NO_TLS
|
||||||
|
|
||||||
|
def get_ldap_groups(self, specific_group=""):
|
||||||
|
if not self._fetch_groups and not specific_group:
|
||||||
|
return {"Controls": None, "Referrals": None, "Entries": []}
|
||||||
|
return self._fetch_specific_groups(specific_group) if not self._fetch_groups else self._fetch_all_groups()
|
||||||
|
|
||||||
|
def authenticate_ldap_user(self, username, password):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
ldap_conn = Connection(server=self._ldap_server, user=username, password=password, auto_bind=auto_bind)
|
||||||
|
if ldap_conn.bound:
|
||||||
|
ldap_conn.unbind()
|
||||||
|
return "Done"
|
||||||
|
raise Exception(f"Authentication connection failed (server type: {self._ldap_server_vendor})")
|
||||||
|
|
||||||
|
def search_user_data(self, username, attributes, search_user_by_dn=False):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
if search_user_by_dn:
|
||||||
|
search_filter = f"(&(objectClass={self.USER_OBJECT_CLASS})" + self._get_formatted_custom_attributes() + ")"
|
||||||
|
ldap_conn.search(search_base=username, search_filter=search_filter, size_limit=1,
|
||||||
|
attributes=attributes, search_scope=BASE)
|
||||||
|
else:
|
||||||
|
custom_attributes = self._get_formatted_custom_attributes()
|
||||||
|
search_filter = (f"(&(objectClass={self.USER_OBJECT_CLASS})"
|
||||||
|
f"({self.USER_IDENTIFIER_ATTRIBUTE}={username}){custom_attributes})")
|
||||||
|
ldap_conn.search(search_base=self._base_dn, search_filter=search_filter, size_limit=1, attributes=attributes)
|
||||||
|
if not ldap_conn.entries:
|
||||||
|
raise Exception("LDAP user not found")
|
||||||
|
entry = ldap_conn.entries[0]
|
||||||
|
referrals = ldap_conn.result.get("referrals")
|
||||||
|
if self.GROUPS_IDENTIFIER_ATTRIBUTE not in entry or not entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value:
|
||||||
|
raise Exception(f"User's {self.GROUPS_IDENTIFIER_ATTRIBUTE} not found")
|
||||||
|
return entry, referrals
|
||||||
|
|
||||||
|
def get_user_data(self, username, pull_name, pull_mail, pull_phone,
|
||||||
|
name_attribute, mail_attribute, phone_attribute, search_user_by_dn=False):
|
||||||
|
attributes = [self.GROUPS_IDENTIFIER_ATTRIBUTE]
|
||||||
|
if pull_name:
|
||||||
|
attributes.append(name_attribute)
|
||||||
|
if pull_mail:
|
||||||
|
attributes.append(mail_attribute)
|
||||||
|
if pull_phone:
|
||||||
|
attributes.append(phone_attribute)
|
||||||
|
entry, referrals = self.search_user_data(username, attributes, search_user_by_dn)
|
||||||
|
user_data = {
|
||||||
|
"dn": entry.entry_dn,
|
||||||
|
"gid_number": [str(entry[self.GROUPS_IDENTIFIER_ATTRIBUTE].value)],
|
||||||
|
"referrals": referrals,
|
||||||
|
}
|
||||||
|
if name_attribute in entry and entry[name_attribute].value:
|
||||||
|
user_data["name"] = entry[name_attribute].value
|
||||||
|
if mail_attribute in entry and entry[mail_attribute].value:
|
||||||
|
user_data["email"] = entry[mail_attribute].value
|
||||||
|
if phone_attribute in entry and entry[phone_attribute].value:
|
||||||
|
user_data["mobile"] = entry[phone_attribute].value
|
||||||
|
return user_data
|
||||||
|
|
||||||
|
def get_user_groups(self, user_identifier):
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
search_filter = (f"(&(objectClass={self.GROUPS_OBJECT_CLASS})"
|
||||||
|
f"({self.GROUPS_MEMBERSHIP_IDENTIFIER_ATTRIBUTE}={user_identifier}))")
|
||||||
|
ldap_group_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=search_filter,
|
||||||
|
attributes=[self.GROUPS_IDENTIFIER_ATTRIBUTE], paged_size=self._page_size)
|
||||||
|
return LdapClient._parse_ldap_users_groups_entries(ldap_group_entries)
|
||||||
|
|
||||||
|
def authenticate_and_roles_openldap(self, username, password, pull_name=True, pull_mail=True, pull_phone=False,
|
||||||
|
mail_attribute="mail", name_attribute="name", phone_attribute="mobile"):
|
||||||
|
search_user_by_dn, user_identifier = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE)
|
||||||
|
user_data = self.get_user_data(
|
||||||
|
username=username, search_user_by_dn=search_user_by_dn, pull_name=pull_name, pull_mail=pull_mail,
|
||||||
|
pull_phone=pull_phone, mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)
|
||||||
|
self.authenticate_ldap_user(user_data["dn"], password)
|
||||||
|
user_groups = self.get_user_groups(user_identifier)
|
||||||
|
return {
|
||||||
|
"Controls": None,
|
||||||
|
"Referrals": user_data["referrals"],
|
||||||
|
"Entries": [LdapClient._build_entry_for_user(
|
||||||
|
user_groups=user_groups, user_data=user_data,
|
||||||
|
mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)],
|
||||||
|
}
|
||||||
|
|
||||||
|
def authenticate_and_roles_active_directory(self, username, password, pull_name=True, pull_mail=True, pull_phone=False,
|
||||||
|
mail_attribute="mail", name_attribute="name", phone_attribute="mobile"):
|
||||||
|
ad_username = self._get_ad_username(username)
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
attributes = [self.GROUPS_MEMBER, self.GROUPS_PRIMARY_ID]
|
||||||
|
if pull_name:
|
||||||
|
attributes.append(name_attribute)
|
||||||
|
if pull_mail:
|
||||||
|
attributes.append(mail_attribute)
|
||||||
|
if pull_phone:
|
||||||
|
attributes.append(phone_attribute)
|
||||||
|
search_filter = f"(|(sAMAccountName={ad_username})(userPrincipalName={username}))"
|
||||||
|
ldap_conn_entries = ldap_conn.extend.standard.paged_search(
|
||||||
|
search_base=self._base_dn, search_filter=search_filter,
|
||||||
|
attributes=attributes, paged_size=self._page_size, generator=False)
|
||||||
|
referrals, entries = self._parse_and_authenticate_ldap_group_entries_and_referrals(
|
||||||
|
ldap_group_entries=ldap_conn_entries, password=password)
|
||||||
|
if not entries:
|
||||||
|
raise Exception("LDAP user not found")
|
||||||
|
return {"Controls": [], "Referrals": referrals, "Entries": entries}
|
||||||
|
|
||||||
|
def authenticate_and_roles(self, username, password, pull_name=True, pull_mail=True, pull_phone=False,
|
||||||
|
mail_attribute="mail", name_attribute="name", phone_attribute="mobile"):
|
||||||
|
self._has_wildcards_in_user_logon(username)
|
||||||
|
if self._ldap_server_vendor == self.ACTIVE_DIRECTORY:
|
||||||
|
return self.authenticate_and_roles_active_directory(
|
||||||
|
username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone,
|
||||||
|
mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)
|
||||||
|
return self.authenticate_and_roles_openldap(
|
||||||
|
username=username, password=password, pull_name=pull_name, pull_mail=pull_mail, pull_phone=pull_phone,
|
||||||
|
mail_attribute=mail_attribute, name_attribute=name_attribute, phone_attribute=phone_attribute)
|
||||||
|
|
||||||
|
def entries_search_command(self, args):
|
||||||
|
search_params = {
|
||||||
|
"search_base": args.get("search_base") or self._base_dn,
|
||||||
|
"search_scope": args.get("search_scope") or SUBTREE,
|
||||||
|
"search_filter": create_entries_search_filter(args),
|
||||||
|
"attributes": get_search_attributes(args.get("attributes", "all")),
|
||||||
|
}
|
||||||
|
auto_bind = self._get_auto_bind_value()
|
||||||
|
with Connection(self._ldap_server, self._username, self._password, auto_bind=auto_bind) as ldap_conn:
|
||||||
|
page = arg_to_num(args.get("page"))
|
||||||
|
if page:
|
||||||
|
page_size = int(args.get("page_size", 50))
|
||||||
|
if page_size > MAX_PAGE_SIZE:
|
||||||
|
raise Exception("The page size must be less than or equal to 2000")
|
||||||
|
else:
|
||||||
|
page = 1
|
||||||
|
page_size = int(args.get("limit", 50))
|
||||||
|
entries_paged_search(connection=ldap_conn, search_params=search_params, page=page, page_size=page_size)
|
||||||
|
outputs = [
|
||||||
|
{**json.loads(entry.entry_to_json()).get("attributes", {}), "dn": json.loads(entry.entry_to_json()).get("dn")}
|
||||||
|
for entry in ldap_conn.entries
|
||||||
|
]
|
||||||
|
return {"results": outputs, "count": len(outputs)}
|
||||||
|
|
||||||
|
def ad_authenticate(self, username, password):
|
||||||
|
self._has_wildcards_in_user_logon(username)
|
||||||
|
if self._ldap_server_vendor == self.OPENLDAP:
|
||||||
|
search_user_by_dn, _ = LdapClient._is_valid_dn(username, self.USER_IDENTIFIER_ATTRIBUTE)
|
||||||
|
entry, _ = self.search_user_data(username, [self.GROUPS_IDENTIFIER_ATTRIBUTE], search_user_by_dn)
|
||||||
|
username = entry.entry_dn
|
||||||
|
return self.authenticate_ldap_user(username, password)
|
||||||
|
|
||||||
|
def test_module(self):
|
||||||
|
self._get_formatted_custom_attributes()
|
||||||
|
if self._ldap_server_vendor == self.OPENLDAP:
|
||||||
|
try:
|
||||||
|
parse_dn(self._username)
|
||||||
|
except LDAPInvalidDnError:
|
||||||
|
raise Exception("Invalid credentials input. The bind user must be a full DN.")
|
||||||
|
self.authenticate_ldap_user(username=self._username, password=self._password)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
client = LdapClient(S)
|
||||||
|
client.test_module()
|
||||||
|
out({"ok": True, "vendor": client._ldap_server_vendor})
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except LDAPBindError as e:
|
||||||
|
fail("Authentication connection failed. Additional details: " + str(e))
|
||||||
|
except (LDAPSocketOpenError, LDAPSocketReceiveError, LDAPStartTLSError) as e:
|
||||||
|
msg = "Failed to connect to the LDAP server. Additional details: " + str(e)
|
||||||
|
if not arg_to_bool(S.get("insecure", False)):
|
||||||
|
msg += ' Try the "Trust any certificate" option.'
|
||||||
|
fail(msg)
|
||||||
|
except LDAPInvalidPortError:
|
||||||
|
fail("Invalid LDAP server input. The server must be of the form: ip or ldap://ip")
|
||||||
|
except LDAPException as e:
|
||||||
|
fail("LDAP error: " + str(e))
|
||||||
|
except Exception as e:
|
||||||
|
fail(str(e))
|
||||||
Reference in New Issue
Block a user