├── .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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------