An access token can be regenerated: a new secret, its name, permissions, repositories and reach kept, and the old secret refused at once. Its page (yours under Settings, a workspace's under its settings for owners) has Regenerate with a new expiry, within the workspace's rules on lifetime, and shows the secret once; identity's regenerate_token logs it to the security log or the workspace's audit log, and refuses a revoked token. The token form's quick permissions have All, every resource at its highest level. A project's Observability settings no longer list the token g1t gives its apps on g1t.page: it is g1t's, and the page says apps there need none.
14 files+260−420/14 viewed
| 639 | 639 | a token that reads your notifications. | |
| 640 | 640 | 5. Under **Permissions**, set each resource the token needs to a level. | |
| 641 | 641 | **Read only**, **Agent** and **CI** fill in a [preset](#presets); | |
| 642 | − | **Clear** sets everything back to no access. | |
| 642 | + | **All** sets every resource to its highest level; **Clear** sets | |
| 643 | + | everything back to no access. | |
| 643 | 644 | 6. Leave **Use the website as you**, under **Website**, off unless the | |
| 644 | 645 | token is for automation that drives a browser. See | |
| 645 | 646 | [use a token on the website](#use-a-token-on-the-website). | |
| ⋯ | |||
| 663 | 664 | itself stays the | |
| 664 | 665 | same; the change applies from its next request. Widening a token made for | |
| 665 | 666 | a workspace that requires approval asks its owners again. Where it reaches | |
| 666 | − | and when it expires cannot change; make a new token instead. | |
| 667 | + | cannot change; make a new token instead. | |
| 667 | 668 | ||
| 669 | + | ### Regenerate a token | |
| 670 | + | ||
| 671 | + | To rotate a token, or replace one that may have leaked, open its page, | |
| 672 | + | choose when the new one expires, and select **Regenerate**. The token gets | |
| 673 | + | a new secret, shown once, and keeps its name, permissions, repositories | |
| 674 | + | and where it reaches. The old secret stops working at once, so give | |
| 675 | + | whatever used it the new one. A token made for a workspace keeps to that | |
| 676 | + | workspace's [rules](#a-workspaces-rules-for-tokens) on how long it may | |
| 677 | + | last. A workspace's owners regenerate its own tokens the same way, from | |
| 678 | + | **Workspace settings** → **Access tokens**. Each regeneration is in your | |
| 679 | + | [security log](#security-log), or the workspace's audit log. | |
| 680 | + | ||
| 668 | 681 | **Delete token**, at the bottom of its page, stops it working at once. | |
| 669 | 682 | ||
| 670 | 683 | ### Use a token on the website | |
| ⋯ | |||
| 876 | 889 | | Agent | Every resource at read except Self-hosted runners and Chat, and Code, Issues, Pull requests, Memory and context and Notifications at read and write, and g1t agents at run. Reads everything, works on issues and pull requests, pushes code, puts g1t to work, and answers your inbox. No admin level. | | |
| 877 | 890 | | CI | Repositories: read; Code, Packages, Workflows, Checks and statuses, and Deployments: read and write. Clones and pushes code, pushes and pulls packages, runs workflows, and reports [checks](/guides/checks/) and deployments. | | |
| 878 | 891 | ||
| 892 | + | **All** is not a preset that grows: it ticks every permission there is | |
| 893 | + | when you select it. A permission g1t adds later is not added to a token on | |
| 894 | + | its own; edit the token to give it, as with any other. | |
| 895 | + | ||
| 879 | 896 | A new personal token starts on Read only; a new workspace token on CI. | |
| 880 | 897 | ||
| 881 | 898 | ## Scopes | |
| 235 | 235 | const blocked = Boolean(one && !workspaceOwned && chosen?.policy && !chosen.policy.allowTokensForThisWorkspace); | |
| 236 | 236 | const keptOut = !workspaceOwned && reach === ALL_WORKSPACES ? keptOutOfAll(workspaces) : []; | |
| 237 | 237 | const choosePreset = (id: PresetId) => setLevels(permissionsOf(presetScopes(id))); | |
| 238 | + | // Every permission this kind of token can have, each at its highest level. | |
| 239 | + | const chooseAll = () => | |
| 240 | + | setLevels(Object.fromEntries(groups.flatMap((group) => group.resources.map((row) => [row.resource, levelsOf(row.resource).at(-1)!]))) as typeof levels); | |
| 238 | 241 | ||
| 239 | 242 | return ( | |
| 240 | 243 | <div className="space-y-8"> | |
| ⋯ | |||
| 341 | 344 | </Button> | |
| 342 | 345 | </Hint> | |
| 343 | 346 | ))} | |
| 347 | + | <Hint label="Every permission, each at its highest level. A permission g1t adds later is added to the token by editing it."> | |
| 348 | + | <Button | |
| 349 | + | type="button" | |
| 350 | + | onClick={chooseAll} | |
| 351 | + | variant="outline" | |
| 352 | + | size="inline" | |
| 353 | + | className="rounded-full px-2.5 py-0.5 text-xs text-muted" | |
| 354 | + | > | |
| 355 | + | All | |
| 356 | + | </Button> | |
| 357 | + | </Hint> | |
| 344 | 358 | <Button | |
| 345 | 359 | type="button" | |
| 346 | 360 | onClick={() => setLevels({})} | |
| 1 | + | /** | |
| 2 | + | * Regenerating a token: a new secret for it, everything else kept, the | |
| 3 | + | * old secret refused from then on. For rotating a token without remaking | |
| 4 | + | * it, or replacing one that may have leaked. The page's action answers | |
| 5 | + | * `intent=regenerate` with `{ secret, token }`, shown once by `TokenCreated`. | |
| 6 | + | */ | |
| 7 | + | import { RefreshCw } from "lucide-react"; | |
| 8 | + | import { useState } from "react"; | |
| 9 | + | import { Form } from "react-router"; | |
| 10 | + | ||
| 11 | + | import { | |
| 12 | + | AlertDialog, | |
| 13 | + | AlertDialogAction, | |
| 14 | + | AlertDialogCancel, | |
| 15 | + | AlertDialogContent, | |
| 16 | + | AlertDialogDescription, | |
| 17 | + | AlertDialogFooter, | |
| 18 | + | AlertDialogHeader, | |
| 19 | + | AlertDialogTitle, | |
| 20 | + | AlertDialogTrigger, | |
| 21 | + | } from "./ui/alert-dialog"; | |
| 22 | + | import { Button } from "./ui/button"; | |
| 23 | + | import { SelectField } from "./ui/select"; | |
| 24 | + | import { DEFAULT_EXPIRY_DAYS, EXPIRY_DAYS, describeDays } from "../lib/access-tokens"; | |
| 25 | + | ||
| 26 | + | export function RegenerateToken({ name }: { name: string }) { | |
| 27 | + | const [expires, setExpires] = useState(String(DEFAULT_EXPIRY_DAYS)); | |
| 28 | + | const options = [...EXPIRY_DAYS.map((days) => ({ value: String(days), label: describeDays(days) })), { value: "never", label: "No expiration" }]; | |
| 29 | + | return ( | |
| 30 | + | <section className="rounded-xl border border-line p-4"> | |
| 31 | + | <h2 className="text-sm font-medium">Regenerate this token</h2> | |
| 32 | + | <p className="mt-1 text-xs text-muted"> | |
| 33 | + | A new secret, with the same name, permissions and reach. Anything using the old one stops working at once, until you give it the new | |
| 34 | + | one. | |
| 35 | + | </p> | |
| 36 | + | <div className="mt-3 flex flex-wrap items-center gap-2"> | |
| 37 | + | <SelectField aria-label="Expires" className="w-40" value={expires} onValueChange={setExpires} options={options} /> | |
| 38 | + | <AlertDialog> | |
| 39 | + | <AlertDialogTrigger asChild> | |
| 40 | + | <Button variant="outline"> | |
| 41 | + | <RefreshCw size={14} /> | |
| 42 | + | Regenerate | |
| 43 | + | </Button> | |
| 44 | + | </AlertDialogTrigger> | |
| 45 | + | <AlertDialogContent> | |
| 46 | + | <Form method="post" className="grid gap-4"> | |
| 47 | + | <input type="hidden" name="intent" value="regenerate" /> | |
| 48 | + | <input type="hidden" name="expires" value={expires} /> | |
| 49 | + | <AlertDialogHeader> | |
| 50 | + | <AlertDialogTitle>Regenerate {name}?</AlertDialogTitle> | |
| 51 | + | <AlertDialogDescription> | |
| 52 | + | Its old secret stops working now. The new one is shown once, and {expires === "never" ? "does not expire" : `expires in ${describeDays(Number(expires))}`}. | |
| 53 | + | </AlertDialogDescription> | |
| 54 | + | </AlertDialogHeader> | |
| 55 | + | <AlertDialogFooter> | |
| 56 | + | <AlertDialogCancel>Cancel</AlertDialogCancel> | |
| 57 | + | <AlertDialogAction asChild> | |
| 58 | + | <button type="submit">Regenerate token</button> | |
| 59 | + | </AlertDialogAction> | |
| 60 | + | </AlertDialogFooter> | |
| 61 | + | </Form> | |
| 62 | + | </AlertDialogContent> | |
| 63 | + | </AlertDialog> | |
| 64 | + | </div> | |
| 65 | + | </section> | |
| 66 | + | ); | |
| 67 | + | } |
| 60 | 60 | return { days, never }; | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | + | /** A new lifetime chosen in a form's `expires` (days, or `never`), as seconds; null for none, or an error. */ | |
| 64 | + | export function ttlFromForm(form: FormData): { ok: true; value: number | null } | { ok: false; error: string } { | |
| 65 | + | const expires = String(form.get("expires") ?? DEFAULT_EXPIRY_DAYS); | |
| 66 | + | if (expires === "never") return { ok: true, value: null }; | |
| 67 | + | const days = Number(expires); | |
| 68 | + | if (!Number.isInteger(days) || days < 1 || days > MAX_TOKEN_LIFETIME_DAYS) { | |
| 69 | + | return { ok: false, error: `Choose when it expires: at most ${MAX_TOKEN_LIFETIME_DAYS} days, or never.` }; | |
| 70 | + | } | |
| 71 | + | return { ok: true, value: days * 86_400 }; | |
| 72 | + | } | |
| 73 | + | ||
| 63 | 74 | /** The resources a token can hold: a workspace's own token holds none about a person. */ | |
| 64 | 75 | export function resourcesFor(workspaceOwned: boolean): { resource: ScopeResource; label: string; group: ResourceGroup }[] { | |
| 65 | 76 | return SCOPE_RESOURCES.filter((row) => !workspaceOwned || row.group !== "account"); |
| 110 | 110 | export default function ObservabilitySettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 111 | 111 | const { settings, tokens, exports, usage, slug } = loaderData; | |
| 112 | 112 | const submit = useSubmit(); | |
| 113 | − | const deploymentToken = tokens?.find((t) => t.managed); | |
| 114 | 113 | ||
| 115 | 114 | return ( | |
| 116 | 115 | <div className="mx-auto max-w-4xl"> | |
| ⋯ | |||
| 153 | 152 | </h2> | |
| 154 | 153 | <NewToken /> | |
| 155 | 154 | </div> | |
| 156 | − | <p className="mt-1 text-sm text-muted">A token lets software send to this project and do nothing else. Each is shown once, when it is made.</p> | |
| 155 | + | <p className="mt-1 text-sm text-muted"> | |
| 156 | + | For software running anywhere other than g1t.page: a token lets it send to this project and do nothing else. Each is shown once, when | |
| 157 | + | it is made. Apps on g1t.page need none; g1t gives them their own. | |
| 158 | + | </p> | |
| 157 | 159 | {!tokens ? ( | |
| 158 | 160 | <Card className="mt-3 p-4 text-sm text-muted">Tokens could not be loaded just now.</Card> | |
| 159 | 161 | ) : tokens.length === 0 ? ( | |
| 160 | − | <Card className="mt-3 p-4 text-sm text-muted">No tokens yet.</Card> | |
| 162 | + | <Card className="mt-3 p-4 text-sm text-muted">None yet.</Card> | |
| 161 | 163 | ) : ( | |
| 162 | 164 | <Card className="mt-3 divide-y divide-line"> | |
| 163 | 165 | {tokens.map((t) => ( | |
| ⋯ | |||
| 166 | 168 | <p className="flex items-center gap-2 text-sm font-medium"> | |
| 167 | 169 | <KeyRound size={14} className="text-faint" /> | |
| 168 | 170 | {t.name} | |
| 169 | − | {t.managed && <Badge tone="accent">g1t</Badge>} | |
| 170 | 171 | </p> | |
| 171 | 172 | <p className="mt-0.5 text-xs text-faint"> | |
| 172 | 173 | <span className="font-mono">{t.hint}</span> · made <TimeAgo at={t.created_us / 1000} /> ·{" "} | |
| ⋯ | |||
| 179 | 180 | )} | |
| 180 | 181 | </p> | |
| 181 | 182 | </div> | |
| 182 | − | {t.managed ? ( | |
| 183 | − | <span className="text-xs text-faint">Given to the project's apps on g1t.page</span> | |
| 184 | − | ) : ( | |
| 185 | − | <AlertDialog> | |
| 186 | − | <AlertDialogTrigger asChild> | |
| 187 | − | <Button variant="outline" size="sm" aria-label={`Revoke ${t.name}`}> | |
| 188 | − | <Trash2 size={14} /> | |
| 189 | − | Revoke | |
| 190 | − | </Button> | |
| 191 | − | </AlertDialogTrigger> | |
| 192 | − | <AlertDialogContent> | |
| 193 | − | <Form method="post" className="grid gap-4"> | |
| 194 | − | <input type="hidden" name="intent" value="revoke-token" /> | |
| 195 | − | <input type="hidden" name="id" value={t.id} /> | |
| 196 | − | <AlertDialogHeader> | |
| 197 | − | <AlertDialogTitle>Revoke {t.name}?</AlertDialogTitle> | |
| 198 | − | <AlertDialogDescription>Anything sending with it is refused at once. What it already sent stays.</AlertDialogDescription> | |
| 199 | − | </AlertDialogHeader> | |
| 200 | − | <AlertDialogFooter> | |
| 201 | − | <AlertDialogCancel>Cancel</AlertDialogCancel> | |
| 202 | − | <AlertDialogAction asChild> | |
| 203 | − | <button type="submit">Revoke token</button> | |
| 204 | − | </AlertDialogAction> | |
| 205 | − | </AlertDialogFooter> | |
| 206 | − | </Form> | |
| 207 | − | </AlertDialogContent> | |
| 208 | − | </AlertDialog> | |
| 209 | − | )} | |
| 183 | + | <AlertDialog> | |
| 184 | + | <AlertDialogTrigger asChild> | |
| 185 | + | <Button variant="outline" size="sm" aria-label={`Revoke ${t.name}`}> | |
| 186 | + | <Trash2 size={14} /> | |
| 187 | + | Revoke | |
| 188 | + | </Button> | |
| 189 | + | </AlertDialogTrigger> | |
| 190 | + | <AlertDialogContent> | |
| 191 | + | <Form method="post" className="grid gap-4"> | |
| 192 | + | <input type="hidden" name="intent" value="revoke-token" /> | |
| 193 | + | <input type="hidden" name="id" value={t.id} /> | |
| 194 | + | <AlertDialogHeader> | |
| 195 | + | <AlertDialogTitle>Revoke {t.name}?</AlertDialogTitle> | |
| 196 | + | <AlertDialogDescription>Anything sending with it is refused at once. What it already sent stays.</AlertDialogDescription> | |
| 197 | + | </AlertDialogHeader> | |
| 198 | + | <AlertDialogFooter> | |
| 199 | + | <AlertDialogCancel>Cancel</AlertDialogCancel> | |
| 200 | + | <AlertDialogAction asChild> | |
| 201 | + | <button type="submit">Revoke token</button> | |
| 202 | + | </AlertDialogAction> | |
| 203 | + | </AlertDialogFooter> | |
| 204 | + | </Form> | |
| 205 | + | </AlertDialogContent> | |
| 206 | + | </AlertDialog> | |
| 210 | 207 | </div> | |
| 211 | 208 | ))} | |
| 212 | 209 | </Card> | |
| 213 | 210 | )} | |
| 214 | − | {!deploymentToken && <p className="mt-2 text-xs text-faint">The project's apps on g1t.page get a token of their own at their next deployment.</p>} | |
| 215 | 211 | </section> | |
| 216 | 212 | ||
| 217 | 213 | <section className="mt-10" aria-labelledby="exports-title"> | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 6 | 6 | import { ErrorText, SubmitButton } from "../../components/ui"; | |
| 7 | 7 | import { TokenForm } from "../../components/token-form"; | |
| 8 | − | import { TokenBadges, TokenMeta } from "../../components/token-list"; | |
| 9 | − | import { changesTo, tokenFromForm } from "../../lib/access-tokens"; | |
| 8 | + | import { TokenBadges, TokenCreated, TokenMeta } from "../../components/token-list"; | |
| 9 | + | import { RegenerateToken } from "../../components/token-regenerate"; | |
| 10 | + | import { changesTo, tokenFromForm, ttlFromForm } from "../../lib/access-tokens"; | |
| 10 | 11 | import { personalToken, workspaceChoices } from "../../lib/access-tokens.server"; | |
| 11 | 12 | import { identity } from "../../lib/services.server"; | |
| 12 | 13 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| ⋯ | |||
| 33 | 34 | await identity.removeAccessToken(user, params.id); | |
| 34 | 35 | throw redirect("/settings/tokens"); | |
| 35 | 36 | } | |
| 37 | + | if (form.get("intent") === "regenerate") { | |
| 38 | + | const ttl = ttlFromForm(form); | |
| 39 | + | if (!ttl.ok) return { error: ttl.error, saved: false }; | |
| 40 | + | const made = await identity.regenerateToken(user, params.id, ttl.value); | |
| 41 | + | return made.ok ? { error: null, saved: false, secret: made.value.token, made: made.value.info } : { error: made.error.message, saved: false }; | |
| 42 | + | } | |
| 36 | 43 | const token = await personalToken(user, params.id); | |
| 37 | 44 | const input = tokenFromForm(form, { editing: true }); | |
| 38 | 45 | if (!input.ok) return { error: input.error, saved: false }; | |
| ⋯ | |||
| 56 | 63 | </h2> | |
| 57 | 64 | <TokenMeta token={token} /> | |
| 58 | 65 | </div> | |
| 66 | + | {actionData && "secret" in actionData && actionData.secret && actionData.made && ( | |
| 67 | + | <TokenCreated secret={actionData.secret} token={actionData.made} back="/settings/tokens" /> | |
| 68 | + | )} | |
| 59 | 69 | <Form method="post" key={token.id} className="space-y-6"> | |
| 60 | 70 | <input type="hidden" name="intent" value="save" /> | |
| 61 | 71 | <TokenForm workspaces={workspaces} editing={token} /> | |
| ⋯ | |||
| 72 | 82 | : "The token stays the same; what it may do changes from its next request."} | |
| 73 | 83 | </p> | |
| 74 | 84 | </Form> | |
| 85 | + | <RegenerateToken name={token.name} /> | |
| 75 | 86 | <section className="rounded-xl border border-danger/30 p-4"> | |
| 76 | 87 | <h2 className="text-sm font-medium">Delete this token</h2> | |
| 77 | 88 | <p className="mt-1 text-xs text-muted">Anything still using it stops working at once.</p> | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 6 | 6 | import { ErrorText, SubmitButton } from "../../components/ui"; | |
| 7 | 7 | import { TokenForm } from "../../components/token-form"; | |
| 8 | − | import { TokenBadges, TokenFacts, TokenMeta } from "../../components/token-list"; | |
| 9 | − | import { changesTo, tokenFromForm } from "../../lib/access-tokens"; | |
| 8 | + | import { TokenBadges, TokenCreated, TokenFacts, TokenMeta } from "../../components/token-list"; | |
| 9 | + | import { RegenerateToken } from "../../components/token-regenerate"; | |
| 10 | + | import { changesTo, tokenFromForm, ttlFromForm } from "../../lib/access-tokens"; | |
| 10 | 11 | import { workspaceRepos, workspaceToken } from "../../lib/access-tokens.server"; | |
| 11 | 12 | import { identity } from "../../lib/services.server"; | |
| 12 | 13 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| ⋯ | |||
| 36 | 37 | if (!removed.ok) return { error: removed.error.message, saved: false }; | |
| 37 | 38 | throw redirect(`/${slug}/-/tokens`); | |
| 38 | 39 | } | |
| 40 | + | if (form.get("intent") === "regenerate") { | |
| 41 | + | const ttl = ttlFromForm(form); | |
| 42 | + | if (!ttl.ok) return { error: ttl.error, saved: false }; | |
| 43 | + | const made = await identity.regenerateToken(user, params.id, ttl.value, slug); | |
| 44 | + | return made.ok ? { error: null, saved: false, secret: made.value.token, made: made.value.info } : { error: made.error.message, saved: false }; | |
| 45 | + | } | |
| 39 | 46 | const token = await workspaceToken(user, slug, params.id); | |
| 40 | 47 | const input = tokenFromForm(form, { editing: true, workspaceOwned: true, owner: slug }); | |
| 41 | 48 | if (!input.ok) return { error: input.error, saved: false }; | |
| ⋯ | |||
| 60 | 67 | {token.description && <p className="text-sm text-muted">{token.description}</p>} | |
| 61 | 68 | <TokenMeta token={token} /> | |
| 62 | 69 | </div> | |
| 70 | + | {actionData && "secret" in actionData && actionData.secret && actionData.made && ( | |
| 71 | + | <TokenCreated secret={actionData.secret} token={actionData.made} back={`/${slug}/-/tokens`} /> | |
| 72 | + | )} | |
| 63 | 73 | {owner ? ( | |
| 64 | 74 | <> | |
| 65 | 75 | <Form method="post" key={token.id} className="space-y-6"> | |
| ⋯ | |||
| 73 | 83 | {actionData?.saved && <span className="text-sm text-success">Saved.</span>} | |
| 74 | 84 | </div> | |
| 75 | 85 | </Form> | |
| 86 | + | <RegenerateToken name={token.name} /> | |
| 76 | 87 | <section className="rounded-xl border border-danger/30 p-4"> | |
| 77 | 88 | <h3 className="text-sm font-medium">Delete this token</h3> | |
| 78 | 89 | <p className="mt-1 text-xs text-muted">Anything still using it stops working at once.</p> | |
| 146 | 146 | pub website: Option<bool>, | |
| 147 | 147 | } | |
| 148 | 148 | ||
| 149 | + | /// `regenerate_token`: a person gives a token of theirs, or, as an | |
| 150 | + | /// owner, a workspace's token, a new secret. Everything else about it | |
| 151 | + | /// stays: its name, permissions, reach and repositories. The old secret | |
| 152 | + | /// stops working at once. `ttl_seconds` is its new lifetime from now, as | |
| 153 | + | /// when it was made (null: it does not expire), within its workspace's | |
| 154 | + | /// rules. Returns `Outcome<CreatedAccessToken>`, the new secret shown once. | |
| 155 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 156 | + | pub struct RegenerateTokenArgs { | |
| 157 | + | pub actor: User, | |
| 158 | + | pub id: String, | |
| 159 | + | /// The workspace whose token it is; null for one of the actor's own. | |
| 160 | + | #[serde(default)] | |
| 161 | + | pub owner: Option<String>, | |
| 162 | + | #[serde(default)] | |
| 163 | + | pub ttl_seconds: Option<u64>, | |
| 164 | + | } | |
| 165 | + | ||
| 149 | 166 | /// A workspace's rules for personal access tokens. | |
| 150 | 167 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 151 | 168 | #[serde(rename_all = "camelCase")] |
| 126 | 126 | permissions: change.permissions ?? null, | |
| 127 | 127 | website: change.website ?? null, | |
| 128 | 128 | }), | |
| 129 | + | regenerateToken: (actor, id, ttlSeconds, owner) => | |
| 130 | + | call("regenerate_token", { actor, id, owner: owner ?? null, ttl_seconds: ttlSeconds ?? null }), | |
| 129 | 131 | getTokenPolicy: (slug, viewer) => call("get_token_policy", { slug, viewer }), | |
| 130 | 132 | setTokenPolicy: (actor, slug, change) => | |
| 131 | 133 | call("set_token_policy", { |
| 933 | 933 | * workspace that approves tokens asks for approval again. | |
| 934 | 934 | */ | |
| 935 | 935 | updateToken(actor: User, id: string, change: TokenChange, owner?: string | null): Promise<Result<AccessToken>>; | |
| 936 | + | /** | |
| 937 | + | * A new secret for a token, everything else kept, the old secret refused | |
| 938 | + | * from then on: rotating it. `ttlSeconds` is its new lifetime from now | |
| 939 | + | * (null: it does not expire), within its workspace's rules. The secret | |
| 940 | + | * is shown once. | |
| 941 | + | */ | |
| 942 | + | regenerateToken(actor: User, id: string, ttlSeconds: number | null, owner?: string | null): Promise<Result<{ token: string; info: AccessToken }>>; | |
| 936 | 943 | /** A workspace's rules for personal access tokens. Members only. */ | |
| 937 | 944 | getTokenPolicy(slug: string, viewer: Viewer): Promise<Result<TokenPolicy>>; | |
| 938 | 945 | /** Owners only, as people. `maxLifetimeDays` of 0 removes the limit. */ |
| 1018 | 1018 | // workspaces' rules for tokens; see token_reach.rs. | |
| 1019 | 1019 | "create_token" => reply(&identity.create_token(args(body)?).await?), | |
| 1020 | 1020 | "update_token" => reply(&identity.update_token(args(body)?).await?), | |
| 1021 | + | "regenerate_token" => reply(&identity.regenerate_token(args(body)?).await?), | |
| 1021 | 1022 | "get_token_policy" => reply(&identity.get_token_policy(args(body)?).await?), | |
| 1022 | 1023 | "set_token_policy" => reply(&identity.set_token_policy(args(body)?).await?), | |
| 1023 | 1024 | "list_member_tokens" => reply(&identity.list_member_tokens(args(body)?).await?), |
| 715 | 715 | Ok(Outcome::Ok(info)) | |
| 716 | 716 | } | |
| 717 | 717 | ||
| 718 | + | /// A new secret for a token, everything else kept: for rotating one | |
| 719 | + | /// without remaking it. See `RegenerateTokenArgs`. | |
| 720 | + | pub async fn regenerate_token(&self, a: RegenerateTokenArgs) -> Result<Outcome<CreatedAccessToken>> { | |
| 721 | + | if !is_person(&a.actor) || a.actor.token.is_some() { | |
| 722 | + | return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person, signed in on g1t.sh, can regenerate an access token.")); | |
| 723 | + | } | |
| 724 | + | if a.ttl_seconds.is_some_and(|ttl| !(DAY_SECONDS..=u64::from(MAX_LIFETIME_DAYS) * DAY_SECONDS).contains(&ttl)) { | |
| 725 | + | return Ok(Outcome::fail( | |
| 726 | + | FailureCode::Invalid, | |
| 727 | + | format!("A token lasts between 1 and {MAX_LIFETIME_DAYS} days, or does not expire."), | |
| 728 | + | )); | |
| 729 | + | } | |
| 730 | + | let owner_slug = a.owner.as_deref().map(|slug| slug.trim().to_lowercase()).filter(|slug| !slug.is_empty()); | |
| 731 | + | let found = match (&owner_slug, self.owned_token(&a.id).await?) { | |
| 732 | + | (Some(slug), Some(token)) => { | |
| 733 | + | let workspace_id = match self.owned_workspace(&a.actor, slug).await? { | |
| 734 | + | Outcome::Ok(id) => id, | |
| 735 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 736 | + | }; | |
| 737 | + | if token.workspace_id.as_deref() != Some(workspace_id.as_str()) { | |
| 738 | + | return Ok(Outcome::fail(FailureCode::NotFound, "No such token.")); | |
| 739 | + | } | |
| 740 | + | token | |
| 741 | + | } | |
| 742 | + | (None, Some(token)) if token.user_id.as_deref() == Some(a.actor.id.as_str()) => token, | |
| 743 | + | _ => return Ok(Outcome::fail(FailureCode::NotFound, "No such token.")), | |
| 744 | + | }; | |
| 745 | + | if found.status.as_deref() == Some(TokenStatus::Revoked.as_str()) { | |
| 746 | + | return Ok(Outcome::fail(FailureCode::Conflict, "This token was revoked. Make a new one instead.")); | |
| 747 | + | } | |
| 748 | + | // A personal token made for one workspace keeps to its rules on lifetime. | |
| 749 | + | if found.user_id.is_some() | |
| 750 | + | && let Some(id) = &found.owner_workspace_id | |
| 751 | + | && let Some(slug) = self.slug_of(id).await? | |
| 752 | + | && let Some((_, policy)) = self.policy_of(&slug).await? | |
| 753 | + | && let Some(refusal) = policy.refusal(&slug, true, a.ttl_seconds) | |
| 754 | + | { | |
| 755 | + | return Ok(Outcome::fail(FailureCode::Forbidden, refusal)); | |
| 756 | + | } | |
| 757 | + | let token = format!("{}{}", crate::tokens::TOKEN_PREFIX, crate::crypto::random_hex(20)); | |
| 758 | + | let expires_at = a.ttl_seconds.map(|ttl| rfc3339(now_ms() + ttl * 1000)); | |
| 759 | + | self.db | |
| 760 | + | .prepare("UPDATE access_tokens SET token_hash = ?, expires_at = ? WHERE id = ?") | |
| 761 | + | .bind(&[crate::crypto::sha256_hex(&token).into(), text(expires_at.as_deref()), a.id.as_str().into()])? | |
| 762 | + | .run() | |
| 763 | + | .await?; | |
| 764 | + | let Some(mut info) = self.token_info(&a.id).await? else { | |
| 765 | + | return Ok(Outcome::fail(FailureCode::NotFound, "No such token.")); | |
| 766 | + | }; | |
| 767 | + | self.name_repositories(std::slice::from_mut(&mut info), &Some(a.actor.clone())).await?; | |
| 768 | + | if found.user_id.is_some() { | |
| 769 | + | self.log_security(&a.actor.id, "token_regenerated", Some(&info.name), None).await; | |
| 770 | + | self.audit_account(&a.actor, "token.regenerated", &format!("Regenerated access token {}", info.name)).await; | |
| 771 | + | } else if let Some(slug) = &owner_slug { | |
| 772 | + | self.audit_workspace(&a.actor, "workspace_token.regenerated", slug, Surface::Web, format!("Regenerated workspace access token {}", info.name)).await; | |
| 773 | + | } | |
| 774 | + | Ok(Outcome::Ok(CreatedAccessToken { token, info })) | |
| 775 | + | } | |
| 776 | + | ||
| 718 | 777 | /// A token a person or workspace made on purpose, by id: never an | |
| 719 | 778 | /// agent's or a workflow job's. | |
| 720 | 779 | async fn owned_token(&self, id: &str) -> Result<Option<Owned>> { |
| 259 | 259 | assert.match(a.secret, /^g1to_prj_1_[0-9a-f]{48}$/); | |
| 260 | 260 | const made = await store.createToken("prj_1", "laptop", "u1"); | |
| 261 | 261 | const listed = store.tokens(); | |
| 262 | − | assert.equal(listed.length, 2); | |
| 262 | + | assert.deepEqual(listed.map((t) => t.name), ["laptop"], "the deployments' token is g1t's, not listed"); | |
| 263 | 263 | assert.ok(!JSON.stringify(listed).includes(made.secret), "a token's secret is never listed"); | |
| 264 | − | assert.equal(store.revokeToken(listed.find((t) => t.managed)!.id), false); | |
| 264 | + | const managedId = String((store.sql.exec("SELECT id FROM tokens WHERE managed = 1").toArray()[0] as { id: string }).id); | |
| 265 | + | assert.equal(store.revokeToken(managedId), false); | |
| 265 | 266 | assert.equal(store.revokeToken(made.token.id), true); | |
| 266 | 267 | assert.equal(await store.checkToken(made.secret), false); | |
| 267 | 268 | }); |
| 193 | 193 | }; | |
| 194 | 194 | } | |
| 195 | 195 | ||
| 196 | + | /** | |
| 197 | + | * The tokens people made. The one g1t's deployments send with is kept | |
| 198 | + | * out of sight: it is g1t's, given to the project's apps on their own. | |
| 199 | + | */ | |
| 196 | 200 | tokens(): IngestToken[] { | |
| 197 | − | return this.rows("SELECT * FROM tokens ORDER BY managed DESC, created_us DESC").map((r) => this.tokenRow(r)); | |
| 201 | + | return this.rows("SELECT * FROM tokens WHERE managed = 0 ORDER BY created_us DESC").map((r) => this.tokenRow(r)); | |
| 198 | 202 | } | |
| 199 | 203 | ||
| 200 | 204 | /** A new token: `g1to_<project>_<secret>`, so the ingest door knows whose store to ask. */ |