{
  "id": "a909e19f-b281-54",
  "name": "Find and revoke public Google Drive sharing links with Gmail approval, an allowlist and an audit log",
  "nodes": [
    {
      "parameters": {
        "content": "## Find and revoke public Google Drive sharing links with Gmail approval, an allowlist and an audit log\n\n### How it works\n\n\"Anyone with the link\" is the easiest way to share a file and the easiest to forget. Every week this workflow finds the files you own that are open to anyone, asks you once, and closes the ones you approve, keeping a record of each.\n\n1. **Every Monday your Drive is searched** for files you own that anyone with the link can open, or that anyone can find by search.\n2. **Intentional links are kept.** An Allowlist sheet lists files that are meant to be public, by file id or by part of the name, such as a public price list or a press kit. Those are never touched.\n3. **The riskiest links come first.** The approval email ranks links findable by search first, then links that let anyone *edit*, then the ones untouched the longest, with a direct link to each file.\n4. **Nothing changes until you approve.** Approve in Gmail and every listed public link is removed. Reject and everything stays as it is until next week. If nothing is exposed, you get no email at all.\n5. **Only the public permission is removed.** People you shared with by name keep their access, and the file itself isn't moved or deleted.\n6. **Every revocation is written to an audit sheet**: file, link, what the public access was, when it was closed and whether Drive accepted it.\n\n### Setup steps\n\nTakes ≈10 minutes.\n\n1. Connect a Google Drive credential in **Find Your Publicly Shared Files** and **Revoke the Public Link**. Both call the Drive API directly with that credential.\n2. Create a Google Sheet with two tabs:\n   - `Allowlist` with columns `File id, Name contains, Why it's public`\n   - `Revocations` with columns `When, File, Link, Access removed, File id, Result`\n3. Select the `Allowlist` tab in **Read Your Allowlist** and the `Revocations` tab in **Log the Revocation**.\n4. Connect Gmail in **Ask You to Approve Revoking** and put your own address in it.\n5. Add anything that should stay public to the Allowlist, then run the workflow manually. Share a test file as \"anyone with the link\" first so there's something to find, approve the email, and check the file is private again and the audit sheet has a row.\n\n### Customization\n\n- **Schedule:** change **Every Monday Morning** to daily for tighter control.\n- **Softer mode:** replace the revoke step with a change that turns *anyone can edit* into *anyone can view*, and review the rest by hand.\n- **Team alerts:** send the approval to a shared inbox, or add Slack or Telegram next to Gmail.\n- **Shared drives:** add `supportsAllDrives=true` and `includeItemsFromAllDrives=true` to the search to cover shared drives your account manages.\n\n*A forgotten public link is a leak nobody noticed. This one asks first, closes it cleanly and writes it down.*",
        "height": 1760,
        "width": 520
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1180,
        -260
      ],
      "id": "44aba546-2877-55f3-a09b-e9ffbe4e3ba8",
      "name": "Sticky Note"
    },
    {
      "parameters": {
        "content": "## Find files open to anyone\n\nOnce a week the Drive API is asked for files you own that anyone with the link can open, or anyone can find by search, together with their permissions.",
        "height": 340,
        "width": 760,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -580,
        -260
      ],
      "id": "04ce6c85-2221-539b-ba4c-536ef1f4dca4",
      "name": "Sticky Note1"
    },
    {
      "parameters": {
        "content": "## Rank the exposure and ask you first\n\nAllowlisted files are dropped. The rest are ranked (findable by search, then editable, then oldest) and sent to you in one email. Nothing changes until you approve.",
        "height": 540,
        "width": 1000,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        236,
        -260
      ],
      "id": "38d231b6-77fc-58f4-9add-55cdce75194c",
      "name": "Sticky Note2"
    },
    {
      "parameters": {
        "content": "## Remove only the public link, and log it\n\nEach public permission is deleted through the Drive API. Named collaborators keep access and the file stays where it is. Every result is written to the audit sheet.",
        "height": 340,
        "width": 760,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1292,
        -260
      ],
      "id": "3b111f24-4fdb-5af3-b85d-e18d845f2c64",
      "name": "Sticky Note3"
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 8
            }
          ]
        }
      },
      "id": "c3782693-c5bc-53a0-a4ce-766d8432b126",
      "name": "Every Monday Morning",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -498,
        -123.80000000000001
      ]
    },
    {
      "parameters": {
        "url": "https://www.googleapis.com/drive/v3/files",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleDriveOAuth2Api",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "q",
              "value": "(visibility = 'anyoneWithLink' or visibility = 'anyoneCanFind') and 'me' in owners and trashed = false"
            },
            {
              "name": "fields",
              "value": "files(id,name,mimeType,webViewLink,modifiedTime,permissions(id,type,role,allowFileDiscovery))"
            },
            {
              "name": "pageSize",
              "value": "1000"
            }
          ]
        },
        "options": {}
      },
      "id": "ff46de36-4bbe-50e0-80c1-c8b41301b784",
      "name": "Find Your Publicly Shared Files",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -258,
        -123.80000000000001
      ]
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "options": {}
      },
      "id": "93a06466-5467-5e8e-adae-7f477085e83d",
      "name": "Read Your Allowlist",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        -18,
        -123.80000000000001
      ],
      "alwaysOutputData": true,
      "executeOnce": true
    },
    {
      "parameters": {
        "jsCode": "// Turns Drive's list of publicly shared files into one approval request.\n// A file is exposed when it has an \"anyone\" permission: anyone with the link\n// (or, worse, anyone who searches) can open it. Files on your allowlist are\n// meant to be public and are left alone.\n\nconst files = ($('Find Your Publicly Shared Files').first().json.files ?? []);\nconst allow = $('Read Your Allowlist').all().map(i => i.json);\nconst allowIds = new Set(allow.map(a => String(a['File id'] ?? '').trim()).filter(Boolean));\nconst allowNames = allow.map(a => String(a['Name contains'] ?? '').trim().toLowerCase()).filter(Boolean);\n\nconst days = iso => Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);\nconst exposed = [];\nlet allowlisted = 0, unreadable = 0;\nfor (const f of files) {\n  const perms = (f.permissions ?? []).filter(p => p.type === 'anyone');\n  if (!f.permissions) { unreadable++; continue; }\n  if (!perms.length) continue;\n  const name = String(f.name ?? '');\n  if (allowIds.has(f.id) || allowNames.some(s => name.toLowerCase().includes(s))) { allowlisted++; continue; }\n  for (const p of perms) {\n    exposed.push({\n      fileId: f.id, name, link: f.webViewLink ?? '', type: f.mimeType ?? '',\n      permissionId: p.id, role: p.role,\n      searchable: p.allowFileDiscovery === true,\n      untouchedDays: f.modifiedTime ? days(f.modifiedTime) : null,\n    });\n  }\n}\n\nif (!exposed.length) return [];\n\n// Most dangerous first: searchable, then editable, then the longest forgotten.\nconst rank = e => (e.searchable ? 0 : 1) * 10 + (e.role === 'writer' ? 0 : 1);\nexposed.sort((a, b) => rank(a) - rank(b) || (b.untouchedDays ?? 0) - (a.untouchedDays ?? 0));\n\nconst line = e => '- ' + e.name + ' (' + (e.role === 'writer' ? 'anyone can EDIT' : e.role === 'commenter' ? 'anyone can comment' : 'anyone can view')\n  + (e.searchable ? ', findable by search' : ', anyone with the link') + (e.untouchedDays !== null ? ', untouched ' + e.untouchedDays + ' days' : '') + ')\\n  ' + e.link;\n\nreturn [{ json: {\n  count: exposed.length,\n  editable: exposed.filter(e => e.role === 'writer').length,\n  searchable: exposed.filter(e => e.searchable).length,\n  allowlisted, unreadable,\n  report: exposed.slice(0, 50).map(line).join('\\n') + (exposed.length > 50 ? '\\n…and ' + (exposed.length - 50) + ' more' : ''),\n  exposed,\n} }];"
      },
      "id": "5e56b041-40fb-5f54-bef9-9bdf69508fd7",
      "name": "Build the Exposure Report",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        318,
        -123.80000000000001
      ]
    },
    {
      "parameters": {
        "operation": "sendAndWait",
        "sendTo": "you@example.com",
        "subject": "={{ $json.count }} Google Drive file(s) are open to anyone: approve closing the links?",
        "message": "=These files you own can be opened by anyone with the link. {{ $json.searchable }} can be found by search and {{ $json.editable }} can be edited by anyone.\n\n{{ $json.report }}\n\n{{ $json.allowlisted }} allowlisted file(s) were left alone.{{ $json.unreadable ? ' ' + $json.unreadable + ' file(s) could not be checked.' : '' }}\n\nApprove to remove the public link from every file above. People you shared with by name keep their access. Reject to change nothing.",
        "responseType": "approval",
        "approvalOptions": {
          "values": {
            "approvalType": "double"
          }
        },
        "options": {}
      },
      "id": "19d013c0-2bfb-51c2-a81b-afd26337d398",
      "name": "Ask You to Approve Revoking",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        558,
        -123.80000000000001
      ]
    },
    {
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-approved",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.data.approved }}",
              "rightValue": ""
            }
          ]
        }
      },
      "id": "190d4db7-8789-5ede-a6f2-6299e635fc1b",
      "name": "Did You Approve It?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        798,
        -123.80000000000001
      ]
    },
    {
      "parameters": {},
      "id": "899d5b57-48d5-5dd3-8966-26def2c966a2",
      "name": "Leave the Links as They Are",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1038,
        76.19999999999999
      ]
    },
    {
      "parameters": {
        "jsCode": "// One item per public permission from the report you approved.\nreturn $('Build the Exposure Report').first().json.exposed.map(e => ({ json: e }));"
      },
      "id": "946ab342-df65-5abf-9e34-d92a206973cc",
      "name": "One Item per Public Link",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1374,
        -123.80000000000001
      ]
    },
    {
      "parameters": {
        "method": "DELETE",
        "url": "=https://www.googleapis.com/drive/v3/files/{{ $json.fileId }}/permissions/{{ $json.permissionId }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleDriveOAuth2Api",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      },
      "id": "3d25e9be-1d61-5df5-9cf1-7448634fc656",
      "name": "Revoke the Public Link",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1614,
        -123.80000000000001
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "When": "={{ $now.toISO() }}",
            "File": "={{ $('One Item per Public Link').item.json.name }}",
            "Link": "={{ $('One Item per Public Link').item.json.link }}",
            "Access removed": "=anyone ({{ $('One Item per Public Link').item.json.role }}{{ $('One Item per Public Link').item.json.searchable ? ', findable by search' : ', with the link' }})",
            "File id": "={{ $('One Item per Public Link').item.json.fileId }}",
            "Result": "={{ ($json.statusCode ?? 0) < 300 ? 'Revoked' : 'Failed: ' + ($json.statusCode ?? '') + ' ' + JSON.stringify($json.body ?? $json.error ?? '').slice(0, 200) }}"
          },
          "matchingColumns": [],
          "schema": []
        },
        "options": {}
      },
      "id": "42106ab3-3d08-5e33-bf18-6437d80e32f5",
      "name": "Log the Revocation",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1854,
        -123.80000000000001
      ]
    }
  ],
  "connections": {
    "Every Monday Morning": {
      "main": [
        [
          {
            "node": "Find Your Publicly Shared Files",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find Your Publicly Shared Files": {
      "main": [
        [
          {
            "node": "Read Your Allowlist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Your Allowlist": {
      "main": [
        [
          {
            "node": "Build the Exposure Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build the Exposure Report": {
      "main": [
        [
          {
            "node": "Ask You to Approve Revoking",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ask You to Approve Revoking": {
      "main": [
        [
          {
            "node": "Did You Approve It?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Did You Approve It?": {
      "main": [
        [
          {
            "node": "One Item per Public Link",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Leave the Links as They Are",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "One Item per Public Link": {
      "main": [
        [
          {
            "node": "Revoke the Public Link",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Revoke the Public Link": {
      "main": [
        [
          {
            "node": "Log the Revocation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}
