From dbb740f476ee1ff3548d8ac184a7637aab18eb25 Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Fri, 10 Jul 2026 23:17:50 +0200 Subject: [PATCH] feat(google-calendar): new Google Calendar integration Calendar API v3 ACL management: create access control rules (grant a role to a user/group/domain/public) and list a calendar's ACL rules. Service-account auth with domain-wide delegation (JWT RS256), runs on a remote engine (requires PyJWT + cryptography). Co-Authored-By: Claude Fable 5 --- integrations/google-calendar/manifest.yaml | 62 ++++++++++++++ .../google-calendar/scripts/acl_add.py | 84 +++++++++++++++++++ .../google-calendar/scripts/acl_list.py | 83 ++++++++++++++++++ .../scripts/test_connection.py | 76 +++++++++++++++++ 4 files changed, 305 insertions(+) create mode 100644 integrations/google-calendar/manifest.yaml create mode 100644 integrations/google-calendar/scripts/acl_add.py create mode 100644 integrations/google-calendar/scripts/acl_list.py create mode 100644 integrations/google-calendar/scripts/test_connection.py diff --git a/integrations/google-calendar/manifest.yaml b/integrations/google-calendar/manifest.yaml new file mode 100644 index 0000000..e144106 --- /dev/null +++ b/integrations/google-calendar/manifest.yaml @@ -0,0 +1,62 @@ +id: google_calendar +name: Google Calendar +version: 1.0.0 +description: "Google Calendar (Calendar API v3) — manage calendar access control lists: grant a role on a calendar to a user, group, domain or the public, and list the existing ACL rules. Service-account authentication with domain-wide delegation. Runs on a remote engine. Requires the Python 'PyJWT' and 'cryptography' libraries on the engine host (pip install pyjwt cryptography)." +changelog: "1.0.0 — Initial release: ACL rule creation and listing on user calendars." +category: productivity + +# Per-instance configuration. Create a Google Cloud service account with +# domain-wide delegation, authorize the https://www.googleapis.com/auth/calendar +# scope in the Workspace admin console, and paste the service account JSON key. +# The scripts sign a JWT (RS256) with the key and exchange it for an access +# token that impersonates user_id (or a per-command user override). +config_schema: + properties: + service_account_json: + type: string + description: "Service account key JSON (full file contents) with domain-wide delegation" + x-soar-sensitive: true + user_id: + type: string + description: "Default user to impersonate (primary email address)" + required: + - service_account_json + - user_id + +commands: + - id: acl_add + name: google-calendar-acl-add + description: "Create an access control rule on a calendar (grant a role to a user, group, domain or the public)." + inputs_schema: + properties: + calendar_id: { type: string, description: "Calendar identifier — use 'primary' for the impersonated user's main calendar" } + role: { type: string, description: "Role: none, freeBusyReader, reader, writer or owner" } + scope_type: { type: string, description: "Grantee type: default (public), user, group or domain" } + scope_value: { type: string, description: "Email of the user/group or domain name (omit for scope_type=default)" } + send_notifications: { type: boolean, description: "Send notifications about the sharing change (default true)" } + user_id: { type: string, description: "Override the impersonated user" } + required: [calendar_id, role, scope_type] + outputs_schema: { properties: {} } + - id: acl_list + name: google-calendar-acl-list + description: "List the access control rules of a calendar." + risk: read + inputs_schema: + properties: + calendar_id: { type: string, description: "Calendar identifier — use 'primary' for the impersonated user's main calendar" } + max_results: { type: number, description: "Maximum entries per page (default 100, max 250)" } + page_token: { type: string, description: "Token of the results page to return" } + show_deleted: { type: boolean, description: "Include deleted ACL rules (role 'none')" } + sync_token: { type: string, description: "nextSyncToken from a previous listing — returns only entries changed since" } + user_id: { type: string, description: "Override the impersonated user" } + required: [calendar_id] + outputs_schema: { properties: {} } + + - id: test_connection + name: google-calendar-test-connection + description: "Verify the service account key and delegation (used by the Test button)." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } diff --git a/integrations/google-calendar/scripts/acl_add.py b/integrations/google-calendar/scripts/acl_add.py new file mode 100644 index 0000000..54315de --- /dev/null +++ b/integrations/google-calendar/scripts/acl_add.py @@ -0,0 +1,84 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + _token(scopes, subject)} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +SCOPES = ["https://www.googleapis.com/auth/calendar"] + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + calendar_id = str(inputs.get("calendar_id") or "") + role = str(inputs.get("role") or "") + scope_type = str(inputs.get("scope_type") or "") + if not calendar_id or not role or not scope_type: + raise Exception("calendar_id, role and scope_type are required") + body = {"role": role, "scope": {"type": scope_type}} + if inputs.get("scope_value"): + body["scope"]["value"] = inputs["scope_value"] + url = "https://www.googleapis.com/calendar/v3/calendars/" + urllib.parse.quote(calendar_id) + "/acl" + send = inputs.get("send_notifications") + params = {"sendNotifications": "false" if send is False else "true"} + print(json.dumps(request("POST", url, SCOPES, params=params, body=body, subject=inputs.get("user_id")))) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/google-calendar/scripts/acl_list.py b/integrations/google-calendar/scripts/acl_list.py new file mode 100644 index 0000000..531a45d --- /dev/null +++ b/integrations/google-calendar/scripts/acl_list.py @@ -0,0 +1,83 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + _token(scopes, subject)} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +SCOPES = ["https://www.googleapis.com/auth/calendar"] + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + calendar_id = str(inputs.get("calendar_id") or "") + if not calendar_id: + raise Exception("calendar_id is required") + url = "https://www.googleapis.com/calendar/v3/calendars/" + urllib.parse.quote(calendar_id) + "/acl" + params = { + "maxResults": int(inputs.get("max_results") or 100), + "pageToken": inputs.get("page_token"), + "showDeleted": "true" if inputs.get("show_deleted") else None, + "syncToken": inputs.get("sync_token"), + } + print(json.dumps(request("GET", url, SCOPES, params=params, subject=inputs.get("user_id")))) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/google-calendar/scripts/test_connection.py b/integrations/google-calendar/scripts/test_connection.py new file mode 100644 index 0000000..6436ede --- /dev/null +++ b/integrations/google-calendar/scripts/test_connection.py @@ -0,0 +1,76 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + _token(scopes, subject)} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +SCOPES = ["https://www.googleapis.com/auth/calendar"] + + +def main(): + res = request("GET", "https://www.googleapis.com/calendar/v3/users/me/calendarList", SCOPES, + params={"maxResults": 1}) + if "items" not in res and "kind" not in res: + raise Exception("Unexpected response: " + json.dumps(res)) + print(json.dumps({"ok": True, "impersonated": _cfg().get("user_id", "")})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1)