├── .github └── workflows │ └── deno.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── assets └── avatar.png ├── config.ts ├── datastores └── tailscale.ts ├── deno.jsonc ├── functions ├── access_approval_prompt.ts └── access_request_prompt.ts ├── images ├── tailscale-access-init.png ├── tailscale-access-requesting-access.png ├── tailscale-access-shortcut.png ├── tailscale-access-unconfigured.png └── tailscale-oauth-client.png ├── import_map.json ├── manifest.ts ├── slack.json ├── tailscale.ts ├── triggers └── trigger.ts ├── types ├── slack.ts └── tailscale.ts └── workflows └── CreateAccessRequestWorkflow.ts /.github/workflows/deno.yml: -------------------------------------------------------------------------------- 1 | name: Deno app build and testing 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | deno: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 5 13 | 14 | steps: 15 | - name: Setup repo 16 | uses: actions/checkout@v3 17 | 18 | - name: Setup Deno 19 | uses: denoland/setup-deno@v1 20 | with: 21 | deno-version: v1.x 22 | 23 | - name: Verify formatting 24 | run: deno fmt --check 25 | 26 | - name: Run linter 27 | run: deno lint 28 | 29 | - name: Run tests 30 | run: deno task test 31 | 32 | - name: Run type check 33 | run: deno check *.ts && deno check **/*.ts 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | package 3 | .DS_Store 4 | .slack/apps.dev.json 5 | .env 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "deno.suggest.imports.hosts": { 5 | "https://deno.land": false 6 | }, 7 | "[typescript]": { 8 | "editor.formatOnSave": true, 9 | "editor.defaultFormatter": "denoland.vscode-deno" 10 | }, 11 | "editor.tabSize": 2 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Slack Technologies, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tailscale Slack Accessbot 2 | 3 | This is an example Slack Workflow App that allows users to use Slack to request 4 | instantaneous, time-bound access, known as just-in-time access, to Tailscale 5 | resources from other people in their organization. 6 | 7 | Tailscale Access request access form in Slack 8 | 9 | Note that it relies on Tailscale 10 | [custom device posture attributes](https://tailscale.com/api#tag/devices/POST/device/{deviceId}/attributes/{attributeKey}) 11 | API that might not be available on all pricing plans. 12 | 13 | ## Deploy the Slack App for the first time 14 | 15 | 1. Confirm that the Slack Team to which you want to install the Tailscale 16 | Accessbot has a paid [Slack plan](https://app.slack.com/plans/) which allows 17 | you to **Deploy apps to Slack infrastructure**. 18 | 19 | 1. Install [Deno](https://www.deno.com/) on your local machine following the 20 | Deno 21 | [Installation instructions](https://docs.deno.com/runtime/manual/getting_started/installation). 22 | 23 | 1. Install the Slack CLI on your local machine following the Slack 24 | [Quickstart Guide](https://api.slack.com/automation/quickstart) (or just 25 | `brew install slack-cli` on macOS). 26 | 27 | 1. Authenticate with the Slack CLI by running `slack login` in your terminal: 28 | 29 | 1. The output will contain a command like 30 | `/slackauthticket NzY5YmViN2QtY2ZjZS12ZmRjLTlmYTktNjI0NjI5NWI1ODFk` which 31 | you should paste into the Slack chat box. 32 | 1. Approve the permissions that Slack will grant your CLI. 33 | 1. Paste the confirmation code back into the Slack CLI's **Enter challenge 34 | code** prompt. 35 | 36 | 1. Add the Tailscale Accessbot code to a git repository of your own: 37 | 38 | 1. Run the following commands to create a new directory for the accessbot 39 | code and config: 40 | ```shell 41 | mkdir tailscale-accessbot 42 | cd tailscale-accessbot 43 | ``` 44 | 45 | 1. Run the following commands to pull the Tailscale Accessbot code into your 46 | new directory: 47 | ```shell 48 | git init -b main 49 | git remote add upstream https://github.com/tailscale/accessbot.git 50 | git pull upstream main 51 | ``` 52 | 53 | 1. (Optional, recommended) Create a private git repository on GitHub, GitLab, 54 | or your preferred git host of choice, and push your code there: 55 | ```shell 56 | git remote add origin git@github.com:myorg/tailscale-accessbot 57 | git push -u origin main 58 | ``` 59 | 60 | 1. Deploy the app to Slack: 61 | 62 | 1. Run the following command to begin deploying your app to Slack, which will 63 | prompt you to select the Team to install to: 64 | ```shell 65 | slack deploy 66 | ``` 67 | - If you receive an error containing `app_approval_request_denied` then 68 | your Slack team is configured with **Require App Approval** turned on 69 | but **Allow members to request approval for apps** turned off. Speak to 70 | one of your Slack team owners about changing these settings to allow you 71 | to proceed. They can either turn on the **Allow members to request 72 | approval for apps** setting or add your user to the **Select App 73 | Managers to manage apps** > **Workspace Owners and selected members or 74 | groups** option. Retry `slack deploy` after this change. 75 | - If you are asked whether you would like to request approval to install 76 | the app, select **Yes**. Once Slack tells you that approval has been 77 | granted, you may re-run `slack deploy`. 78 | 79 | 1. Create the trigger when prompted. 80 | 81 | 1. Slack will give you a Shortcut URL such as 82 | `https://slack.com/shortcuts/Ft074AB2RW12/…` which won't work in your web 83 | browser, but which can be used within the Slack app. Paste the Shortcut 84 | URL from your terminal into a Slack chatroom and it will render a **Start 85 | Workflow** button: 86 | 87 | 'Start Workflow' link rendered in Slack 88 | 89 | 1. Selecting the **Start Workflow** button will show the following error 90 | because we are yet to connect it to Tailscale: 91 | 92 | Tailscale workflow before configuration 93 | 94 | 1. The Slack CLI will have created a `.slack` directory containing 95 | `apps.json` and `config.json` files. These contain the app identifiers 96 | that allow the Slack CLI to update the app later. You should now 97 | `git commit` these files and if backing up to a remote repository, 98 | `git push`. 99 | 100 | 1. Connect the app to Tailscale: 101 | 102 | 1. [Generate an OAuth client](https://login.tailscale.com/admin/settings/oauth) 103 | in Tailscale with the `devices:core:read` and `devices:posture_attributes` 104 | scopes: 105 | 106 | Tailscale accessbot OAuth Client 107 | 108 | 1. Run the following command from your accessbot directory, using the OAuth 109 | Client ID in place of ``, and selecting the appropriate team 110 | when prompted: 111 | ```shell 112 | slack env add TAILSCALE_CLIENT_ID 113 | ``` 114 | 115 | 1. Run the following command from your accessbot directory, using the OAuth 116 | Client secret in place of ``, and selecting the appropriate team 117 | when prompted: 118 | ```shell 119 | slack env add TAILSCALE_CLIENT_SECRET 120 | ``` 121 | 122 | 1. Going back to Slack, selecting the **Start Workflow** button again should 123 | now present the Accessbot screen: 124 | 125 | Tailscale Access starting request form in Slack 126 | 127 | - An alternative way to trigger the workflow is to start typing its name 128 | in the "slash command" pop-up menu that you should see after pressing 129 | the "/" key. 130 | 131 | 1. Any errors that occur during the operation of the Workflow will be sent to 132 | you in Slack, or can be inspected on demand using `slack activity`, or 133 | watched in real-time using `slack activity --tail`. 134 | 135 | 1. Proceed to the next section to configure the available access profiles and 136 | update your app. 137 | 138 | ## Configure profiles 139 | 140 | Configuration of Tailscale Access profiles is done by editing `config.ts`. All 141 | available configuration options can be seen in the schema under `config.ts`. 142 | 143 | An example of a minimal configuration can begin as follows: 144 | 145 | ```typescript 146 | export const config: Config = { 147 | profiles: [ 148 | { 149 | attribute: "custom:prodAccess", 150 | description: "Production", 151 | notifyChannel: "C06TH49GKHC", 152 | canSelfApprove: true, 153 | approverEmails: [ 154 | "alice@example.com", 155 | "bob@example.com", 156 | "charlie@example.com", 157 | ], 158 | }, 159 | ], 160 | } as Config; 161 | ``` 162 | 163 | See the `type Profile` declaration at the bottom of config.ts for a description 164 | of the different fields available in this config. 165 | 166 | After changing config.ts, you must run another `slack deploy` to see the config 167 | update in the app. It is recommended that you `git commit` and `git push` at 168 | this point too. 169 | 170 | [Slack documentation](https://api.slack.com/automation/cli/CI-CD-tutorial) has 171 | instructions on automatic deployment of the workflow using Github Actions. 172 | 173 | ## Use the attributes as part of network policy 174 | 175 | After the workflow has been configured and deployed, you can start using 176 | attributes corresponding to the configured access profiles as part of your 177 | network policy. 178 | 179 | For example, the `custom:prodAccess` attribute managed by the workflow can be 180 | referenced by a posture and required for production access: 181 | 182 | ``` 183 | "postures": { 184 | "posture:prodAccess": ["custom:prodAccess == true"], 185 | }, 186 | "acls": [ 187 | { 188 | "action": "accept", 189 | "src": ["group:dev"], 190 | "dst": ["tag:production"], 191 | "srcPosture": ["posture:prodAccess"] 192 | }, 193 | ], 194 | ``` 195 | 196 | See the [Device Posture](https://tailscale.com/kb/1288/device-posture) topic and 197 | the [tailnet policy file syntax](https://tailscale.com/kb/1337/acl-syntax/#acls) 198 | topic for more information about postures and posture conditions. 199 | 200 | ## Develop with the accessbot locally 201 | 202 | You can run the workflow locally, before deploying it to Slack's infrastructure. 203 | 204 | First add the Tailscale Client ID and Secret from the previous step to a `.env` 205 | file in the root of the project: 206 | 207 | ```.env 208 | TAILSCALE_CLIENT_ID=abc1234CNTRL 209 | TAILSCALE_CLIENT_SECRET=tskey-client-abc1234CNTRL-qwerty1234... 210 | ``` 211 | 212 | Then you can run the application using the `slack` CLI. You'll know an app is 213 | the development version if the name has the string `(local)` appended: 214 | 215 | ```shell 216 | # Run app locally 217 | slack run 218 | 219 | Connected, awaiting events 220 | ``` 221 | 222 | To stop running locally, press ` + C` to end the process. 223 | -------------------------------------------------------------------------------- /assets/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/accessbot/473c11b28fdcfdb4d6f6bf6e353b7de208360968/assets/avatar.png -------------------------------------------------------------------------------- /config.ts: -------------------------------------------------------------------------------- 1 | export const config: Config = { 2 | profiles: [ 3 | { 4 | // You can use this example config to test approvals with your own user. 5 | // 6 | // If you enter your own user as the approver, you will still be asked to 7 | // to approve it, which is not the default behaviour. 8 | description: "Accessbot Test: Review", 9 | attribute: "custom:accessbotTestReview", 10 | canSelfApprove: true, 11 | confirmSelfApproval: true, 12 | }, 13 | { 14 | attribute: "custom:accessbotTest", 15 | description: "Accessbot Test: Select Users", 16 | canSelfApprove: true, 17 | maxSeconds: 3 * 86400, // 3 days. 18 | approverEmails: [ 19 | // Enter some email addresses here. 20 | "someone@example.com", 21 | ], 22 | // You can send announcements of approvals to a Slack channel. 23 | // Navigate to the channel in Slack, then click the channel name above the 24 | // chat and in the window that opens, select the About tab. The channel ID 25 | // is available at the bottom of this window. 26 | // notifyChannel: "C06TH49GKHC", 27 | }, 28 | ], 29 | }; 30 | 31 | export type Config = { 32 | /** 33 | * Profiles must be a non-empty set of configuration. 34 | */ 35 | profiles: [Profile, ...Profile[]]; 36 | }; 37 | 38 | export type Profile = { 39 | /** 40 | * The human-readable name for the profile being granted access to by the attribute. 41 | * @example "Production" 42 | */ 43 | description: string; 44 | /** 45 | * The tailscale attribute added to a device for the selected duration, upon 46 | * the request being approved. 47 | */ 48 | attribute: string; 49 | 50 | /** 51 | * The maximum duration to offer the user when they are requesting access to 52 | * this profile. 53 | * @default 86400 (1 day, can be increased to 7*86400 for 7 days) 54 | */ 55 | maxSeconds?: number; 56 | /** 57 | * The channel identifier to post approve/deny updates to. 58 | * @example "CQ12VV345" 59 | * @default undefined (meaning no public channel updates) 60 | */ 61 | notifyChannel?: string; 62 | 63 | /** 64 | * Email addresses of people who may approve an access request. These are 65 | * looked-up to find the relevant slack users. 66 | * @default undefined (meaning anybody can approve) 67 | */ 68 | approverEmails?: string[]; 69 | 70 | /** 71 | * Whether a user can mark themselves as the approver for a request. 72 | * @default false 73 | */ 74 | canSelfApprove?: boolean; 75 | 76 | /** 77 | * Whether a user self-approving is prompted to approve their own access 78 | * request. Can be set to true to show them the prompt anyway. 79 | * @default false (skip self-approval) 80 | */ 81 | confirmSelfApproval?: boolean; 82 | }; 83 | -------------------------------------------------------------------------------- /datastores/tailscale.ts: -------------------------------------------------------------------------------- 1 | // /datastores/drafts.ts 2 | import { DefineDatastore, Schema } from "deno-slack-sdk/mod.ts"; 3 | 4 | export const TailscaleTokenStore = DefineDatastore({ 5 | name: "tailscale_access_token", 6 | primary_key: "client_id", 7 | time_to_live_attribute: "expires_at", 8 | attributes: { 9 | client_id: { 10 | type: Schema.types.string, 11 | }, 12 | access_token: { 13 | type: Schema.types.string, 14 | }, 15 | expires_at: { 16 | type: Schema.slack.types.timestamp, 17 | }, 18 | refresh_token: { 19 | type: Schema.types.string, 20 | }, 21 | }, 22 | }); 23 | 24 | export type AccessToken = typeof TailscaleTokenStore.definition; 25 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://deno.land/x/deno/cli/schemas/config-file.v1.json", 3 | "importMap": "import_map.json", 4 | "lock": false, 5 | "exclude": [".*"], 6 | "tasks": { 7 | "test": "deno fmt --check && deno lint && deno test --allow-read --allow-none" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /functions/access_approval_prompt.ts: -------------------------------------------------------------------------------- 1 | import { SlackFunction } from "deno-slack-sdk/mod.ts"; 2 | import tailscale from "../tailscale.ts"; 3 | import { DefineFunction, Schema } from "deno-slack-sdk/mod.ts"; 4 | import { Access, AccessType } from "../types/tailscale.ts"; 5 | import { presetDurations } from "./access_request_prompt.ts"; 6 | import { Env, SlackAPIClient } from "deno-slack-sdk/types.ts"; 7 | import { BaseResponse } from "deno-slack-api/types.ts"; 8 | 9 | const APPROVE_ID = "approve_request"; 10 | const DENY_ID = "deny_request"; 11 | 12 | export const AccessApprovalFunction = DefineFunction({ 13 | callback_id: "access_approval_prompt", 14 | title: "Access Request Approval", 15 | description: "Sends an access request to an approver for review", 16 | source_file: "functions/access_approval_prompt.ts", 17 | input_parameters: { 18 | properties: { 19 | interactivity: { 20 | type: Schema.slack.types.interactivity, 21 | }, 22 | access: { 23 | // TODO(icio): By accepting all of the nested fields as arguments, we 24 | // allow callers to specify mismatched device name/nodeId/addresses to 25 | // mislead the approver. Workflow Builder also does not support complex 26 | // objects, so having a flat list of properties would enable users to 27 | // wire up their own approval flows. 28 | type: AccessType, 29 | }, 30 | }, 31 | required: [ 32 | "interactivity", 33 | "access", 34 | ], 35 | }, 36 | output_parameters: { 37 | properties: {}, 38 | required: [], 39 | }, 40 | }); 41 | 42 | export default SlackFunction( 43 | AccessApprovalFunction, 44 | async ({ env, inputs, client }) => { 45 | const { profile, requester, approver } = inputs.access; 46 | 47 | if (requester === approver.userId && profile.confirmSelfApproval !== true) { 48 | await approve(env, client, true, inputs.access); 49 | return { 50 | completed: true, 51 | outputs: {}, 52 | }; 53 | } 54 | 55 | // Create a block of Block Kit elements composed of several header blocks 56 | // plus the interactive approve/deny buttons at the end 57 | const blocks = accessRequestHeaderBlocks(inputs.access).concat([{ 58 | "type": "actions", 59 | "block_id": "approve-deny-buttons", 60 | "elements": [ 61 | { 62 | type: "button", 63 | text: { 64 | type: "plain_text", 65 | text: "Approve", 66 | }, 67 | action_id: APPROVE_ID, 68 | style: "primary", 69 | }, 70 | { 71 | type: "button", 72 | text: { 73 | type: "plain_text", 74 | text: "Deny", 75 | }, 76 | action_id: DENY_ID, 77 | style: "danger", 78 | }, 79 | ], 80 | }]); 81 | 82 | const msgResponse = await client.chat.postMessage({ 83 | channel: approver.userId, 84 | blocks, 85 | text: "You have been asked to approve Tailscale access", 86 | }); 87 | 88 | if (!msgResponse.ok) { 89 | const msg = `Error sending message to approver: ${msgResponse.error}"`; 90 | console.log(msg); 91 | return { error: msg }; 92 | } 93 | return { 94 | completed: false, 95 | }; 96 | }, 97 | // Create an 'actions handler', which is a function that will be invoked 98 | // when specific interactive Block Kit elements (like buttons!) are interacted 99 | // with. 100 | ).addBlockActionsHandler( 101 | // listen for interactions with components with the following action_ids 102 | [APPROVE_ID, DENY_ID], 103 | // interactions with the above two action_ids get handled by the function below 104 | async function ({ action, env, inputs, body, client }) { 105 | // Send the approval. 106 | const approved = action.action_id == APPROVE_ID; 107 | await approve(env, client, approved, inputs.access); 108 | 109 | // Update the approver's message. 110 | const msgUpdate = await client.chat.update({ 111 | channel: body.container.channel_id, 112 | ts: body.container.message_ts, 113 | blocks: accessRequestHeaderBlocks(inputs.access).concat([ 114 | { 115 | type: "context", 116 | elements: [ 117 | { 118 | type: "mrkdwn", 119 | text: `${ 120 | approved ? " :white_check_mark: Approved" : ":x: Denied" 121 | }`, 122 | }, 123 | ], 124 | }, 125 | ]), 126 | }); 127 | if (!msgUpdate.ok) { 128 | const msg = 129 | `Error updating approver message requester: ${msgUpdate.error}"`; 130 | console.log(msg); 131 | return { error: msg }; 132 | } 133 | 134 | await client.functions.completeSuccess({ 135 | function_execution_id: body.function_data.execution_id, 136 | completed: true, 137 | outputs: {}, 138 | }); 139 | }, 140 | ); 141 | 142 | async function approve( 143 | env: Env, 144 | client: SlackAPIClient, 145 | approved: boolean, 146 | access: Access, 147 | ) { 148 | const { profile, requester, durationSeconds, device, reason, approver } = 149 | access; 150 | 151 | const channels = [requester]; 152 | if (profile.notifyChannel) { 153 | channels.push(profile.notifyChannel); 154 | } 155 | 156 | const requesterRes = client.users.info({ user: requester }).catch(( 157 | err, 158 | ) => (console.error("Error loading requester user info:", err), undefined)); 159 | const approverRes = client.users.info({ user: approver.userId }).catch(( 160 | err, 161 | ) => (console.error("Error loading approver user info:", err), undefined)); 162 | 163 | const msg = { 164 | blocks: [{ 165 | type: "context", 166 | elements: [ 167 | { 168 | type: "mrkdwn", 169 | text: 170 | `<@${requester}>'s access request for ${profile.description} on <${ 171 | tailscaleMachineLink( 172 | access.device.nodeId, 173 | access.device.addresses, 174 | ) 175 | }|${access.device.name || access.device.nodeId}>` + 176 | `${reason ? ` for "${reason}"` : ""} was ${ 177 | approved ? " :white_check_mark: Approved" : ":x: Denied" 178 | } by <@${approver.userId}> until ` + 179 | new Date(Date.now() + durationSeconds * 1000).toISOString(), 180 | }, 181 | ], 182 | }], 183 | text: `<@${requester}>'s access request was ${ 184 | approved ? "approved" : "denied" 185 | }!`, 186 | }; 187 | try { 188 | await Promise.all( 189 | channels.map((channel) => 190 | client.chat.postMessage({ channel, ...msg }).then((r) => { 191 | if (r.ok) return r; 192 | throw new Error(`Error sending message to ${channel}: ${r.error}`); 193 | }) 194 | ), 195 | ); 196 | } catch (e) { 197 | console.error(e.message); 198 | return { error: e.message }; 199 | } 200 | 201 | // Update Tailscale with the new attr request. 202 | if (approved) { 203 | let comment = 204 | `Tailscale Access Slackbot: request from ${ 205 | userref(await requesterRes) 206 | } approved by ${userref(await approverRes)}` + 207 | (reason ? `\nReason: ${reason}` : ""); 208 | if (comment.length > 200) { 209 | comment = comment.slice(0, 200); 210 | } 211 | const r = await tailscale(env, client)( 212 | `https://api.tailscale.com/api/v2/device/${ 213 | encodeURIComponent(device.nodeId) 214 | }/attributes/${profile.attribute}`, 215 | { 216 | method: "POST", 217 | body: JSON.stringify({ 218 | value: true, 219 | expiry: new Date(Date.now() + durationSeconds * 1000).toISOString(), 220 | comment: comment, 221 | }), 222 | }, 223 | ); 224 | console.info("tailscale attr update:", r.statusText, await r.text()); 225 | } 226 | } 227 | 228 | function userref(res?: BaseResponse): string { 229 | if (res?.user?.name) { 230 | if (res?.user?.real_name) { 231 | return res.user.real_name + " (" + res.user.name + ")"; 232 | } 233 | return res.user.name; 234 | } 235 | return ""; 236 | } 237 | 238 | // deno-lint-ignore no-explicit-any 239 | function accessRequestHeaderBlocks(access: any): any[] { 240 | return [ 241 | { 242 | type: "section", 243 | text: { 244 | type: "mrkdwn", 245 | text: 246 | `*:wave: <@${access.requester}> is requesting Tailscale access to ${access.profile.description}.*${ 247 | access.reason ? "\n\nReason:\n>" + access.reason : "" 248 | }`, 249 | }, 250 | fields: [ 251 | { 252 | type: "mrkdwn", 253 | text: `:${osEmoji(access.device.os)}: <${ 254 | tailscaleMachineLink(access.device.nodeId, access.device.addresses) 255 | }|${access.device.name || access.device.nodeId}>`, 256 | }, 257 | { 258 | type: "mrkdwn", 259 | text: access.device.tags 260 | ? `:robot_face: \`${access.device.tags.join("\` \`")}\`` 261 | : `:bust_in_silhouette: ${access.device.user}`, 262 | }, 263 | { 264 | type: "mrkdwn", 265 | text: `:stopwatch: ${ 266 | presetDurations.find((d) => d.seconds === access.durationSeconds) 267 | ?.text || (access.durationSeconds + " seconds") 268 | }`, 269 | }, 270 | { 271 | type: "mrkdwn", 272 | text: `:label: \`${access.profile.attribute}\``, 273 | }, 274 | ], 275 | }, 276 | ]; 277 | } 278 | 279 | function osEmoji(os?: string): string { 280 | switch (os) { 281 | case "android": 282 | case "iOS": 283 | return "iphone"; 284 | case "tvOS": 285 | return "tv"; 286 | default: 287 | return "computer"; 288 | } 289 | } 290 | 291 | function tailscaleMachineLink(nodeId: string, addresses?: string[]): string { 292 | const m = "https://login.tailscale.com/admin/machines"; 293 | if (addresses?.[0]) { 294 | return m + "/" + addresses[0]; 295 | } 296 | return m + "?q=" + encodeURIComponent(nodeId); 297 | } 298 | -------------------------------------------------------------------------------- /functions/access_request_prompt.ts: -------------------------------------------------------------------------------- 1 | import { SlackFunction } from "deno-slack-sdk/mod.ts"; 2 | import tailscale from "../tailscale.ts"; 3 | import { config } from "../config.ts"; 4 | import { SlackFunctionOutputs, SuggestionResponse } from "../types/slack.ts"; 5 | import { Env, SlackAPIClient } from "deno-slack-sdk/types.ts"; 6 | import { DefineFunction, Schema } from "deno-slack-sdk/mod.ts"; 7 | import { AccessType } from "../types/tailscale.ts"; 8 | 9 | export const AccessRequestFunction = DefineFunction({ 10 | callback_id: "access_request_prompt", 11 | title: "Request Tailscale Access", 12 | source_file: "functions/access_request_prompt.ts", 13 | input_parameters: { 14 | properties: { 15 | interactivity: { 16 | type: Schema.slack.types.interactivity, 17 | }, 18 | user: { 19 | type: Schema.slack.types.user_id, 20 | }, 21 | }, 22 | required: [ 23 | "interactivity", 24 | "user", 25 | ], 26 | }, 27 | output_parameters: { 28 | properties: { 29 | interactivity: { 30 | type: Schema.slack.types.interactivity, 31 | }, 32 | access: { 33 | type: AccessType, 34 | }, 35 | }, 36 | required: [ 37 | "interactivity", 38 | "access", 39 | ], 40 | }, 41 | }); 42 | 43 | const ACTION_PROFILE = "profile", 44 | ACTION_REASON = "reason", 45 | ACTION_DURATION = "duration", 46 | ACTION_APPROVER = "approver", 47 | ACTION_DEVICE = "device", 48 | SUBMIT_ID = "request_form"; 49 | 50 | export default SlackFunction( 51 | AccessRequestFunction, 52 | async ({ inputs, env, client }) => { 53 | // Open the empty modal. 54 | const r = await client.views.open({ 55 | interactivity_pointer: inputs.interactivity.interactivity_pointer, 56 | view: await buildView(env, client, inputs.interactivity.interactor.id), 57 | }); 58 | if (r.error || !r.ok) { 59 | console.error("Error opening view:", r); 60 | return { 61 | error: `opening view: ${r.error || "unknown error"}`, 62 | }; 63 | } 64 | return { completed: false }; 65 | }, 66 | ) 67 | .addBlockActionsHandler(ACTION_PROFILE, async ({ client, env, body }) => { 68 | // Update the fields dependent on the selected profile. 69 | const r = await client.views.update({ 70 | view_id: body.view.id, 71 | hash: body.view.hash, 72 | ...await buildView(env, client, body.user.id, body.view?.state), 73 | }); 74 | if (r.error == "hash_collision") { 75 | return; 76 | } 77 | if (r.error || !r.ok) { 78 | return { 79 | error: `updating view: ${r.error || "unknown error"}`, 80 | }; 81 | } 82 | }) 83 | .addBlockSuggestionHandler( 84 | ACTION_DEVICE, 85 | async ( 86 | { client, env, inputs, body }, 87 | ): Promise => { 88 | // Start fetching the user profile. 89 | const requesterInfoRes = client.users.info({ 90 | user: inputs.interactivity.interactor.id, 91 | }).catch((err) => ({ 92 | ok: false, 93 | error: err, 94 | user: undefined, 95 | })); 96 | 97 | type Device = { 98 | nodeId: string; 99 | name: string; 100 | user: string; 101 | lastSeen: string; 102 | tags: string[]; 103 | addresses: string[]; 104 | }; 105 | 106 | const query = body.value.trim().toLowerCase(); 107 | 108 | let devices: Device[]; 109 | try { 110 | // Fetch the list of devices. 111 | const ts = tailscale(env, client); 112 | const r = await ts( 113 | "https://api.tailscale.com/api/v2/tailnet/-/devices", 114 | ); 115 | if (r.status !== 200) { 116 | throw new Error(r.statusText); 117 | } 118 | 119 | // Filterthe devices by the query. 120 | devices = (await r.json()).devices as Device[]; 121 | if (devices?.length && query) { 122 | devices = devices.filter( 123 | (d: Device) => 124 | d.name.includes(query) || d.nodeId.startsWith(query) || 125 | d.addresses?.some((ip) => ip.startsWith(query)), 126 | ); 127 | } 128 | } catch (e) { 129 | console.error("Error loading devices", e); 130 | return { 131 | options: [{ 132 | value: "!", 133 | text: { 134 | type: "plain_text", 135 | emoji: true, 136 | text: `:warning: Error retreiving devices: ${e.message}`, 137 | }, 138 | }], 139 | }; 140 | } 141 | 142 | if (!devices || !devices.length) { 143 | return { options: [] }; 144 | } 145 | 146 | // List the user's top devices. 147 | let yourDevices: Device[] = []; 148 | try { 149 | // Wait for the email. 150 | const requesterInfo = await requesterInfoRes; 151 | if (!requesterInfo.ok) { 152 | throw new Error( 153 | `looking up requester info: ${requesterInfo.error}"`, 154 | ); 155 | } 156 | const email = requesterInfo?.user?.profile?.email?.toLowerCase(); 157 | if (!email) { 158 | throw new Error(`no email in: ${requesterInfo}"`); 159 | } 160 | 161 | // Filter the devices, sorted by most recently active. 162 | yourDevices = devices.filter((d) => 163 | d.user.toLowerCase() == email && !d.tags 164 | ); 165 | yourDevices.sort((a, b) => a.lastSeen < b.lastSeen ? 1 : -1); 166 | } catch (err) { 167 | // Carry on with the rest of our devices, even if we didn't get an email 168 | // to provide a pre-filtered list. 169 | console.error("Error loading user email to filter devices:", err); 170 | } 171 | 172 | return { 173 | option_groups: [ 174 | { 175 | label: { 176 | type: "plain_text", 177 | text: "Your Devices", 178 | }, 179 | options: yourDevices.slice(0, 80).map((d) => ({ 180 | value: d.nodeId, 181 | text: { 182 | type: "plain_text", 183 | text: d.name, 184 | }, 185 | })), 186 | }, 187 | { 188 | label: { 189 | type: "plain_text", 190 | text: "All Devices", 191 | }, 192 | options: devices.slice(0, 20).map((d) => ({ 193 | value: d.nodeId, 194 | text: { 195 | type: "plain_text", 196 | text: d.name, 197 | }, 198 | })), 199 | }, 200 | ].filter((g) => g.options?.length > 0), 201 | } as SuggestionResponse; 202 | }, 203 | ) 204 | .addViewSubmissionHandler( 205 | SUBMIT_ID, 206 | async ({ body, env, client, inputs }) => { 207 | const errors: Record = {}; 208 | const s = formState(body.view.state); 209 | 210 | // Validate the profile. 211 | const profile = config.profiles.find((p) => p.attribute === s.profile); 212 | if (!profile) { 213 | errors[ACTION_PROFILE] = 214 | `Access with attribute ${s.profile} could not be found.`; 215 | } 216 | if (!s.profile) { 217 | errors[ACTION_PROFILE] = "Choose which access to request."; 218 | } 219 | 220 | // Validate the device. 221 | if (!s.device || s.device === "!") { 222 | errors[ACTION_DEVICE] = "Choose which device to use."; 223 | } 224 | 225 | // Validate the approver. 226 | let [approverId, approverEmail] = (s.approver || "").split(":", 2); 227 | if (!approverId) { 228 | errors[ACTION_APPROVER] = "An approver is required to confirm access."; 229 | } else if (profile) { 230 | if (!profile.canSelfApprove && approverId === inputs.user) { 231 | errors[ACTION_APPROVER] ||= `You cannot approve your own access to ${ 232 | profile?.description || "this profile" 233 | }.`; 234 | } 235 | // If the user was presented with a user_select to choose the approver, 236 | // then we didn't pass through the email address in the value, so load it. 237 | if (!approverEmail) { 238 | try { 239 | const r = await client.users.info({ user: approverId }); 240 | approverEmail = r.user?.profile?.email || ""; 241 | if (r.error) { 242 | errors[ACTION_APPROVER] ||= "Error loading approver email: " + 243 | r.error; 244 | } 245 | } catch (e) { 246 | errors[ACTION_APPROVER] ||= "Error loading approver email: " + e; 247 | } 248 | } 249 | // Validate the approver is allowed by the profile configuration. 250 | approverEmail = approverEmail.trim().toLowerCase(); 251 | console.log({ 252 | approverId, 253 | approverEmail, 254 | allowed: profile.approverEmails, 255 | }); 256 | if ( 257 | profile.approverEmails?.length && 258 | !profile.approverEmails.some((e) => 259 | e.trim().toLowerCase() == approverEmail 260 | ) 261 | ) { 262 | errors[ACTION_APPROVER] ||= 263 | `The user you selected cannot approve access to ${profile.description}.`; 264 | } 265 | } 266 | 267 | // Return any validation errors that we've found. 268 | for (const _ in errors) { 269 | return { 270 | response_action: "errors", 271 | errors, 272 | }; 273 | } 274 | 275 | // Load the details of the device that we selected. 276 | let device; 277 | try { 278 | device = await tailscale(env, client)( 279 | `https://api.tailscale.com/api/v2/device/${ 280 | encodeURIComponent(s.device!) 281 | }`, 282 | ) 283 | .then((r) => r.json()); 284 | } catch (e) { 285 | console.trace(`error fetching tailscale device: ${e}`); 286 | } 287 | 288 | const outputs: SlackFunctionOutputs< 289 | typeof AccessRequestFunction.definition 290 | > = { 291 | interactivity: body.interactivity, 292 | access: { 293 | requester: inputs.user, 294 | profile: profile!, 295 | approver: { 296 | userId: approverId, 297 | email: approverEmail, 298 | }, 299 | device: { 300 | nodeId: s.device!, 301 | name: device?.name || undefined, 302 | tags: device?.tags || undefined, 303 | user: device?.user || undefined, 304 | addresses: device?.addresses || undefined, 305 | os: device?.os || undefined, 306 | }, 307 | reason: s.reason!, 308 | durationSeconds: parseInt(s.duration!, 10), 309 | }, 310 | }; 311 | console.log("done", body.view.state, s, outputs); 312 | 313 | // Pass the request data onto the next workflow step. 314 | await client.functions.completeSuccess({ 315 | function_execution_id: body.function_data.execution_id, 316 | outputs, 317 | }); 318 | }, 319 | ); 320 | 321 | type FormState = { 322 | [ACTION_PROFILE]?: string; 323 | [ACTION_DEVICE]?: string; 324 | [ACTION_DURATION]?: string; 325 | [ACTION_APPROVER]?: string; 326 | [ACTION_REASON]?: string; 327 | }; 328 | 329 | // deno-lint-ignore no-explicit-any 330 | function formState(state: any): FormState { 331 | const s: FormState = {}; 332 | for (const blockId in state.values) { 333 | const block = state.values[blockId]; 334 | for (const actionId in block) { 335 | // Filter out bad fields so that s[actionId] is known to be a valid field. 336 | if ( 337 | actionId !== ACTION_APPROVER && actionId !== ACTION_DEVICE && 338 | actionId !== ACTION_DURATION && actionId !== ACTION_PROFILE && 339 | actionId !== ACTION_REASON 340 | ) { 341 | continue; 342 | } 343 | 344 | // Set the field from the inputs. 345 | const act = block[actionId]; 346 | if (act["selected_option"]) { 347 | s[actionId] = act.selected_option.value; 348 | } 349 | if (act["selected_user"]) { 350 | s[actionId] = act.selected_user; 351 | } 352 | if (act["selected_users"]) { 353 | s[actionId] = act.selected_users; 354 | } 355 | if ("value" in act) { 356 | s[actionId] = act.value; 357 | } 358 | } 359 | } 360 | return s; 361 | } 362 | 363 | const minuteSecs = 60; 364 | const hourSecs = 60 * minuteSecs; 365 | const daySecs = 24 * hourSecs; 366 | 367 | export const presetDurations = [ 368 | { text: "5 minutes", seconds: 5 * minuteSecs }, 369 | { text: "30 minutes", seconds: 30 * minuteSecs }, 370 | { text: "1 hour", seconds: 1 * hourSecs }, 371 | { text: "4 hours", seconds: 4 * hourSecs }, 372 | { text: "8 hours", seconds: 8 * hourSecs }, 373 | { text: "12 hours", seconds: 12 * hourSecs }, 374 | { text: "24 hours", seconds: 1 * daySecs }, 375 | { text: "2 days", seconds: 2 * daySecs }, 376 | { text: "3 days", seconds: 3 * daySecs }, 377 | { text: "4 days", seconds: 4 * daySecs }, 378 | { text: "5 days", seconds: 5 * daySecs }, 379 | { text: "6 days", seconds: 6 * daySecs }, 380 | { text: "7 days", seconds: 7 * daySecs }, 381 | ]; 382 | 383 | async function buildView( 384 | env: Env, 385 | client: SlackAPIClient, 386 | userId?: string, 387 | // deno-lint-ignore no-explicit-any 388 | viewState: any = {}, 389 | ) { 390 | if (!env.TAILSCALE_CLIENT_ID || !env.TAILSCALE_CLIENT_SECRET) { 391 | return buildEnvvarView(); 392 | } 393 | 394 | const state = formState(viewState); 395 | const profile = config.profiles.find((p) => p.attribute === state.profile); 396 | const profileOpts = config.profiles.map((p) => ({ 397 | value: p.attribute, 398 | text: { 399 | type: "plain_text", 400 | text: p.description, 401 | }, 402 | })); 403 | 404 | const maxSeconds = profile?.maxSeconds || daySecs; 405 | const durationOpts = presetDurations 406 | .filter((d) => d.seconds <= maxSeconds) 407 | .map((d) => ({ 408 | text: { type: "plain_text", text: d.text }, 409 | value: d.seconds.toFixed(0), 410 | })); 411 | state.duration ||= durationOpts[0].value; 412 | 413 | return { 414 | type: "modal", 415 | callback_id: SUBMIT_ID, 416 | title: { 417 | type: "plain_text", 418 | text: "Requesting Access", 419 | }, 420 | 421 | submit: { 422 | type: "plain_text", 423 | text: "Submit", 424 | }, 425 | close: { 426 | type: "plain_text", 427 | text: "Cancel", 428 | }, 429 | clear_on_close: true, // Do we want or not want this? 430 | notify_on_close: false, // Should we mark the function as completed/errored when the window is closed? If so, how do we complete the workflow? 431 | // submit_disabled: true, // Errors: Apparently only for "configuration modals" - "Configuration modals are used in Workflow Builder during the addition of Steps from Apps" but "We're retiring all Slack app functionality around Steps from Apps in September 2024." 432 | blocks: [ 433 | { 434 | block_id: "profile", 435 | type: "input", 436 | dispatch_action: true, 437 | label: { 438 | type: "plain_text", 439 | emoji: true, 440 | text: `:closed_lock_with_key: What do you want to access?`, 441 | }, 442 | element: { 443 | action_id: ACTION_PROFILE, 444 | type: "static_select", 445 | placeholder: { 446 | type: "plain_text", 447 | text: "Choose access...", 448 | }, 449 | options: profileOpts, 450 | initial_option: state.profile 451 | ? profileOpts.find((p) => p.value === state.profile) 452 | : undefined, 453 | }, 454 | }, 455 | { 456 | block_id: "device", 457 | type: "input", 458 | label: { 459 | type: "plain_text", 460 | emoji: true, 461 | text: `:computer: Which device are you using?`, 462 | }, 463 | element: { 464 | action_id: ACTION_DEVICE, 465 | type: "external_select", 466 | placeholder: { 467 | type: "plain_text", 468 | text: "Choose device...", 469 | }, 470 | min_query_length: 0, 471 | }, 472 | }, 473 | state.profile && { 474 | block_id: "duration", 475 | type: "input", 476 | label: { 477 | type: "plain_text", 478 | emoji: true, 479 | text: ":stopwatch: For how long?", 480 | }, 481 | element: { 482 | action_id: ACTION_DURATION, 483 | type: "static_select", 484 | placeholder: { 485 | type: "plain_text", 486 | text: "Choose duration...", 487 | }, 488 | options: durationOpts, 489 | initial_option: durationOpts.find((d) => d.value === state.duration), 490 | }, 491 | }, 492 | state.profile && await buildApproverBlock( 493 | client, 494 | userId, 495 | profile?.canSelfApprove, 496 | profile?.approverEmails, 497 | ), 498 | state.profile && { 499 | block_id: "reason", 500 | type: "input", 501 | label: { 502 | type: "plain_text", 503 | emoji: true, 504 | text: ":open_book: What do you need the access for?", 505 | }, 506 | element: { 507 | action_id: ACTION_REASON, 508 | type: "plain_text_input", 509 | // 80 is arbitrary here. If the final comment (that also includes 510 | // requester and approver names) comes over the API limit of 200 511 | // characters, we'll truncate it before sending the request. 512 | max_length: 80, 513 | placeholder: { 514 | type: "plain_text", 515 | text: "Enter reason...", 516 | }, 517 | }, 518 | }, 519 | ].filter(Boolean), 520 | }; 521 | } 522 | 523 | async function buildApproverBlock( 524 | client: SlackAPIClient, 525 | userId?: string, 526 | canSelfApprove?: boolean, 527 | emails?: string[], 528 | ) { 529 | if (!emails?.length && canSelfApprove) { 530 | return { 531 | block_id: "approver", 532 | type: "input", 533 | label: { 534 | type: "plain_text", 535 | emoji: true, 536 | text: ":sleuth_or_spy: Who should approve?", 537 | }, 538 | element: { 539 | action_id: ACTION_APPROVER, 540 | type: "radio_buttons", 541 | initial_option: { 542 | value: userId, 543 | text: { 544 | type: "plain_text", 545 | text: "No approval neeeded", 546 | }, 547 | }, 548 | options: [ 549 | { 550 | value: userId, 551 | text: { 552 | type: "plain_text", 553 | text: "No approval neeeded", 554 | }, 555 | }, 556 | ], 557 | }, 558 | }; 559 | } 560 | 561 | if (!emails?.length || emails.length > 10) { 562 | // We can't use radio buttons for this. 563 | return { 564 | block_id: "approver", 565 | type: "input", 566 | label: { 567 | type: "plain_text", 568 | emoji: true, 569 | text: ":sleuth_or_spy: Who should approve?", 570 | }, 571 | element: { 572 | action_id: ACTION_APPROVER, 573 | type: "users_select", 574 | placeholder: { 575 | type: "plain_text", 576 | emoji: true, 577 | text: "Choose an approver...", 578 | }, 579 | }, 580 | }; 581 | } 582 | 583 | // We can't use the users_select with a specific set of users, but we can show 584 | // up to 10 radio buttons. 585 | const users = await Promise.all( 586 | emails.map((email) => client.users.lookupByEmail({ email })), 587 | ); 588 | 589 | // Filter the list of approvers to successful responses. 590 | const approvers = users.filter((u) => 591 | u.ok && u.user && !u.user.deleted && 592 | (canSelfApprove || emails.length == 1 || u.user.id != userId) 593 | ); 594 | 595 | // Warn about any users who could not be found by email. 596 | const failedLooksup = users.map((u, i) => 597 | u.ok && u.user && !u.user.deleted ? null : emails[i] 598 | ).filter( 599 | Boolean, 600 | ); 601 | 602 | return { 603 | block_id: "approver", 604 | type: "input", 605 | label: { 606 | type: "plain_text", 607 | emoji: true, 608 | text: ":sleuth_or_spy: Who should approve?", 609 | }, 610 | hint: failedLooksup?.length 611 | ? { 612 | type: "plain_text", 613 | text: `Lookups failed for: ${failedLooksup.join(", ")}`, 614 | } 615 | : undefined, 616 | element: { 617 | action_id: ACTION_APPROVER, 618 | type: "radio_buttons", 619 | options: approvers.length 620 | ? approvers.map((u) => ({ 621 | value: u.user.id + ":" + (u.user.profile?.email || ""), 622 | text: { 623 | type: "mrkdwn", 624 | text: `<@${u.user.id}> - ${u?.user?.profile?.real_name}${ 625 | userId == u.user.id ? " (You)" : "" 626 | }`, 627 | }, 628 | description: { 629 | type: "plain_text", 630 | text: "Local time: " + localTime(u.user.tz_offset), 631 | }, 632 | })) 633 | : [{ 634 | // FIXME: What happens when we try to let the user do this? 635 | value: "!", 636 | text: { 637 | type: "plain_text", 638 | emoji: true, 639 | text: ":warning: No reviewers could be found.", 640 | }, 641 | }], 642 | }, 643 | }; 644 | } 645 | 646 | /** 647 | * @param offsetSeconds The seconds east of UTC (Slack's user.tz_offset). 648 | * @returns 649 | */ 650 | function localTime(offsetSeconds: number): string { 651 | const now = new Date(); 652 | now.setUTCSeconds(offsetSeconds + now.getTimezoneOffset() * 60); 653 | return now.toLocaleTimeString(undefined, { 654 | timeStyle: "short", 655 | hourCycle: "h12", 656 | }); 657 | } 658 | 659 | function buildEnvvarView() { 660 | return { 661 | type: "modal", 662 | title: { 663 | type: "plain_text", 664 | text: "Requesting Access", 665 | }, 666 | close: { 667 | type: "plain_text", 668 | text: "Cancel", 669 | }, 670 | clear_on_close: true, 671 | notify_on_close: false, 672 | blocks: [ 673 | { 674 | type: "section", 675 | text: { 676 | type: "mrkdwn", 677 | text: ":warning: This workflow requires configuring the " + 678 | "`TAILSCALE_CLIENT_ID` and `TAILSCALE_CLIENT_SECRET` " + 679 | "environment variables. Without it, API requests to " + 680 | "Tailscale would fail.", 681 | }, 682 | }, 683 | ], 684 | }; 685 | } 686 | -------------------------------------------------------------------------------- /images/tailscale-access-init.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/accessbot/473c11b28fdcfdb4d6f6bf6e353b7de208360968/images/tailscale-access-init.png -------------------------------------------------------------------------------- /images/tailscale-access-requesting-access.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/accessbot/473c11b28fdcfdb4d6f6bf6e353b7de208360968/images/tailscale-access-requesting-access.png -------------------------------------------------------------------------------- /images/tailscale-access-shortcut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/accessbot/473c11b28fdcfdb4d6f6bf6e353b7de208360968/images/tailscale-access-shortcut.png -------------------------------------------------------------------------------- /images/tailscale-access-unconfigured.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/accessbot/473c11b28fdcfdb4d6f6bf6e353b7de208360968/images/tailscale-access-unconfigured.png -------------------------------------------------------------------------------- /images/tailscale-oauth-client.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/accessbot/473c11b28fdcfdb4d6f6bf6e353b7de208360968/images/tailscale-oauth-client.png -------------------------------------------------------------------------------- /import_map.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "deno-slack-hooks/": "https://deno.land/x/deno_slack_hooks@1.3.0/", 4 | "deno-slack-sdk/": "https://deno.land/x/deno_slack_sdk@2.9.0/", 5 | "deno-slack-api/": "https://deno.land/x/deno_slack_api@2.3.2/" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /manifest.ts: -------------------------------------------------------------------------------- 1 | import { Manifest } from "deno-slack-sdk/mod.ts"; 2 | import { CreateAccessRequestWorkflow } from "./workflows/CreateAccessRequestWorkflow.ts"; 3 | import { AccessApprovalFunction } from "./functions/access_approval_prompt.ts"; 4 | import { AccessRequestFunction } from "./functions/access_request_prompt.ts"; 5 | import { TailscaleTokenStore } from "./datastores/tailscale.ts"; 6 | import { 7 | AccessType, 8 | ApproverType, 9 | DeviceType, 10 | ProfileType, 11 | } from "./types/tailscale.ts"; 12 | 13 | export default Manifest({ 14 | name: "Tailscale Access", 15 | description: "Ask for temporary access to devices in your Tailnet", 16 | icon: "./assets/avatar.png", 17 | workflows: [ 18 | CreateAccessRequestWorkflow, 19 | ], 20 | datastores: [ 21 | TailscaleTokenStore, 22 | ], 23 | functions: [ 24 | AccessRequestFunction, 25 | AccessApprovalFunction, 26 | ], 27 | types: [ 28 | ProfileType, 29 | DeviceType, 30 | ApproverType, 31 | AccessType, 32 | ], 33 | outgoingDomains: [ 34 | "api.tailscale.com", 35 | ], 36 | botScopes: [ 37 | "commands", 38 | "users:read", // look up user profile. 39 | "users:read.email", // look up user email address. 40 | "chat:write", 41 | "chat:write.public", 42 | "datastore:read", 43 | "datastore:write", 44 | ], 45 | }); 46 | -------------------------------------------------------------------------------- /slack.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "get-hooks": "deno run -q --allow-read --allow-net https://deno.land/x/deno_slack_hooks@1.3.0/mod.ts" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tailscale.ts: -------------------------------------------------------------------------------- 1 | import { Env, SlackAPIClient } from "deno-slack-sdk/types.ts"; 2 | import { AccessToken, TailscaleTokenStore } from "./datastores/tailscale.ts"; 3 | import { OAuth2, OAuth2Token } from "npm:fetch-mw-oauth2@1"; 4 | 5 | const ua = "tailscale-accessbot/0.0.1"; 6 | 7 | export type TailscaleRequestInit = RequestInit & { 8 | cacheSeconds?: number; 9 | }; 10 | 11 | /** 12 | * @param env The slack environment containing TAILSCALE_CLIENT_ID and TAILSCALE_CLIENT_SECRET. 13 | * @param client SlackAPIClient for accessing the Datastore where we persist temporary access tokens for re-use between bot interactions. 14 | * @returns 15 | */ 16 | export default function tailscale( 17 | env: Env, 18 | client: SlackAPIClient, 19 | ) { 20 | const clientId = env.TAILSCALE_CLIENT_ID; 21 | const clientSecret = env.TAILSCALE_CLIENT_SECRET; 22 | 23 | // Try to read an existing access token from the data store. 24 | const ts = client.apps.datastore.get({ 25 | datastore: TailscaleTokenStore.name, 26 | id: clientId, 27 | }) 28 | .catch((err) => { 29 | // If an error occurs, continue anyway. 30 | console.error("Exception reading token from datastore:", err); 31 | return null; 32 | }) 33 | .then((tokRes) => { 34 | // Try to retrieve a token, but not too hard. 35 | const tok = tokRes && tokRes.ok && tokRes.item.access_token 36 | ? { 37 | accessToken: tokRes.item.access_token, 38 | refreshToken: tokRes.item.refresh_token, 39 | expiresAt: tokRes.item.expires_at 40 | ? tokRes.item.expires_at * 1000 41 | : undefined, 42 | } as OAuth2Token 43 | : undefined; 44 | 45 | // Always generate an OAuth2 client for making requests. 46 | // It will attempt to generate its own tokens. 47 | return new OAuth2( 48 | { 49 | grantType: "client_credentials", 50 | tokenEndpoint: "https://api.tailscale.com/api/v2/oauth/token", 51 | clientId: clientId, 52 | clientSecret: clientSecret, 53 | 54 | onTokenUpdate: function (token: OAuth2Token) { 55 | // Persist updated tokensin the data store. 56 | client.apps.datastore.put({ 57 | datastore: TailscaleTokenStore.name, 58 | item: { 59 | client_id: clientId, 60 | access_token: token.accessToken, 61 | refresh_token: token.refreshToken, 62 | expires_at: token.expiresAt 63 | ? token.expiresAt / 1000 64 | : undefined, 65 | }, 66 | }).catch((err) => 67 | console.error("Error persisting tailscale access token:", err) 68 | ); 69 | }, 70 | }, 71 | tok, 72 | ); 73 | }); 74 | 75 | // Inject our user-agent to the fetch the requests. 76 | return ( 77 | input: RequestInfo, 78 | init?: TailscaleRequestInit, 79 | ): Promise => { 80 | // The actual request. 81 | const method = init?.method?.toUpperCase() || "GET"; 82 | const headers = new Headers(init?.headers); 83 | headers.set("User-Agent", ua); 84 | const res = ts.then((c) => c.fetch(input, { ...init, method, headers })); 85 | 86 | // Just use the request, if we can't or don't want to check the cache. 87 | if (!init?.cacheSeconds || method != "GET") { 88 | return res; 89 | } 90 | 91 | if (init.cacheSeconds) { 92 | throw new Error("cacheSeconds is still a work-in-progress"); 93 | } 94 | 95 | // Multiple consumers want the body - read it only once. 96 | const resBody = res 97 | .then(async (res) => ({ res, body: await res.text() })); 98 | 99 | // When the response completes, update the response cache. 100 | const key = clientId + ":" + (new Request(input, init).url); 101 | resBody.then(({ res, body }) => 102 | writeResponseCache(client, key, init.cacheSeconds!, res, body) 103 | ).catch(() => { 104 | // Swallow these errors - the returned promise will include it. 105 | }); 106 | 107 | return Promise.any([ 108 | readResponseCache(client, key), 109 | resBody.then(({ res, body }) => 110 | new Response(body, { 111 | status: res.status, 112 | statusText: res.statusText, 113 | headers: res.headers, 114 | }) 115 | ), 116 | ]); 117 | }; 118 | } 119 | 120 | async function writeResponseCache( 121 | client: SlackAPIClient, 122 | key: string, 123 | ttlSeconds: number, 124 | res: Response, 125 | body: string, 126 | ) { 127 | // Only cache good, re-usable responses. 128 | if (res.status != 200 && res.status !== 201) { 129 | console.log(`tscache skip: key=${key}: ${res.statusText}`); 130 | return; 131 | } 132 | 133 | // DynamoDB which backs the Slack datastores have an item limit of 400KB, 134 | // and we need to save a small amount of space for the other properties 135 | // we write into it. 136 | const len = body.length; 137 | if (len > 400_000) { 138 | console.log(`tscache skip: key=${key} len=${len}: too large`); 139 | return; 140 | } 141 | 142 | try { 143 | // TODO(icio): use a different datastore than the access token. 144 | const r = await client.apps.datastore.put({ 145 | datastore: TailscaleTokenStore.name, 146 | item: { 147 | client_id: key, 148 | expires_at: Date.now() / 1000 + ttlSeconds, 149 | access_token: JSON.stringify({ 150 | status: res.status, 151 | statusText: res.statusText, 152 | headers: [...res.headers.entries()], 153 | body: body, 154 | }), 155 | }, 156 | }); 157 | if (!r.ok) { 158 | console.error(`tscache error: key=${key} len=${len}:`, r.error); 159 | return; 160 | } 161 | console.debug(`tscache updated: key=${key} len=${len}`); 162 | } catch (exc) { 163 | console.error(`tscache error: key=${key} len=${len}:`, exc); 164 | } 165 | } 166 | 167 | function readResponseCache( 168 | client: SlackAPIClient, 169 | key: string, 170 | ): Promise { 171 | // TODO(icio): use a different datastore than the access token. 172 | return client.apps.datastore.get({ 173 | datastore: TailscaleTokenStore.name, 174 | id: key, 175 | }) 176 | .then((got) => { 177 | if (!got.ok) throw new Error(got.error); 178 | if (!got.item) throw new Error("no item"); 179 | if (got.item.expires_at * 1000 < Date.now()) { 180 | throw new Error("cache expired"); 181 | } 182 | if (!got.item.access_token) { 183 | throw new Error("empty item access_token"); 184 | } 185 | console.debug(`tscache: read: key=${key}:`, got.item); 186 | const { body, ...init } = JSON.parse(got.item.access_token); 187 | return new Response(body, init); 188 | }) 189 | .catch((err) => { 190 | console.error(`tscache read error: key=${key}:`, err); 191 | throw err; 192 | }); 193 | } 194 | -------------------------------------------------------------------------------- /triggers/trigger.ts: -------------------------------------------------------------------------------- 1 | import { Trigger } from "deno-slack-sdk/types.ts"; 2 | import { TriggerContextData, TriggerTypes } from "deno-slack-api/mod.ts"; 3 | 4 | const trigger: Trigger = { 5 | type: TriggerTypes.Shortcut, 6 | name: "Tailscale Access", 7 | description: "Request temporary access to devices in your Tailnet", 8 | workflow: "#/workflows/create_access_request", 9 | inputs: { 10 | interactivity: { 11 | value: TriggerContextData.Shortcut.interactivity, 12 | }, 13 | user: { 14 | value: TriggerContextData.Shortcut.user_id, 15 | }, 16 | }, 17 | }; 18 | 19 | export default trigger; 20 | -------------------------------------------------------------------------------- /types/slack.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FunctionDefinitionArgs, 3 | FunctionRuntimeParameters, 4 | } from "deno-slack-sdk/functions/types.ts"; 5 | 6 | export type SlackFunctionOutputs = Definition extends 7 | FunctionDefinitionArgs 8 | ? FunctionRuntimeParameters 9 | : never; 10 | 11 | export type SlackFunctionInputs = Definition extends 12 | FunctionDefinitionArgs 13 | ? FunctionRuntimeParameters 14 | : never; 15 | 16 | export type PlainTextObject = { 17 | type: "plain_text"; 18 | emoji?: boolean; 19 | text: string; 20 | }; 21 | export type Option = { 22 | text: PlainTextObject; 23 | value: string; 24 | }; 25 | export type OptionGroup = { 26 | label: PlainTextObject; 27 | options: Option[]; 28 | }; 29 | export type SuggestionResponse = 30 | | { options: Option[] } 31 | | { option_groups: OptionGroup[] }; 32 | -------------------------------------------------------------------------------- /types/tailscale.ts: -------------------------------------------------------------------------------- 1 | import { DefineType, Schema } from "deno-slack-sdk/mod.ts"; 2 | import { FunctionRuntimeParameters } from "deno-slack-sdk/functions/types.ts"; 3 | 4 | export const ProfileType = DefineType({ 5 | name: "Profile", 6 | type: Schema.types.object, 7 | additionalProperties: false, 8 | required: ["attribute", "description"], 9 | properties: { 10 | attribute: { 11 | type: Schema.types.string, 12 | }, 13 | description: { 14 | type: Schema.types.string, 15 | }, 16 | maxSeconds: { 17 | type: Schema.types.number, 18 | }, 19 | notifyChannel: { 20 | type: Schema.slack.types.channel_id, 21 | }, 22 | approverEmails: { 23 | type: Schema.types.array, 24 | items: { 25 | type: Schema.types.string, 26 | }, 27 | }, 28 | canSelfApprove: { 29 | type: Schema.types.boolean, 30 | default: false, 31 | }, 32 | confirmSelfApproval: { 33 | type: Schema.types.boolean, 34 | default: false, 35 | }, 36 | }, 37 | }); 38 | 39 | export const DeviceType = DefineType({ 40 | name: "Device", 41 | type: Schema.types.object, 42 | required: ["nodeId"], 43 | properties: { 44 | nodeId: { 45 | type: Schema.types.string, 46 | }, 47 | name: { 48 | type: Schema.types.string, 49 | }, 50 | addresses: { 51 | type: Schema.types.array, 52 | items: { 53 | type: Schema.types.string, 54 | }, 55 | }, 56 | tags: { 57 | type: Schema.types.array, 58 | items: { 59 | type: Schema.types.string, 60 | }, 61 | }, 62 | user: { 63 | type: Schema.types.string, 64 | }, 65 | os: { 66 | type: Schema.types.string, 67 | }, 68 | }, 69 | }); 70 | 71 | export const ApproverType = DefineType({ 72 | name: "Approver", 73 | type: Schema.types.object, 74 | additionalProperties: false, 75 | required: [ 76 | "userId", 77 | ], 78 | properties: { 79 | "userId": { 80 | type: Schema.slack.types.user_id, 81 | }, 82 | "email": { 83 | type: Schema.types.string, 84 | }, 85 | }, 86 | }); 87 | 88 | export type Access = FunctionRuntimeParameters< 89 | typeof AccessType.definition.properties, 90 | typeof AccessType.definition.required 91 | >; 92 | 93 | export const AccessType = DefineType({ 94 | name: "Access", 95 | type: Schema.types.object, 96 | additionalProperties: false, 97 | required: [ 98 | "profile", 99 | "requester", 100 | "device", 101 | "durationSeconds", 102 | "approver", 103 | "reason", 104 | ], 105 | properties: { 106 | requester: { 107 | type: Schema.slack.types.user_id, 108 | }, 109 | profile: { 110 | type: ProfileType, 111 | }, 112 | device: { 113 | type: DeviceType, 114 | }, 115 | approver: { 116 | type: ApproverType, 117 | }, 118 | durationSeconds: { 119 | type: Schema.types.number, 120 | }, 121 | reason: { 122 | type: Schema.types.string, 123 | }, 124 | }, 125 | }); 126 | -------------------------------------------------------------------------------- /workflows/CreateAccessRequestWorkflow.ts: -------------------------------------------------------------------------------- 1 | import { DefineWorkflow, Schema } from "deno-slack-sdk/mod.ts"; 2 | import { AccessRequestFunction } from "../functions/access_request_prompt.ts"; 3 | import { AccessApprovalFunction } from "../functions/access_approval_prompt.ts"; 4 | 5 | export const CreateAccessRequestWorkflow = DefineWorkflow({ 6 | callback_id: "create_access_request", 7 | title: "Request Tailscale Access", 8 | description: "Request temporary access to devices in your Tailnet", 9 | input_parameters: { 10 | properties: { 11 | interactivity: { 12 | type: Schema.slack.types.interactivity, 13 | }, 14 | user: { 15 | type: Schema.slack.types.user_id, 16 | }, 17 | }, 18 | required: ["interactivity", "user"], 19 | }, 20 | }); 21 | 22 | const accessRequest = CreateAccessRequestWorkflow.addStep( 23 | AccessRequestFunction, 24 | { 25 | interactivity: CreateAccessRequestWorkflow.inputs.interactivity, 26 | user: CreateAccessRequestWorkflow.inputs.user, 27 | }, 28 | ); 29 | 30 | CreateAccessRequestWorkflow.addStep(AccessApprovalFunction, { 31 | interactivity: accessRequest.outputs.interactivity, 32 | access: accessRequest.outputs.access, 33 | }); 34 | --------------------------------------------------------------------------------