From f38e206f5995a89f4ce2cc32c3172904fd672f53 Mon Sep 17 00:00:00 2001 From: Paul Scott Date: Wed, 8 May 2024 09:53:44 +0100 Subject: [PATCH] Tailscale accessbot Co-authored-by: Anton Tolchanov Co-authored-by: Kristoffer Dalby --- .github/workflows/deno.yml | 33 ++ .gitignore | 5 + .slack/apps.json | 11 + .slack/config.json | 3 + .vscode/settings.json | 12 + LICENSE | 21 + README.md | 116 ++++ assets/avatar.png | Bin 0 -> 6784 bytes config.ts | 94 ++++ datastores/tailscale.ts | 24 + deno.jsonc | 9 + functions/access_approval_prompt.ts | 292 ++++++++++ functions/access_request_prompt.ts | 643 +++++++++++++++++++++++ import_map.json | 7 + manifest.ts | 45 ++ slack.json | 5 + tailscale.ts | 193 +++++++ triggers/trigger.ts | 19 + types/slack.ts | 31 ++ types/tailscale.ts | 125 +++++ workflows/CreateAccessRequestWorkflow.ts | 33 ++ 21 files changed, 1721 insertions(+) create mode 100644 .github/workflows/deno.yml create mode 100644 .gitignore create mode 100644 .slack/apps.json create mode 100644 .slack/config.json create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assets/avatar.png create mode 100644 config.ts create mode 100644 datastores/tailscale.ts create mode 100644 deno.jsonc create mode 100644 functions/access_approval_prompt.ts create mode 100644 functions/access_request_prompt.ts create mode 100644 import_map.json create mode 100644 manifest.ts create mode 100644 slack.json create mode 100644 tailscale.ts create mode 100644 triggers/trigger.ts create mode 100644 types/slack.ts create mode 100644 types/tailscale.ts create mode 100644 workflows/CreateAccessRequestWorkflow.ts diff --git a/.github/workflows/deno.yml b/.github/workflows/deno.yml new file mode 100644 index 0000000..1b288e3 --- /dev/null +++ b/.github/workflows/deno.yml @@ -0,0 +1,33 @@ +name: Deno app build and testing + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + deno: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Setup repo + uses: actions/checkout@v3 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v1.x + + - name: Verify formatting + run: deno fmt --check + + - name: Run linter + run: deno lint + + - name: Run tests + run: deno task test + + - name: Run type check + run: deno check *.ts && deno check **/*.ts && deno check **/**/*.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..baa4baa --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +dist +package +.DS_Store +.slack/apps.dev.json +.env diff --git a/.slack/apps.json b/.slack/apps.json new file mode 100644 index 0000000..1859e22 --- /dev/null +++ b/.slack/apps.json @@ -0,0 +1,11 @@ +{ + "apps": { + "TPQSV7ZK4": { + "app_id": "A06UZ165AT0", + "IsDev": false, + "team_domain": "tailscale", + "team_id": "TPQSV7ZK4" + } + }, + "default": "tailscale" +} \ No newline at end of file diff --git a/.slack/config.json b/.slack/config.json new file mode 100644 index 0000000..580a2c9 --- /dev/null +++ b/.slack/config.json @@ -0,0 +1,3 @@ +{ + "project_id": "258bfdcb-4a36-4de0-b71e-922b28a3af25" +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e42e32c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "deno.enable": true, + "deno.lint": true, + "deno.suggest.imports.hosts": { + "https://deno.land": false + }, + "[typescript]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "editor.tabSize": 2 +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..399e41d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Slack Technologies, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a5a0ee --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# Request Access + +This automation features an access request workflow where users can create a +request (with details) that is routed to another user to approve or deny. + +When approved, the workflow will assign a custom device posture attribute to the +requester's device. + +## Setup + +Before getting started, first make sure you have a development workspace where +you have permission to install apps. **Please note that the features in this +project require that the workspace be part of +[a Slack paid plan](https://slack.com/pricing).** + +### Install the Slack CLI + +To use this sample, you need to install and configure the Slack CLI. +Step-by-step instructions can be found in our +[Quickstart Guide](https://api.slack.com/automation/quickstart). + +### Configuration + +Configure your access profiles in `config.json`. You can see configuration +schema in `types/config.ts`. + +Create an OAuth client in Tailscale with the `devices:write` scope. For running +locally, put your OAuth client credentials into the `.env` file. In production, +configure the same variables using `slack env` commands after deploying, e.g. + +```bash +slack env add TAILSCALE_CLIENT_ID ... +slack env add TAILSCALE_CLIENT_SECRET .. +``` + +## Running Your Project Locally + +While building your app, you can see your changes appear in your workspace in +real-time with `slack run`. You'll know an app is the development version if the +name has the string `(local)` appended. + +```zsh +# Run app locally +$ slack run + +Connected, awaiting events +``` + +To stop running locally, press ` + C` to end the process. + +## Deploying Your App + +Once development is complete, deploy the app to Slack infrastructure using +`slack deploy`: + +```zsh +$ slack deploy +``` + +When deploying for the first time, you'll be prompted to +[create a new link trigger](#creating-triggers) for the deployed version of your +app. When that trigger is invoked, the workflow should run just as it did when +developing locally (but without requiring your server to be running). + +## Viewing Activity Logs + +Activity logs of your application can be viewed live and as they occur with the +following command: + +```zsh +$ slack activity --tail +``` + +## Project Structure + +### `.slack/` + +Contains `apps.dev.json` and `apps.json`, which include installation details for +development and deployed apps. + +### `datastores/` + +[Datastores](https://api.slack.com/automation/datastores) securely store data +for your application on Slack infrastructure. Required scopes to use datastores +include `datastore:write` and `datastore:read`. + +### `functions/` + +[Functions](https://api.slack.com/automation/functions) are reusable building +blocks of automation that accept inputs, perform calculations, and provide +outputs. Functions can be used independently or as steps in workflows. + +### `triggers/` + +[Triggers](https://api.slack.com/automation/triggers) determine when workflows +are run. A trigger file describes the scenario in which a workflow should be +run, such as a user pressing a button or when a specific event occurs. + +### `workflows/` + +A [workflow](https://api.slack.com/automation/workflows) is a set of steps +(functions) that are executed in order. + +Workflows can be configured to run without user input or they can collect input +by beginning with a [form](https://api.slack.com/automation/forms) before +continuing to the next step. + +### `manifest.ts` + +The [app manifest](https://api.slack.com/automation/manifest) contains the app's +configuration. This file defines attributes like app name and description. + +### `slack.json` + +Used by the CLI to interact with the project's SDK dependencies. It contains +script hooks that are executed by the CLI and implemented by the SDK. diff --git a/assets/avatar.png b/assets/avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..148d2aab0236bea2a75e810aa0cda0c6c81257a9 GIT binary patch literal 6784 zcmeHMdsI^C-rhQ97pti(Ybw*qw9BZOnx>4Sf8lj=@;&#jF*nLC4k7lpNm3g5fS0CNJy=?3%Q>QNZ0s=VIaLJ}_wBI1b`oZ1P`UW3IJAI1AydY70_WF0MOR{`;x!o@_#9Ykuxb*3oN2{ zMq3ng^JCnoU7E6Z^dyIIXJ6zydHm$a(YU!!M`uJidO?qBv74;tw)2;(a$k(*kBli;;KVK_L8lVNl00M69?8?u7Ha z|9RaJIO1;YRdZp&->Yi!wtH%6JpG`zw>L4Pm9nvXo3hf6Mv@V3y6>mX5nbZWAv|%~ zjK(41cb4lnhNRg7N9<^bxU&<7vqy_AXA7zraF1yL$Hgg-GL)yE-5P~R7>G;R(M)h6 z`&3^K2ncXJImj9M*o)bu8@8Su&2|s)&g@;40r-)GTWTIXy2e9B86Vrf)=hEqgTQXn zXkWW&(+DF|*7??(4^#lRh>71@xWYg~LUB_%7vGrG<@X57V?gKIA~FCnzvr0NhZAM+ zBkj#*@m8~MUe_2wJ4aLi_|#12t>DO>Ngbi!4s0x%cP0^oR+w=H!gVAwK{;6mgn{*j zv;-q&Hk1Tyz&xd6N4M(RAH-QcbGL2ak#DBWp?E9Z{TiE9aH61H}&DHa(ZS_bKsnRr#siP^Q-D0eMYe z-ulW&1*w1BoBf-1C3`H?)KaZn|5Id}#*0OO?y6JSv7g5A?hF3#P~whcxtNZPc}7;8 z`@>DcLX>rWGyJHF%(X4^*%uRUE^@36qQnDJF{JB|mGEnTpFfMGMVX31GJB3HLIp*& zC4J=LLb?+w2fe}r=GXvjHwSxVJL-p_s_3v?%%7mm<2QhJM}#{Xv#gQrL5&ug_P3#^ zx-15k^YviT2xNNA=cRZR;PTC^A1IW3I)Gx&+Ve9D75TGLPTpEA$NCc3s<6O%hUGWV zk!*l)I|+mLt@S6HtuRC`W*F=(9IqL2VFlo5W4*qbMaSmr${EqHR3v4{B$^^=;t(;# zr;0_ttM#aF?C2l$bh%$$UEQrBnc+%jr`^=SY&+fIlas_PDqY$@UVc3xn~5=MOF!3D zhRX+ic5qitCX#0T`v&b5$=I?AaAfymU&^i{;P23ZH~FP94CKhL@35JLcSF4 zGs290Y+Ss=Jo1BMTq3sxPxfdg|0I2P8Fr4D;il-}RN!7!ssrwjnwlE7Y6wqA7`y=! zv0qc4LVQ4|HL?eb9jD2?{j@K^4o8kAJMN+oi)<9j|N6#fgqc1S*Su??2WP4ky|B29L2{hV=jUj8r>JZGMmHum>@2vaaIHBuM zdU`rnIF4&*0K>^1z+eU9<#QNI7Aq(|P;Z&)#cn#w1-*B#E1jd2*seG4x#fHwGEX3W zxOL34lW9iI)aaO#o3&z<<6V9eah%=W8VVFhajE+V0q|UHszQ0Wk|E zmAG&lVKYaO7K({$2kedxqnGEGVcu`xPI9}5210@G#fGq@?Xg82jgRsb4N$leaP&af zmDGgJ1pN~F0XgEY@O|ZCptyJ}eT1fKP^>7tT)=OqP_s)wn+-)?t;-wx7(!^11&}Ni zmRbpb`ICg6NxmS7+VqX<;pQy=`k7pE_l!ZX;&(<%viTFSSo}i2@Pw>D)L74(tHnYE z8wIxO%O02E;PGtY^JbhlX|%7>9S#{xaR4^jSf|!R3_BxhEbh8O(4;YC{>{^0OUr0E zecHIgZSC=7b?Ek|vY?@H)IAC5u zzALe~4kcgX1!%Ani*rHs@T1(;C>(=O#ze9ViStbIb2NqZgtSO~%2NaXg-|=535>U_ zWHY9mNh!TI=c)tL+QETy22GWQZDf_53q18@h@Xip$MuDdHzNN8P&3lD?igt;^W*oe zly~i>-s`r4z!5-~rneSkWc&#fqyx4V>ndj5Pt$#wM@jQ$pb%YO+p*WsWP>2goq|_H z89GMHtR>aWyOhj1?4dZ?a?qa;>Fd(hn@}O{37QJ71kV56kL#{bCeG~?Xp@riu71W3 zFq?&4dxL|6iOUr+Ab`XEy30Nax8iic=Q&SsZpB&7Mx^X-5@>O%BK0bN>kK9{k(=ks z$DpNQ+HuJ_u*&{ZLQ;)Vevzpo8Htb3Y=Qa zfcWZBCXZ(s6gzamnqD^{doey-uj#L(V1r*kqkj}Z&v_kIdN~1B1Er}V8@RVB*5;&_ z*Ws3yUGN%_h2dRkt+d0qLxk-yatToJ{#ax!7Xg!wWa(+k{fnQKy zvW`)Xq2k6uLhdKgVKaAK0&0u6pX>Pa&9%+_;d)VHZzUbS-=@PJ{#k>Z?BAge4yBSX zgeHGZG<~_!Xz5R1afssGX9t8o3Fh+p-MV}IwbV7|p$yHiGUeMX zrNb{bKcY}H_Nah5CL7c-gI$H>Iw3M2Tj0>RxiiIrgLMfO)SE3ye!pS|bj@wx-h+gS zsEOYjD#E$O#3kO-N!b(%#dX6Fe(o!HExI7U1`{(vsCL@Gc3my2#0fRu^=uuFq68DM z?8UTqw0myWOxWAHeKjw9ixNcdgu?SB4AnQGX_?+M|M^8W~rDg z&ykH^hTSjrMx1aqqf7Z6ZRb2jm!hxl+O>=8f|0q+Ok^e9$Y;bj=zuh<<$ubayQ0JR z+A=JUkM`^mhb3%P^o_mjWdDWt866>_#)a7C7zYz@Z*%&{vuC!Xl8!c?WhWg)3_594 zihD7l`;SWplKD5UfKte9+CIuxeQj5bU~byfNEtjQ)Y$23eVuQ?;`f=_BOJA?lJ2Np zWV@p!&T=#-(ah97Gr6FEU(w5Pj1H{bwnw1+u1UDeP3;yBqauE|EBzQUqP$10$Ki8+ z-YO3qnq4gvAOb1gg&;la9d+H+(%>Ff&E8Ou_Nw6B9xSVhL1e^}eHBp><%&p*|0$qKTn*=)^sI&Ax{yUY`1aFlD zVNltyHQ_#nJ7?e_R0VHWAnk|XC#TP~bjUd+4zJ#X4(w2R`j5Vt)s3sD%cZ=gsl*7gZK=HlY168Q=fDZCEKe@1A!-VKFK75Z{CC|gjVdYLFIM$k(iR+ zLqnQputNnHP*?F4FuQ7CE8K(sJtBAl^r&Ucg5>$-PA&zL&Gno#qez$jNU1vBhMf+f z&WkbyO!HLCQ@xqr0jxhOH=i(CKe@f&QmDPJ&ULN)9nqnYj_?L%W zy26nTe0Pk8Gy~+KM1Bm&DA#rR|oJ|4~uh6b;L==p9 zoLTuvwi{5@hq>~Pt=>mbP?v)UJ63{8ZdT#lxuSec`+mu^cY=oBduc|qa--rX>hYjH zAeLG`{Di+irAJy;GG}H=zvS%A)YRots{$`^G{ly@;=l957mmtjKj%AkW#afdDiI}I zUdX#l94x?WXjdM5MC5J$asG;TNBS-_M~wD+k}lSjhhQr!E8Q1Tye}LErK04> zEgOwdTmf>`X0#_}{I_rCX#t%+A$30{QCI5ek1NU6QfsA)z~M&P#nN8{6l-f`yybJ8 zoN_S3H8|TgLOU5FjYH;-`pV<@&Qe$jk%h7rqItNWJZ{~mXJQq2#dHl*Jt#EPz|@A8 z6epKSL9w~bM(>jEErjo3r)%U{PF;ktsf{|7!Ilb!xLFHDU5b)*WFWjWwfD#bh!krz}e#AuGemqqnlFBJh=Ah8672 z7*JMaXBBH0=-)>OY%P{cVg-_H^2*G+!ro*i6Wu9no=6x=0t<4WJ-*fhE^R+u3(2ZR z(qKxByryUF1@(#mZup&YJb8!A*^6G;Pg=Vs(F;V%|15TE)+#$VI0VJU4jsg;YqFHe z*hw{svr{7(Nhm8OY%%M9DTLpCH?+V+UfUp&SQI!z{F1!vqBf?DGkxi&@sDj6CDqc} z+KUy{y)IJOhmnE(5>*%o&gJaRDCj|t(BSW9<$u2*_%| literal 0 HcmV?d00001 diff --git a/config.ts b/config.ts new file mode 100644 index 0000000..dfdf622 --- /dev/null +++ b/config.ts @@ -0,0 +1,94 @@ +export const config: Config = { + profiles: [ + { + description: "Accessbot Test", + attribute: "custom:accessbotTester", + canSelfApprove: true, + confirmSelfApproval: true, + }, + { + attribute: "custom:prodAccess", + description: "Production", + notifyChannel: "C06TH49GKHC", + canSelfApprove: true, + approverEmails: [ + "paul@tailscale.com", + "anton@tailscale.com", + "kristoffer@tailscale.com", + "apenwarr@tailscale.com", + "bradfitz@tailscale.com", + "unknown@tailscale.com", + ], + }, + { + attribute: "custom:stagingAccess", + description: "Staging", + notifyChannel: "C06TH49GKHC", + canSelfApprove: true, + }, + { + attribute: "custom:bust", + description: "Only unrecognised reviewers", + notifyChannel: "C06TH49GKHC", + canSelfApprove: false, + approverEmails: [ + "unknown@tailscale.com", + "nobody@tailscale.com", + "dgentry@tailscale.com", // :( + ], + }, + ], +}; + +export type Config = { + /** + * Profiles must be a non-empty set of configuration. + */ + profiles: [Profile, ...Profile[]]; +}; + +export type Profile = { + /** + * The human-readable name for the profile being granted access to by the attribute. + * @example "Production" + */ + description: string; + /** + * The tailscale attribute added to a device for the selected duration, upon + * the request being approved. + */ + attribute: string; + + /** + * The maximum duration to offer the user when they are requesting access to + * this profile. + * @default undefined (meaning offer all preset durations to the user) + */ + maxSeconds?: number; + /** + * The channel identifier to post approve/deny updates to. + * @example "CQ12VV345" + * @default undefined (meaning no public channel updates) + */ + notifyChannel?: string; + + /** + * Email addresses of people who may approve an access request. These are + * looked-up to find the relevant slack users. + * @default undefined (meaning anybody can approve) + */ + approverEmails?: string[]; + + /** + * Whether a user can mark themselves as the approver for a request. + * @default false + */ + canSelfApprove?: boolean; + + /** + * Whether a user self-approving is prompted to approve their own access + * request. Can be set to true to show them the prompt anyway. + * @default false (skip self-approval) + */ + confirmSelfApproval?: boolean; +}; diff --git a/datastores/tailscale.ts b/datastores/tailscale.ts new file mode 100644 index 0000000..c349112 --- /dev/null +++ b/datastores/tailscale.ts @@ -0,0 +1,24 @@ +// /datastores/drafts.ts +import { DefineDatastore, Schema } from "deno-slack-sdk/mod.ts"; + +export const TailscaleTokenStore = DefineDatastore({ + name: "tailscale_access_token", + primary_key: "client_id", + time_to_live_attribute: "expires_at", + attributes: { + client_id: { + type: Schema.types.string, + }, + access_token: { + type: Schema.types.string, + }, + expires_at: { + type: Schema.slack.types.timestamp, + }, + refresh_token: { + type: Schema.types.string, + }, + }, +}); + +export type AccessToken = typeof TailscaleTokenStore.definition; diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 0000000..3dbada6 --- /dev/null +++ b/deno.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "https://deno.land/x/deno/cli/schemas/config-file.v1.json", + "importMap": "import_map.json", + "lock": false, + "exclude": [".*"], + "tasks": { + "test": "deno fmt --check && deno lint && deno test --allow-read --allow-none" + } +} diff --git a/functions/access_approval_prompt.ts b/functions/access_approval_prompt.ts new file mode 100644 index 0000000..8bdc405 --- /dev/null +++ b/functions/access_approval_prompt.ts @@ -0,0 +1,292 @@ +import { SlackFunction } from "deno-slack-sdk/mod.ts"; +import tailscale from "../tailscale.ts"; +import { DefineFunction, Schema } from "deno-slack-sdk/mod.ts"; +import { Access, AccessType } from "../types/tailscale.ts"; +import { presetDurations } from "./access_request_prompt.ts"; +import { Env, SlackAPIClient } from "deno-slack-sdk/types.ts"; +import { BaseResponse } from "deno-slack-api/types.ts"; + +const APPROVE_ID = "approve_request"; +const DENY_ID = "deny_request"; + +export const AccessApprovalFunction = DefineFunction({ + callback_id: "access_approval_prompt", + title: "Access Request Approval", + description: "Sends an access request to an approver for review", + source_file: "functions/access_approval_prompt.ts", + input_parameters: { + properties: { + interactivity: { + type: Schema.slack.types.interactivity, + }, + access: { + // TODO(icio): By accepting all of the nested fields as arguments, we + // allow callers to specify mismatched device name/nodeId/addresses to + // mislead the approver. Workflow Builder also does not support complex + // objects, so having a flat list of properties would enable users to + // wire up their own approval flows. + type: AccessType, + }, + }, + required: [ + "interactivity", + "access", + ], + }, + output_parameters: { + properties: {}, + required: [], + }, +}); + +export default SlackFunction( + AccessApprovalFunction, + async ({ env, inputs, client }) => { + const { profile, requester, approver } = inputs.access; + + if (requester === approver.userId && profile.confirmSelfApproval !== true) { + await approve(env, client, true, inputs.access); + return { + completed: true, + outputs: {}, + }; + } + + // Create a block of Block Kit elements composed of several header blocks + // plus the interactive approve/deny buttons at the end + const blocks = accessRequestHeaderBlocks(inputs.access).concat([{ + "type": "actions", + "block_id": "approve-deny-buttons", + "elements": [ + { + type: "button", + text: { + type: "plain_text", + text: "Approve", + }, + action_id: APPROVE_ID, + style: "primary", + }, + { + type: "button", + text: { + type: "plain_text", + text: "Deny", + }, + action_id: DENY_ID, + style: "danger", + }, + ], + }]); + + const msgResponse = await client.chat.postMessage({ + channel: approver.userId, + blocks, + text: "You have been asked to approve Tailscale access", + }); + + if (!msgResponse.ok) { + const msg = `Error sending message to approver: ${msgResponse.error}"`; + console.log(msg); + return { error: msg }; + } + return { + completed: false, + }; + }, + // Create an 'actions handler', which is a function that will be invoked + // when specific interactive Block Kit elements (like buttons!) are interacted + // with. +).addBlockActionsHandler( + // listen for interactions with components with the following action_ids + [APPROVE_ID, DENY_ID], + // interactions with the above two action_ids get handled by the function below + async function ({ action, env, inputs, body, client }) { + // Send the approval. + const approved = action.action_id == APPROVE_ID; + await approve(env, client, approved, inputs.access); + + // Update the approver's message. + const msgUpdate = await client.chat.update({ + channel: body.container.channel_id, + ts: body.container.message_ts, + blocks: accessRequestHeaderBlocks(inputs.access).concat([ + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `${ + approved ? " :white_check_mark: Approved" : ":x: Denied" + }`, + }, + ], + }, + ]), + }); + if (!msgUpdate.ok) { + const msg = + `Error updating approver message requester: ${msgUpdate.error}"`; + console.log(msg); + return { error: msg }; + } + + await client.functions.completeSuccess({ + function_execution_id: body.function_data.execution_id, + completed: true, + outputs: {}, + }); + }, +); + +async function approve( + env: Env, + client: SlackAPIClient, + approved: boolean, + access: Access, +) { + const { profile, requester, durationSeconds, device, reason, approver } = + access; + + const channels = [requester]; + if (profile.notifyChannel) { + channels.push(profile.notifyChannel); + } + + const requesterRes = client.users.info({ user: requester }).catch(( + err, + ) => (console.error("Error loading requester user info:", err), undefined)); + const approverRes = client.users.info({ user: approver.userId }).catch(( + err, + ) => (console.error("Error loading approver user info:", err), undefined)); + + const msg = { + blocks: [{ + type: "context", + elements: [ + { + type: "mrkdwn", + text: + `<@${requester}>'s access request for ${profile.description} on <${ + tailscaleMachineLink( + access.device.nodeId, + access.device.addresses, + ) + }|${access.device.name || access.device.nodeId}>` + + `${reason ? ` for "${reason}"` : ""} was ${ + approved ? " :white_check_mark: Approved" : ":x: Denied" + } by <@${approver.userId}>`, + }, + ], + }], + text: `<@${requester}>'s access request was ${ + approved ? "approved" : "denied" + }!`, + }; + try { + await Promise.all( + channels.map((channel) => + client.chat.postMessage({ channel, ...msg }).then((r) => { + if (r.ok) return r; + throw new Error(`Error sending message to ${channel}: ${r.error}`); + }) + ), + ); + } catch (e) { + console.error(e.message); + return { error: e.message }; + } + + // Update Tailscale with the new attr request. + if (approved) { + const r = await tailscale(env, client)( + `https://api.tailscale.com/api/v2/device/${ + encodeURIComponent(device.nodeId) + }/attributes/${encodeURIComponent(profile.attribute)}`, + { + method: "POST", + body: JSON.stringify({ + value: true, + expiry: new Date(Date.now() + durationSeconds * 1000).toISOString(), + comment: + `Tailscale Access Slackbot: request from ${ + userref(await requesterRes) + } approved by ${userref(await approverRes)}` + + (reason ? `\nReason: ${reason}` : ""), + }), + }, + ); + console.info("tailscale attr update:", r.statusText, await r.text()); + } +} + +function userref(res?: BaseResponse): string { + if (res?.user?.name) { + if (res?.user?.real_name) { + return res.user.real_name + " (" + res.user.name + ")"; + } + return res.user.name; + } + return ""; +} + +// deno-lint-ignore no-explicit-any +function accessRequestHeaderBlocks(access: any): any[] { + return [ + { + type: "section", + text: { + type: "mrkdwn", + text: + `*:wave: <@${access.requester}> is requesting Tailscale access to ${access.profile.description}.*${ + access.reason ? "\n\nReason:\n>" + access.reason : "" + }`, + }, + fields: [ + { + type: "mrkdwn", + text: `:${osEmoji(access.device.os)}: <${ + tailscaleMachineLink(access.device.nodeId, access.device.addresses) + }|${access.device.name || access.device.nodeId}>`, + }, + { + type: "mrkdwn", + text: access.device.tags + ? `:robot_face: \`${access.device.tags.join("\` \`")}\`` + : `:bust_in_silhouette: ${access.device.user}`, + }, + { + type: "mrkdwn", + text: `:stopwatch: ${ + presetDurations.find((d) => d.seconds === access.durationSeconds) + ?.text || (access.durationSeconds + " seconds") + }`, + }, + { + type: "mrkdwn", + text: `:label: \`${access.profile.attribute}\``, + }, + ], + }, + ]; +} + +function osEmoji(os?: string): string { + switch (os) { + case "android": + case "iOS": + return "iphone"; + case "tvOS": + return "tv"; + default: + return "computer"; + } +} + +function tailscaleMachineLink(nodeId: string, addresses?: string[]): string { + const m = "https://login.tailscale.com/admin/machines"; + if (addresses?.[0]) { + return m + "/" + addresses[0]; + } + return m + "?q=" + encodeURIComponent(nodeId); +} diff --git a/functions/access_request_prompt.ts b/functions/access_request_prompt.ts new file mode 100644 index 0000000..85b4add --- /dev/null +++ b/functions/access_request_prompt.ts @@ -0,0 +1,643 @@ +import { SlackFunction } from "deno-slack-sdk/mod.ts"; +import tailscale from "../tailscale.ts"; +import { config } from "../config.ts"; +import { SlackFunctionOutputs, SuggestionResponse } from "../types/slack.ts"; +import { Env, SlackAPIClient } from "deno-slack-sdk/types.ts"; +import { DefineFunction, Schema } from "deno-slack-sdk/mod.ts"; +import { AccessType } from "../types/tailscale.ts"; + +export const AccessRequestFunction = DefineFunction({ + callback_id: "access_request_prompt", + title: "Request Tailscale Access", + source_file: "functions/access_request_prompt.ts", + input_parameters: { + properties: { + interactivity: { + type: Schema.slack.types.interactivity, + }, + user: { + type: Schema.slack.types.user_id, + }, + }, + required: [ + "interactivity", + "user", + ], + }, + output_parameters: { + properties: { + interactivity: { + type: Schema.slack.types.interactivity, + }, + access: { + type: AccessType, + }, + }, + required: [ + "interactivity", + "access", + ], + }, +}); + +const ACTION_PROFILE = "profile", + ACTION_REASON = "reason", + ACTION_DURATION = "duration", + ACTION_APPROVER = "approver", + ACTION_DEVICE = "device", + SUBMIT_ID = "request_form"; + +export default SlackFunction( + AccessRequestFunction, + async ({ inputs, env, client }) => { + // Open the empty modal. + const r = await client.views.open({ + interactivity_pointer: inputs.interactivity.interactivity_pointer, + view: await buildView(env, client, inputs.interactivity.interactor.id), + }); + if (r.error || !r.ok) { + console.error("Error opening view:", r); + return { + error: `opening view: ${r.error || "unknown error"}`, + }; + } + return { completed: false }; + }, +) + .addBlockActionsHandler(ACTION_PROFILE, async ({ client, env, body }) => { + // Update the fields dependent on the selected profile. + const r = await client.views.update({ + view_id: body.view.id, + hash: body.view.hash, + ...await buildView(env, client, body.user.id, body.view?.state), + }); + if (r.error == "hash_collision") { + return; + } + if (r.error || !r.ok) { + return { + error: `updating view: ${r.error || "unknown error"}`, + }; + } + }) + .addBlockSuggestionHandler( + ACTION_DEVICE, + async ( + { client, env, inputs, body }, + ): Promise => { + // Start fetching the user profile. + const requesterInfoRes = client.users.info({ + user: inputs.interactivity.interactor.id, + }).catch((err) => ({ + ok: false, + error: err, + user: undefined, + })); + + type Device = { + nodeId: string; + name: string; + user: string; + lastSeen: string; + tags: string[]; + addresses: string[]; + }; + + const query = body.value.trim().toLowerCase(); + + let devices: Device[]; + try { + // Fetch the list of devices. + const ts = tailscale(env, client); + const r = await ts( + "https://api.tailscale.com/api/v2/tailnet/-/devices", + ); + if (r.status !== 200) { + throw new Error(r.statusText); + } + + // Filterthe devices by the query. + devices = (await r.json()).devices as Device[]; + if (devices?.length && query) { + devices = devices.filter( + (d: Device) => + d.name.includes(query) || d.nodeId.startsWith(query) || + d.addresses?.some((ip) => ip.startsWith(query)), + ); + } + } catch (e) { + console.error("Error loading devices", e); + return { + options: [{ + value: "!", + text: { + type: "plain_text", + emoji: true, + text: `:warning: Error retreiving devices: ${e.message}`, + }, + }], + }; + } + + if (!devices || !devices.length) { + return { options: [] }; + } + + // List the user's top devices. + let yourDevices: Device[] = []; + try { + // Wait for the email. + const requesterInfo = await requesterInfoRes; + if (!requesterInfo.ok) { + throw new Error( + `looking up requester info: ${requesterInfo.error}"`, + ); + } + const email = requesterInfo?.user?.profile?.email?.toLowerCase(); + if (!email) { + throw new Error(`no email in: ${requesterInfo}"`); + } + + // Filter the devices, sorted by most recently active. + yourDevices = devices.filter((d) => + d.user.toLowerCase() == email && !d.tags + ); + yourDevices.sort((a, b) => a.lastSeen < b.lastSeen ? 1 : -1); + } catch (err) { + // Carry on with the rest of our devices, even if we didn't get an email + // to provide a pre-filtered list. + console.error("Error loading user email to filter devices:", err); + } + + return { + option_groups: [ + { + label: { + type: "plain_text", + text: "Your Devices", + }, + options: yourDevices.slice(0, 80).map((d) => ({ + value: d.nodeId, + text: { + type: "plain_text", + text: d.name, + }, + })), + }, + { + label: { + type: "plain_text", + text: "All Devices", + }, + options: devices.slice(0, 20).map((d) => ({ + value: d.nodeId, + text: { + type: "plain_text", + text: d.name, + }, + })), + }, + ].filter((g) => g.options?.length > 0), + } as SuggestionResponse; + }, + ) + .addViewSubmissionHandler( + SUBMIT_ID, + async ({ body, env, client, inputs }) => { + const errors: Record = {}; + const s = formState(body.view.state); + + // Validate the profile. + const profile = config.profiles.find((p) => p.attribute === s.profile); + if (!profile) { + errors[ACTION_PROFILE] = + `Access with attribute ${s.profile} could not be found.`; + } + if (!s.profile) { + errors[ACTION_PROFILE] = "Choose which access to request."; + } + + // Validate the device. + if (!s.device || s.device === "!") { + errors[ACTION_DEVICE] = "Choose which device to use."; + } + + // Validate the approver. + let [approverId, approverEmail] = (s.approver || "").split(":", 2); + if (!approverId) { + errors[ACTION_APPROVER] = "An approver is required to confirm access."; + } else if (profile) { + if (!profile.canSelfApprove && approverId === inputs.user) { + errors[ACTION_APPROVER] ||= `You cannot approve your own access to ${ + profile?.description || "this profile" + }.`; + } + // If the user was presented with a user_select to choose the approver, + // then we didn't pass through the email address in the value, so load it. + if (!approverEmail) { + try { + const r = await client.users.info({ user: approverId }); + approverEmail = r.user?.profile?.email || ""; + if (r.error) { + errors[ACTION_APPROVER] ||= "Error loading approver email: " + + r.error; + } + } catch (e) { + errors[ACTION_APPROVER] ||= "Error loading approver email: " + e; + } + } + // Validate the approver is allowed by the profile configuration. + approverEmail = approverEmail.trim().toLowerCase(); + console.log({ + approverId, + approverEmail, + allowed: profile.approverEmails, + }); + if ( + profile.approverEmails?.length && + !profile.approverEmails.some((e) => + e.trim().toLowerCase() == approverEmail + ) + ) { + errors[ACTION_APPROVER] ||= + `The user you selected cannot approve access to ${profile.description}.`; + } + } + + // Return any validation errors that we've found. + for (const _ in errors) { + return { + response_action: "errors", + errors, + }; + } + + // Load the details of the device that we selected. + let device; + try { + device = await tailscale(env, client)( + `https://api.tailscale.com/api/v2/device/${ + encodeURIComponent(s.device!) + }`, + ) + .then((r) => r.json()); + } catch (e) { + console.trace(`error fetching tailscale device: ${e}`); + } + + const outputs: SlackFunctionOutputs< + typeof AccessRequestFunction.definition + > = { + interactivity: body.interactivity, + access: { + requester: inputs.user, + profile: profile!, + approver: { + userId: approverId, + email: approverEmail, + }, + device: { + nodeId: s.device!, + name: device?.name || undefined, + tags: device?.tags || undefined, + user: device?.user || undefined, + addresses: device?.addresses || undefined, + os: device?.os || undefined, + }, + reason: s.reason!, + durationSeconds: parseInt(s.duration!, 10), + }, + }; + console.log("done", body.view.state, s, outputs); + + // Pass the request data onto the next workflow step. + await client.functions.completeSuccess({ + function_execution_id: body.function_data.execution_id, + outputs, + }); + }, + ); + +type FormState = { + [ACTION_PROFILE]?: string; + [ACTION_DEVICE]?: string; + [ACTION_DURATION]?: string; + [ACTION_APPROVER]?: string; + [ACTION_REASON]?: string; +}; + +// deno-lint-ignore no-explicit-any +function formState(state: any): FormState { + const s: FormState = {}; + for (const blockId in state.values) { + const block = state.values[blockId]; + for (const actionId in block) { + // Filter out bad fields so that s[actionId] is known to be a valid field. + if ( + actionId !== ACTION_APPROVER && actionId !== ACTION_DEVICE && + actionId !== ACTION_DURATION && actionId !== ACTION_PROFILE && + actionId !== ACTION_REASON + ) { + continue; + } + + // Set the field from the inputs. + const act = block[actionId]; + if (act["selected_option"]) { + s[actionId] = act.selected_option.value; + } + if (act["selected_user"]) { + s[actionId] = act.selected_user; + } + if (act["selected_users"]) { + s[actionId] = act.selected_users; + } + if ("value" in act) { + s[actionId] = act.value; + } + } + } + return s; +} + +const minuteSecs = 60; +const hourSecs = 60 * 60; + +export const presetDurations = [ + { text: "5 minutes", seconds: 5 * minuteSecs }, + { text: "30 minutes", seconds: 30 * minuteSecs }, + { text: "1 hour", seconds: 1 * hourSecs }, + { text: "4 hours", seconds: 4 * hourSecs }, + { text: "8 hours", seconds: 8 * hourSecs }, + { text: "12 hours", seconds: 12 * hourSecs }, + { text: "24 hours", seconds: 24 * hourSecs }, +]; + +async function buildView( + env: Env, + client: SlackAPIClient, + userId?: string, + // deno-lint-ignore no-explicit-any + viewState: any = {}, +) { + if (!env.TAILSCALE_CLIENT_ID || !env.TAILSCALE_CLIENT_SECRET) { + return buildEnvvarView(); + } + + const state = formState(viewState); + const profile = config.profiles.find((p) => p.attribute === state.profile); + const profileOpts = config.profiles.map((p) => ({ + value: p.attribute, + text: { + type: "plain_text", + text: p.description, + }, + })); + + let durations = presetDurations; + if (profile && profile.maxSeconds) { + durations = durations.filter((d) => d.seconds <= profile.maxSeconds!); + } + const durationOpts = durations.map((d) => ({ + text: { type: "plain_text", text: d.text }, + value: d.seconds.toFixed(0), + })); + state.duration ||= durationOpts[0].value; + + return { + type: "modal", + callback_id: SUBMIT_ID, + title: { + type: "plain_text", + text: "Requesting Access", + }, + + submit: { + type: "plain_text", + text: "Submit", + }, + close: { + type: "plain_text", + text: "Cancel", + }, + clear_on_close: true, // Do we want or not want this? + 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? + // 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." + blocks: [ + { + block_id: "profile", + type: "input", + dispatch_action: true, + label: { + type: "plain_text", + emoji: true, + text: `:closed_lock_with_key: What do you want to access?`, + }, + element: { + action_id: ACTION_PROFILE, + type: "static_select", + placeholder: { + type: "plain_text", + text: "Choose access...", + }, + options: profileOpts, + initial_option: state.profile + ? profileOpts.find((p) => p.value === state.profile) + : undefined, + }, + }, + { + block_id: "device", + type: "input", + label: { + type: "plain_text", + emoji: true, + text: `:computer: Which device are you using?`, + }, + element: { + action_id: ACTION_DEVICE, + type: "external_select", + placeholder: { + type: "plain_text", + text: "Choose device...", + }, + min_query_length: 0, + }, + }, + state.profile && { + block_id: "duration", + type: "input", + label: { + type: "plain_text", + emoji: true, + text: ":stopwatch: For how long?", + }, + element: { + action_id: ACTION_DURATION, + type: "static_select", + placeholder: { + type: "plain_text", + text: "Choose duration...", + }, + options: durationOpts, + initial_option: durationOpts.find((d) => d.value === state.duration), + }, + }, + state.profile && await buildApproverBlock( + client, + userId, + profile?.canSelfApprove, + profile?.approverEmails, + ), + state.profile && { + block_id: "reason", + type: "input", + label: { + type: "plain_text", + emoji: true, + text: ":open_book: What do you need the access for?", + }, + element: { + action_id: ACTION_REASON, + type: "plain_text_input", + placeholder: { + type: "plain_text", + text: "Enter reason...", + }, + }, + }, + ].filter(Boolean), + }; +} + +async function buildApproverBlock( + client: SlackAPIClient, + userId?: string, + showSelf?: boolean, + emails?: string[], +) { + if (!emails?.length || emails.length > 10) { + // We can't use radio buttons for this. + return { + block_id: "approver", + type: "input", + label: { + type: "plain_text", + emoji: true, + text: ":sleuth_or_spy: Who should approve?", + }, + element: { + action_id: ACTION_APPROVER, + type: "users_select", + placeholder: { + type: "plain_text", + emoji: true, + text: "Choose an approver...", + }, + }, + }; + } + + // We can't use the users_select with a specific set of users, but we can show + // up to 10 radio buttons. + const users = await Promise.all( + emails.map((email) => client.users.lookupByEmail({ email })), + ); + + // Filter the list of approvers to successful responses. + const approvers = users.filter((u) => + u.ok && u.user && !u.user.deleted && + (showSelf || emails.length == 1 || u.user.id != userId) + ); + + // Warn about any users who could not be found by email. + const failedLooksup = users.map((u, i) => + u.ok && u.user && !u.user.deleted ? null : emails[i] + ).filter( + Boolean, + ); + + return { + block_id: "approver", + type: "input", + label: { + type: "plain_text", + emoji: true, + text: ":sleuth_or_spy: Who should approve?", + }, + hint: failedLooksup?.length + ? { + type: "plain_text", + text: `Lookups failed for: ${failedLooksup.join(", ")}`, + } + : undefined, + element: { + action_id: ACTION_APPROVER, + type: "radio_buttons", + options: approvers.length + ? approvers.map((u) => ({ + value: u.user.id + ":" + (u.user.profile?.email || ""), + text: { + type: "mrkdwn", + text: `<@${u.user.id}> - ${u?.user?.profile?.real_name}${ + userId == u.user.id ? " (You)" : "" + }`, + }, + description: { + type: "plain_text", + text: "Local time: " + localTime(u.user.tz_offset), + }, + })) + : [{ + // FIXME: What happens when we try to let the user do this? + value: "!", + text: { + type: "plain_text", + emoji: true, + text: ":warning: No reviewers could be found.", + }, + }], + }, + }; +} + +/** + * @param offsetSeconds The seconds east of UTC (Slack's user.tz_offset). + * @returns + */ +function localTime(offsetSeconds: number): string { + const now = new Date(); + now.setUTCSeconds(offsetSeconds + now.getTimezoneOffset() * 60); + return now.toLocaleTimeString(undefined, { + timeStyle: "short", + hourCycle: "h12", + }); +} + +function buildEnvvarView() { + return { + type: "modal", + title: { + type: "plain_text", + text: "Requesting Access", + }, + close: { + type: "plain_text", + text: "Cancel", + }, + clear_on_close: true, + notify_on_close: false, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: ":warning: This workflow requires configuring the " + + "`TAILSCALE_CLIENT_ID` and `TAILSCALE_CLIENT_SECRET` " + + "environment variables. Without it, API requests to " + + "Tailscale would fail.", + }, + }, + ], + }; +} diff --git a/import_map.json b/import_map.json new file mode 100644 index 0000000..1e38786 --- /dev/null +++ b/import_map.json @@ -0,0 +1,7 @@ +{ + "imports": { + "deno-slack-hooks/": "https://deno.land/x/deno_slack_hooks@1.3.0/", + "deno-slack-sdk/": "https://deno.land/x/deno_slack_sdk@2.9.0/", + "deno-slack-api/": "https://deno.land/x/deno_slack_api@2.3.2/" + } +} diff --git a/manifest.ts b/manifest.ts new file mode 100644 index 0000000..3ad41c7 --- /dev/null +++ b/manifest.ts @@ -0,0 +1,45 @@ +import { Manifest } from "deno-slack-sdk/mod.ts"; +import { CreateAccessRequestWorkflow } from "./workflows/CreateAccessRequestWorkflow.ts"; +import { AccessApprovalFunction } from "./functions/access_approval_prompt.ts"; +import { AccessRequestFunction } from "./functions/access_request_prompt.ts"; +import { TailscaleTokenStore } from "./datastores/tailscale.ts"; +import { + AccessType, + ApproverType, + DeviceType, + ProfileType, +} from "./types/tailscale.ts"; + +export default Manifest({ + name: "Tailscale Access", + description: "Ask for temporary access to devices in your Tailnet", + icon: "./assets/avatar.png", + workflows: [ + CreateAccessRequestWorkflow, + ], + datastores: [ + TailscaleTokenStore, + ], + functions: [ + AccessRequestFunction, + AccessApprovalFunction, + ], + types: [ + ProfileType, + DeviceType, + ApproverType, + AccessType, + ], + outgoingDomains: [ + "api.tailscale.com", + ], + botScopes: [ + "commands", + "users:read", // look up user profile. + "users:read.email", // look up user email address. + "chat:write", + "chat:write.public", + "datastore:read", + "datastore:write", + ], +}); diff --git a/slack.json b/slack.json new file mode 100644 index 0000000..655963f --- /dev/null +++ b/slack.json @@ -0,0 +1,5 @@ +{ + "hooks": { + "get-hooks": "deno run -q --allow-read --allow-net https://deno.land/x/deno_slack_hooks@1.3.0/mod.ts" + } +} diff --git a/tailscale.ts b/tailscale.ts new file mode 100644 index 0000000..a921933 --- /dev/null +++ b/tailscale.ts @@ -0,0 +1,193 @@ +import { Env, SlackAPIClient } from "deno-slack-sdk/types.ts"; +import { AccessToken, TailscaleTokenStore } from "./datastores/tailscale.ts"; +import { OAuth2, OAuth2Token } from "npm:fetch-mw-oauth2@1"; + +const ua = "tailscale-accessbot/0.0.1"; + +export type TailscaleRequestInit = RequestInit & { + cacheSeconds?: number; +}; + +/** + * @param env The slack environment containing TAILSCALE_CLIENT_ID and TAILSCALE_CLIENT_SECRET. + * @param client SlackAPIClient for accessing the Datastore where we persist temporary access tokens for re-use between bot interactions. + * @returns + */ +export default function tailscale( + env: Env, + client: SlackAPIClient, +) { + const clientId = env.TAILSCALE_CLIENT_ID; + const clientSecret = env.TAILSCALE_CLIENT_SECRET; + + // Try to read an existing access token from the data store. + const ts = client.apps.datastore.get({ + datastore: TailscaleTokenStore.name, + id: clientId, + }) + .catch((err) => { + // If an error occurs, continue anyway. + console.error("Exception reading token from datastore:", err); + return null; + }) + .then((tokRes) => { + // Try to retrieve a token, but not too hard. + const tok = tokRes && tokRes.ok && tokRes.item.access_token + ? { + accessToken: tokRes.item.access_token, + refreshToken: tokRes.item.refresh_token, + expiresAt: tokRes.item.expires_at + ? tokRes.item.expires_at * 1000 + : undefined, + } as OAuth2Token + : undefined; + + // Always generate an OAuth2 client for making requests. + // It will attempt to generate its own tokens. + return new OAuth2( + { + grantType: "client_credentials", + tokenEndpoint: "https://api.tailscale.com/api/v2/oauth/token", + clientId: clientId, + clientSecret: clientSecret, + + onTokenUpdate: function (token: OAuth2Token) { + // Persist updated tokensin the data store. + client.apps.datastore.put({ + datastore: TailscaleTokenStore.name, + item: { + client_id: clientId, + access_token: token.accessToken, + refresh_token: token.refreshToken, + expires_at: token.expiresAt + ? token.expiresAt / 1000 + : undefined, + }, + }).catch((err) => + console.error("Error persisting tailscale access token:", err) + ); + }, + }, + tok, + ); + }); + + // Inject our user-agent to the fetch the requests. + return ( + input: RequestInfo, + init?: TailscaleRequestInit, + ): Promise => { + // The actual request. + const method = init?.method?.toUpperCase() || "GET"; + const headers = new Headers(init?.headers); + headers.set("User-Agent", ua); + const res = ts.then((c) => c.fetch(input, { ...init, method, headers })); + + // Just use the request, if we can't or don't want to check the cache. + if (!init?.cacheSeconds || method != "GET") { + return res; + } + + if (init.cacheSeconds) { + throw new Error("cacheSeconds is still a work-in-progress"); + } + + // Multiple consumers want the body - read it only once. + const resBody = res + .then(async (res) => ({ res, body: await res.text() })); + + // When the response completes, update the response cache. + const key = clientId + ":" + (new Request(input, init).url); + resBody.then(({ res, body }) => + writeResponseCache(client, key, init.cacheSeconds!, res, body) + ).catch(() => { + // Swallow these errors - the returned promise will include it. + }); + + return Promise.any([ + readResponseCache(client, key), + resBody.then(({ res, body }) => + new Response(body, { + status: res.status, + statusText: res.statusText, + headers: res.headers, + }) + ), + ]); + }; +} + +async function writeResponseCache( + client: SlackAPIClient, + key: string, + ttlSeconds: number, + res: Response, + body: string, +) { + // Only cache good, re-usable responses. + if (res.status != 200 && res.status !== 201) { + console.log(`tscache skip: key=${key}: ${res.statusText}`); + return; + } + + // DynamoDB which backs the Slack datastores have an item limit of 400KB, + // and we need to save a small amount of space for the other properties + // we write into it. + const len = body.length; + if (len > 400_000) { + console.log(`tscache skip: key=${key} len=${len}: too large`); + return; + } + + try { + // TODO(icio): use a different datastore than the access token. + const r = await client.apps.datastore.put({ + datastore: TailscaleTokenStore.name, + item: { + client_id: key, + expires_at: Date.now() / 1000 + ttlSeconds, + access_token: JSON.stringify({ + status: res.status, + statusText: res.statusText, + headers: [...res.headers.entries()], + body: body, + }), + }, + }); + if (!r.ok) { + console.error(`tscache error: key=${key} len=${len}:`, r.error); + return; + } + console.debug(`tscache updated: key=${key} len=${len}`); + } catch (exc) { + console.error(`tscache error: key=${key} len=${len}:`, exc); + } +} + +function readResponseCache( + client: SlackAPIClient, + key: string, +): Promise { + // TODO(icio): use a different datastore than the access token. + return client.apps.datastore.get({ + datastore: TailscaleTokenStore.name, + id: key, + }) + .then((got) => { + if (!got.ok) throw new Error(got.error); + if (!got.item) throw new Error("no item"); + if (got.item.expires_at * 1000 < Date.now()) { + throw new Error("cache expired"); + } + if (!got.item.access_token) { + throw new Error("empty item access_token"); + } + console.debug(`tscache: read: key=${key}:`, got.item); + const { body, ...init } = JSON.parse(got.item.access_token); + return new Response(body, init); + }) + .catch((err) => { + console.error(`tscache read error: key=${key}:`, err); + throw err; + }); +} diff --git a/triggers/trigger.ts b/triggers/trigger.ts new file mode 100644 index 0000000..84649a1 --- /dev/null +++ b/triggers/trigger.ts @@ -0,0 +1,19 @@ +import { Trigger } from "deno-slack-sdk/types.ts"; +import { TriggerContextData, TriggerTypes } from "deno-slack-api/mod.ts"; + +const trigger: Trigger = { + type: TriggerTypes.Shortcut, + name: "Tailscale Access", + description: "Request temporary access to devices in your Tailnet", + workflow: "#/workflows/create_access_request", + inputs: { + interactivity: { + value: TriggerContextData.Shortcut.interactivity, + }, + user: { + value: TriggerContextData.Shortcut.user_id, + }, + }, +}; + +export default trigger; diff --git a/types/slack.ts b/types/slack.ts new file mode 100644 index 0000000..227edca --- /dev/null +++ b/types/slack.ts @@ -0,0 +1,31 @@ +import { + FunctionDefinitionArgs, + FunctionRuntimeParameters, +} from "deno-slack-sdk/functions/types.ts"; + +export type SlackFunctionOutputs = Definition extends + FunctionDefinitionArgs + ? FunctionRuntimeParameters + : never; + +export type SlackFunctionInputs = Definition extends + FunctionDefinitionArgs + ? FunctionRuntimeParameters + : never; + +export type PlainTextObject = { + type: "plain_text"; + emoji?: boolean; + text: string; +}; +export type Option = { + text: PlainTextObject; + value: string; +}; +export type OptionGroup = { + label: PlainTextObject; + options: Option[]; +}; +export type SuggestionResponse = + | { options: Option[] } + | { option_groups: OptionGroup[] }; diff --git a/types/tailscale.ts b/types/tailscale.ts new file mode 100644 index 0000000..5bda240 --- /dev/null +++ b/types/tailscale.ts @@ -0,0 +1,125 @@ +import { DefineType, Schema } from "deno-slack-sdk/mod.ts"; +import { FunctionRuntimeParameters } from "deno-slack-sdk/functions/types.ts"; + +export const ProfileType = DefineType({ + name: "Profile", + type: Schema.types.object, + additionalProperties: false, + required: ["attribute", "description"], + properties: { + attribute: { + type: Schema.types.string, + }, + description: { + type: Schema.types.string, + }, + maxSeconds: { + type: Schema.types.number, + }, + notifyChannel: { + type: Schema.slack.types.channel_id, + }, + approverEmails: { + type: Schema.types.array, + items: { + type: Schema.types.string, + }, + }, + canSelfApprove: { + type: Schema.types.boolean, + default: false, + }, + confirmSelfApproval: { + type: Schema.types.boolean, + default: false, + }, + }, +}); + +export const DeviceType = DefineType({ + name: "Device", + type: Schema.types.object, + required: ["nodeId"], + properties: { + nodeId: { + type: Schema.types.string, + }, + name: { + type: Schema.types.string, + }, + addresses: { + type: Schema.types.array, + items: { + type: Schema.types.string, + }, + }, + tags: { + type: Schema.types.array, + items: { + type: Schema.types.string, + }, + }, + user: { + type: Schema.types.string, + }, + os: { + type: Schema.types.string, + }, + }, +}); + +export const ApproverType = DefineType({ + name: "Approver", + type: Schema.types.object, + additionalProperties: false, + required: [ + "userId", + ], + properties: { + "userId": { + type: Schema.slack.types.user_id, + }, + "email": { + type: Schema.types.string, + }, + }, +}); + +export type Access = FunctionRuntimeParameters< + typeof AccessType.definition.properties, + typeof AccessType.definition.required +>; + +export const AccessType = DefineType({ + name: "Access", + type: Schema.types.object, + additionalProperties: false, + required: [ + "profile", + "requester", + "device", + "durationSeconds", + "approver", + "reason", + ], + properties: { + requester: { + type: Schema.slack.types.user_id, + }, + profile: { + type: ProfileType, + }, + device: { + type: DeviceType, + }, + approver: { + type: ApproverType, + }, + durationSeconds: { + type: Schema.types.number, + }, + reason: { + type: Schema.types.string, + }, + }, +}); diff --git a/workflows/CreateAccessRequestWorkflow.ts b/workflows/CreateAccessRequestWorkflow.ts new file mode 100644 index 0000000..4912aab --- /dev/null +++ b/workflows/CreateAccessRequestWorkflow.ts @@ -0,0 +1,33 @@ +import { DefineWorkflow, Schema } from "deno-slack-sdk/mod.ts"; +import { AccessRequestFunction } from "../functions/access_request_prompt.ts"; +import { AccessApprovalFunction } from "../functions/access_approval_prompt.ts"; + +export const CreateAccessRequestWorkflow = DefineWorkflow({ + callback_id: "create_access_request", + title: "Request Tailscale Access", + description: "Request temporary access to devices in your Tailnet", + input_parameters: { + properties: { + interactivity: { + type: Schema.slack.types.interactivity, + }, + user: { + type: Schema.slack.types.user_id, + }, + }, + required: ["interactivity", "user"], + }, +}); + +const accessRequest = CreateAccessRequestWorkflow.addStep( + AccessRequestFunction, + { + interactivity: CreateAccessRequestWorkflow.inputs.interactivity, + user: CreateAccessRequestWorkflow.inputs.user, + }, +); + +CreateAccessRequestWorkflow.addStep(AccessApprovalFunction, { + interactivity: accessRequest.outputs.interactivity, + access: accessRequest.outputs.access, +});