also looking at this
docs(cloud): say what attaching does to a credential's blast radius
#171
4 files
+358
-1
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -105,6 +105,54 @@ Every field is optional, and each resolves through the same chain: | ||
| 105 | 105 | |
| 106 | 106 | `infrastructure.compute.image` overrides `hetzner.image` when set: it is the provider-agnostic way to pin an image, and it is what a golden-image bake sets. |
| 107 | 107 | |
| 108 | ## Attaching to another project's server | |
| 109 | ||
| 110 | Set `cloud.attachTo` to an owner project's `project.slug` to deploy this project's | |
| 111 | sites onto a box that project already runs, instead of provisioning one: | |
| 112 | ||
| 113 | ```typescript | |
| 114 | export default { | |
| 115 | project: { name: 'LogHQ', slug: 'loghq', region: 'us-east-1' }, | |
| 116 | cloud: { provider: 'hetzner', attachTo: 'statushq' }, | |
| 117 | sites: { app: { root: 'dist', start: 'bun run server.ts', port: 3022 } }, | |
| 118 | } satisfies Partial<CloudConfig> | |
| 119 | ``` | |
| 120 | ||
| 121 | The deploy targets the owner's `<slug>-<environment>-app` server, ships only this | |
| 122 | project's sites, and adds its own additive rpx `sites.d/<slug>.json` fragment plus | |
| 123 | DNS. It never touches the owner's box lifecycle, firewall, or other tenants: the | |
| 124 | owner provisions and manages the shared box, attachers only deploy onto it. | |
| 125 | ||
| 126 | ### It widens what your CI credential can reach | |
| 127 | ||
| 128 | Attaching finds the owner's box by **listing** the provider's servers with this | |
| 129 | project's own token. That is the whole mechanism, and it has a consequence worth | |
| 130 | stating plainly before you adopt it: the owner's box must be visible to the | |
| 131 | attacher's credential, so both projects have to live in the same provider project. | |
| 132 | ||
| 133 | On Hetzner there is no narrower option. A Cloud API token is scoped to a project | |
| 134 | with Read or Read & Write, with no per-resource scoping, and a deploy needs write. | |
| 135 | So once `attachTo` is set, this project's CI token can modify and delete every | |
| 136 | server in that provider project: | |
| 137 | ||
| 138 | | Before | After | | |
| 139 | |---|---| | |
| 140 | | `loghq` CI reaches the `loghq` box | `loghq` CI reaches `statushq`, `bughq`, `stacks`, `localtunnels` | | |
| 141 | | `bughq` CI reaches the `bughq` box | `bughq` CI reaches all of the above | | |
| 142 | ||
| 143 | Three pipelines that each had blast radius over one box now each have it over the | |
| 144 | whole fleet. A compromised CI run or a mistargeted teardown reaches production | |
| 145 | systems belonging to unrelated apps. | |
| 146 | ||
| 147 | **Per-project isolation and attaching are mutually exclusive.** An app kept in its | |
| 148 | own provider project cannot be attached at all, because its token cannot see the | |
| 149 | owner's box. Co-hosting trades credential isolation for a shared box; that trade | |
| 150 | is often worth making, but it should be a decision rather than a surprise. | |
| 151 | ||
| 152 | `describeCredentialReach()` and `formatCredentialReach()` report what a given | |
| 153 | token actually reaches, separating the owner's boxes from the ones no one asked | |
| 154 | for, so the radius can be shown before an attach is approved. | |
| 155 | ||
| 108 | 156 | ## Two app models |
| 109 | 157 | |
| 110 | 158 | ts-cloud deploys apps two ways; pick per environment: |
| @@ -15,7 +15,34 @@ export interface CloudProviderConfig { | ||
| 15 | 15 | * this project's sites, and adds its own additive rpx `sites.d/<slug>.json` |
| 16 | 16 | * fragment + DNS — never touching the owner's box lifecycle, firewall, or other |
| 17 | 17 | * tenants. The owner provisions and manages the shared box; attachers only |
| 18 | * deploy onto it. Requires read access via the same `HCLOUD_TOKEN`. | |
| 18 | * deploy onto it. | |
| 19 | * | |
| 20 | * ## This widens what your CI credential can reach | |
| 21 | * | |
| 22 | * Attaching resolves the host by listing the provider's servers with THIS | |
| 23 | * project's own token, so the owner's box has to be visible to it, which means | |
| 24 | * both projects live in the same provider project. On Hetzner that is the whole | |
| 25 | * story: Cloud API tokens are scoped to a project with Read or Read & Write and | |
| 26 | * offer no per-resource scoping, and a deploy needs write. So the moment this is | |
| 27 | * set, this project's CI token can modify and delete EVERY server in that | |
| 28 | * provider project, not just the box it deploys to. | |
| 29 | * | |
| 30 | * Concretely, three apps that each owned one box become three pipelines that | |
| 31 | * each reach all three, plus anything else in the project. That is a real change | |
| 32 | * in blast radius: a compromised CI run or a mistargeted teardown now reaches | |
| 33 | * production systems belonging to unrelated apps. | |
| 34 | * | |
| 35 | * **Per-project isolation and attaching are mutually exclusive.** An app kept in | |
| 36 | * its own provider project cannot be attached at all, because its token cannot | |
| 37 | * see the owner's box. Choosing to co-host is choosing to trade credential | |
| 38 | * isolation for a shared box; the reverse trade is equally available, and neither | |
| 39 | * is a default worth stumbling into. | |
| 40 | * | |
| 41 | * `describeCredentialReach()` reports what a given token can actually reach, so | |
| 42 | * the radius can be shown before an attach is approved rather than discovered | |
| 43 | * afterwards. | |
| 44 | * | |
| 45 | * @see https://github.com/stacksjs/ts-cloud/issues/169 | |
| 19 | 46 | */ |
| 20 | 47 | attachTo?: string |
| 21 | 48 | } |
| @@ -0,0 +1,149 @@ | ||
| 1 | import { TS_CLOUD_LABEL_PREFIX } from '../drivers/hetzner/instance-sizes' | |
| 2 | ||
| 3 | /** | |
| 4 | * What a provider credential can actually reach, so an attach can say so before | |
| 5 | * it is approved. | |
| 6 | * | |
| 7 | * Attaching resolves the owner's box by LISTING the provider's servers with the | |
| 8 | * attaching project's own token. That listing is the whole mechanism, and it has | |
| 9 | * a consequence the config never states: the owner's box must be visible to the | |
| 10 | * attacher's credential, so both projects share one provider project. On Hetzner | |
| 11 | * a Cloud API token is scoped to a project with Read or Read & Write and has no | |
| 12 | * per-resource scoping, and a deploy needs write. Attaching therefore hands this | |
| 13 | * project's CI write access over every server in that project. | |
| 14 | * | |
| 15 | * That is not a hypothetical. Three apps that each owned one box become three | |
| 16 | * pipelines that each reach all three. The trade may well be worth it, but it | |
| 17 | * should be a decision rather than a discovery, and the only honest place to | |
| 18 | * surface it is the plan, where the operator is already looking. | |
| 19 | * | |
| 20 | * Pure and provider-agnostic on purpose: it reads a list of names and labels, so | |
| 21 | * any driver that can enumerate what its credential sees can report a radius, | |
| 22 | * rather than the reporting being welded to one provider's client. | |
| 23 | * | |
| 24 | * @see https://github.com/stacksjs/ts-cloud/issues/169 | |
| 25 | */ | |
| 26 | ||
| 27 | /** The label carrying a resource's owning project slug. */ | |
| 28 | const PROJECT_LABEL = `${TS_CLOUD_LABEL_PREFIX}/project` | |
| 29 | ||
| 30 | /** | |
| 31 | * The minimum a driver has to produce for a server to be attributed. | |
| 32 | * | |
| 33 | * Structural rather than a provider type so a Hetzner server satisfies it as-is | |
| 34 | * and another driver can satisfy it without importing anything. | |
| 35 | */ | |
| 36 | export interface ReachableServer { | |
| 37 | name: string | |
| 38 | labels?: Record<string, string> | |
| 39 | } | |
| 40 | ||
| 41 | export interface CredentialReach { | |
| 42 | /** Every server the credential enumerated. */ | |
| 43 | total: number | |
| 44 | /** Servers belonging to the project being attached TO. Expected reach. */ | |
| 45 | owner: string[] | |
| 46 | /** Servers belonging to the attaching project itself, if it has any. */ | |
| 47 | self: string[] | |
| 48 | /** Servers owned by unrelated ts-cloud projects, keyed by project slug. */ | |
| 49 | others: Map<string, string[]> | |
| 50 | /** | |
| 51 | * Servers carrying no ts-cloud project label. | |
| 52 | * | |
| 53 | * Counted separately because they are the reach most likely to surprise: not | |
| 54 | * managed by ts-cloud at all, and so not visible in any ts-cloud config, yet | |
| 55 | * just as writable by the token. | |
| 56 | */ | |
| 57 | unmanaged: string[] | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * Attribute every server the credential can see to a project. | |
| 62 | * | |
| 63 | * `ownerSlug` is what `cloud.attachTo` names; `selfSlug` is the deploying | |
| 64 | * project. Splitting those two out of `others` is the point: reach over the owner | |
| 65 | * is the reach being asked for, reach over anything else is the reach nobody | |
| 66 | * asked for, and only the second number is an argument against attaching. | |
| 67 | */ | |
| 68 | export function describeCredentialReach( | |
| 69 | servers: ReachableServer[], | |
| 70 | options: { ownerSlug: string, selfSlug?: string }, | |
| 71 | ): CredentialReach { | |
| 72 | const ownerSlug = options.ownerSlug.trim() | |
| 73 | const selfSlug = options.selfSlug?.trim() | |
| 74 | ||
| 75 | const reach: CredentialReach = { total: servers.length, owner: [], self: [], others: new Map(), unmanaged: [] } | |
| 76 | ||
| 77 | for (const server of servers) { | |
| 78 | const project = server.labels?.[PROJECT_LABEL]?.trim() | |
| 79 | ||
| 80 | if (!project) { | |
| 81 | reach.unmanaged.push(server.name) | |
| 82 | continue | |
| 83 | } | |
| 84 | if (project === ownerSlug) { | |
| 85 | reach.owner.push(server.name) | |
| 86 | continue | |
| 87 | } | |
| 88 | if (selfSlug && project === selfSlug) { | |
| 89 | reach.self.push(server.name) | |
| 90 | continue | |
| 91 | } | |
| 92 | ||
| 93 | const existing = reach.others.get(project) | |
| 94 | if (existing) existing.push(server.name) | |
| 95 | else reach.others.set(project, [server.name]) | |
| 96 | } | |
| 97 | ||
| 98 | return reach | |
| 99 | } | |
| 100 | ||
| 101 | /** Servers the credential reaches that neither project being joined owns. */ | |
| 102 | export function unrelatedReachCount(reach: CredentialReach): number { | |
| 103 | let count = reach.unmanaged.length | |
| 104 | for (const names of reach.others.values()) count += names.length | |
| 105 | return count | |
| 106 | } | |
| 107 | ||
| 108 | /** | |
| 109 | * The credential radius as plan lines, ready to print. | |
| 110 | * | |
| 111 | * Returns lines rather than printing so the caller owns the output stream and | |
| 112 | * this stays testable. Sorted, because a plan that reorders itself between runs | |
| 113 | * is a plan nobody diffs. | |
| 114 | * | |
| 115 | * When the reach is exactly the owner's box and this project's own, there is | |
| 116 | * nothing to warn about and the summary says so in one line - a warning that | |
| 117 | * fires every time is a warning that gets skipped. | |
| 118 | */ | |
| 119 | export function formatCredentialReach( | |
| 120 | reach: CredentialReach, | |
| 121 | options: { ownerSlug: string, selfSlug?: string }, | |
| 122 | ): string[] { | |
| 123 | const unrelated = unrelatedReachCount(reach) | |
| 124 | const lines: string[] = [] | |
| 125 | ||
| 126 | lines.push( | |
| 127 | `Attaching to '${options.ownerSlug}' shares one provider project, so this deploy's credential can write to all ${reach.total} server(s) it can see.`, | |
| 128 | ) | |
| 129 | ||
| 130 | if (unrelated === 0) { | |
| 131 | lines.push('Nothing outside the two projects being joined is reachable with it.') | |
| 132 | return lines | |
| 133 | } | |
| 134 | ||
| 135 | lines.push(`${unrelated} of them belong to neither project:`) | |
| 136 | ||
| 137 | for (const project of [...reach.others.keys()].sort()) | |
| 138 | lines.push(` ${project}: ${[...reach.others.get(project)!].sort().join(', ')}`) | |
| 139 | ||
| 140 | if (reach.unmanaged.length > 0) | |
| 141 | lines.push(` not managed by ts-cloud: ${[...reach.unmanaged].sort().join(', ')}`) | |
| 142 | ||
| 143 | lines.push( | |
| 144 | 'A compromised CI run or a mistargeted teardown in this project now reaches those. ' | |
| 145 | + 'Keep the app in its own provider project instead if that is not acceptable, which rules out attaching.', | |
| 146 | ) | |
| 147 | ||
| 148 | return lines | |
| 149 | } | |
| @@ -0,0 +1,133 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { | |
| 3 | describeCredentialReach, | |
| 4 | formatCredentialReach, | |
| 5 | unrelatedReachCount, | |
| 6 | } from '../../src/deploy/attach-credentials' | |
| 7 | import { TS_CLOUD_LABEL_PREFIX } from '../../src/drivers/hetzner/instance-sizes' | |
| 8 | ||
| 9 | const PROJECT_LABEL = `${TS_CLOUD_LABEL_PREFIX}/project` | |
| 10 | ||
| 11 | /** A server as a driver's `listServers()` returns it, labelled or not. */ | |
| 12 | function server(name: string, project?: string) { | |
| 13 | return project === undefined ? { name } : { name, labels: { [PROJECT_LABEL]: project } } | |
| 14 | } | |
| 15 | ||
| 16 | /** The fleet from the issue: one Hetzner project, five boxes. */ | |
| 17 | const fleet = [ | |
| 18 | server('statushq-production-app', 'statushq'), | |
| 19 | server('bughq-production-app', 'bughq'), | |
| 20 | server('loghq-production-app', 'loghq'), | |
| 21 | server('stacks-production-app', 'stacks'), | |
| 22 | server('localtunnels-production-app', 'localtunnels'), | |
| 23 | ] | |
| 24 | ||
| 25 | describe('describeCredentialReach', () => { | |
| 26 | it('separates the owner, this project, unrelated projects and unmanaged boxes', () => { | |
| 27 | const reach = describeCredentialReach( | |
| 28 | [...fleet, server('someones-database')], | |
| 29 | { ownerSlug: 'statushq', selfSlug: 'loghq' }, | |
| 30 | ) | |
| 31 | ||
| 32 | expect(reach.total).toBe(6) | |
| 33 | expect(reach.owner).toEqual(['statushq-production-app']) | |
| 34 | expect(reach.self).toEqual(['loghq-production-app']) | |
| 35 | expect([...reach.others.keys()].sort()).toEqual(['bughq', 'localtunnels', 'stacks']) | |
| 36 | expect(reach.unmanaged).toEqual(['someones-database']) | |
| 37 | }) | |
| 38 | ||
| 39 | it('groups several servers under one project', () => { | |
| 40 | const reach = describeCredentialReach( | |
| 41 | [server('bughq-production-app', 'bughq'), server('bughq-production-lb', 'bughq')], | |
| 42 | { ownerSlug: 'statushq', selfSlug: 'loghq' }, | |
| 43 | ) | |
| 44 | ||
| 45 | expect(reach.others.get('bughq')).toEqual(['bughq-production-app', 'bughq-production-lb']) | |
| 46 | }) | |
| 47 | ||
| 48 | it("treats this project's own boxes as unrelated when no selfSlug is given", () => { | |
| 49 | const reach = describeCredentialReach([server('loghq-production-app', 'loghq')], { ownerSlug: 'statushq' }) | |
| 50 | ||
| 51 | expect(reach.self).toEqual([]) | |
| 52 | expect(reach.others.get('loghq')).toEqual(['loghq-production-app']) | |
| 53 | }) | |
| 54 | ||
| 55 | it('counts a blank project label as unmanaged rather than as a project named ""', () => { | |
| 56 | const reach = describeCredentialReach( | |
| 57 | [{ name: 'odd-box', labels: { [PROJECT_LABEL]: ' ' } }], | |
| 58 | { ownerSlug: 'statushq' }, | |
| 59 | ) | |
| 60 | ||
| 61 | expect(reach.unmanaged).toEqual(['odd-box']) | |
| 62 | expect(reach.others.size).toBe(0) | |
| 63 | }) | |
| 64 | ||
| 65 | it('reports nothing reachable for an empty listing', () => { | |
| 66 | const reach = describeCredentialReach([], { ownerSlug: 'statushq', selfSlug: 'loghq' }) | |
| 67 | ||
| 68 | expect(reach.total).toBe(0) | |
| 69 | expect(unrelatedReachCount(reach)).toBe(0) | |
| 70 | }) | |
| 71 | }) | |
| 72 | ||
| 73 | describe('unrelatedReachCount', () => { | |
| 74 | it('counts every server neither joined project owns', () => { | |
| 75 | const reach = describeCredentialReach([...fleet, server('someones-database')], { | |
| 76 | ownerSlug: 'statushq', | |
| 77 | selfSlug: 'loghq', | |
| 78 | }) | |
| 79 | ||
| 80 | // bughq + stacks + localtunnels + the unlabelled box. | |
| 81 | expect(unrelatedReachCount(reach)).toBe(4) | |
| 82 | }) | |
| 83 | }) | |
| 84 | ||
| 85 | describe('formatCredentialReach', () => { | |
| 86 | it('says so in one line when only the two joined projects are reachable', () => { | |
| 87 | const reach = describeCredentialReach( | |
| 88 | [server('statushq-production-app', 'statushq'), server('loghq-production-app', 'loghq')], | |
| 89 | { ownerSlug: 'statushq', selfSlug: 'loghq' }, | |
| 90 | ) | |
| 91 | ||
| 92 | const lines = formatCredentialReach(reach, { ownerSlug: 'statushq', selfSlug: 'loghq' }) | |
| 93 | ||
| 94 | expect(lines).toHaveLength(2) | |
| 95 | expect(lines[1]).toContain('Nothing outside the two projects') | |
| 96 | }) | |
| 97 | ||
| 98 | it('names every unrelated project and box, sorted so the plan is diffable', () => { | |
| 99 | const reach = describeCredentialReach([...fleet, server('someones-database')], { | |
| 100 | ownerSlug: 'statushq', | |
| 101 | selfSlug: 'loghq', | |
| 102 | }) | |
| 103 | ||
| 104 | const lines = formatCredentialReach(reach, { ownerSlug: 'statushq', selfSlug: 'loghq' }) | |
| 105 | const body = lines.join('\n') | |
| 106 | ||
| 107 | expect(lines[0]).toContain('all 6 server(s)') | |
| 108 | expect(body).toContain('4 of them belong to neither project') | |
| 109 | expect(body).toContain('bughq: bughq-production-app') | |
| 110 | expect(body).toContain('localtunnels: localtunnels-production-app') | |
| 111 | expect(body).toContain('stacks: stacks-production-app') | |
| 112 | expect(body).toContain('not managed by ts-cloud: someones-database') | |
| 113 | ||
| 114 | // The unrelated projects appear in sorted order. | |
| 115 | expect(body.indexOf('bughq:')).toBeLessThan(body.indexOf('localtunnels:')) | |
| 116 | expect(body.indexOf('localtunnels:')).toBeLessThan(body.indexOf('stacks:')) | |
| 117 | }) | |
| 118 | ||
| 119 | it('states the isolation tradeoff, since that is the decision being made', () => { | |
| 120 | const reach = describeCredentialReach(fleet, { ownerSlug: 'statushq', selfSlug: 'loghq' }) | |
| 121 | ||
| 122 | expect(formatCredentialReach(reach, { ownerSlug: 'statushq', selfSlug: 'loghq' }).join('\n')) | |
| 123 | .toContain('own provider project') | |
| 124 | }) | |
| 125 | ||
| 126 | it('is stable across runs for the same input', () => { | |
| 127 | const reach = describeCredentialReach([...fleet].reverse(), { ownerSlug: 'statushq', selfSlug: 'loghq' }) | |
| 128 | const a = formatCredentialReach(reach, { ownerSlug: 'statushq', selfSlug: 'loghq' }) | |
| 129 | const b = formatCredentialReach(reach, { ownerSlug: 'statushq', selfSlug: 'loghq' }) | |
| 130 | ||
| 131 | expect(a).toEqual(b) | |
| 132 | }) | |
| 133 | }) | |