ReviewOS

also looking at this

stacks/ts-cloud

docs(cloud): say what attaching does to a credential's blast radius

#171
Merged glennmichael123 wants to merge docs/attach-credential-reach into main
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.

docs/config.mdmodified+48-0
Changes to docs/config.md
@@ -105,6 +105,54 @@ Every field is optional, and each resolves through the same chain:
105105
106106`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.
107107
108## Attaching to another project's server
109
110Set `cloud.attachTo` to an owner project's `project.slug` to deploy this project's
111sites onto a box that project already runs, instead of provisioning one:
112
113```typescript
114export 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
121The deploy targets the owner's `<slug>-<environment>-app` server, ships only this
122project's sites, and adds its own additive rpx `sites.d/<slug>.json` fragment plus
123DNS. It never touches the owner's box lifecycle, firewall, or other tenants: the
124owner provisions and manages the shared box, attachers only deploy onto it.
125
126### It widens what your CI credential can reach
127
128Attaching finds the owner's box by **listing** the provider's servers with this
129project's own token. That is the whole mechanism, and it has a consequence worth
130stating plainly before you adopt it: the owner's box must be visible to the
131attacher's credential, so both projects have to live in the same provider project.
132
133On Hetzner there is no narrower option. A Cloud API token is scoped to a project
134with Read or Read & Write, with no per-resource scoping, and a deploy needs write.
135So once `attachTo` is set, this project's CI token can modify and delete every
136server 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
143Three pipelines that each had blast radius over one box now each have it over the
144whole fleet. A compromised CI run or a mistargeted teardown reaches production
145systems belonging to unrelated apps.
146
147**Per-project isolation and attaching are mutually exclusive.** An app kept in its
148own provider project cannot be attached at all, because its token cannot see the
149owner's box. Co-hosting trades credential isolation for a shared box; that trade
150is often worth making, but it should be a decision rather than a surprise.
151
152`describeCredentialReach()` and `formatCredentialReach()` report what a given
153token actually reaches, separating the owner's boxes from the ones no one asked
154for, so the radius can be shown before an attach is approved.
155
108156## Two app models
109157
110158ts-cloud deploys apps two ways; pick per environment:
packages/core/src/types.tsmodified+28-1
Changes to packages/core/src/types.ts
@@ -15,7 +15,34 @@ export interface CloudProviderConfig {
1515 * this project's sites, and adds its own additive rpx `sites.d/<slug>.json`
1616 * fragment + DNS never touching the owner's box lifecycle, firewall, or other
1717 * 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
1946 */
2047 attachTo?: string
2148}
packages/ts-cloud/src/deploy/attach-credentials.tsadded+149-0
Changes to packages/ts-cloud/src/deploy/attach-credentials.ts
@@ -0,0 +1,149 @@
1import { 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. */
28const 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 */
36export interface ReachableServer {
37 name: string
38 labels?: Record<string, string>
39}
40
41export 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 */
68export 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. */
102export 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 */
119export 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}
packages/ts-cloud/test/deploy/attach-credentials.test.tsadded+133-0
Changes to packages/ts-cloud/test/deploy/attach-credentials.test.ts
@@ -0,0 +1,133 @@
1import { describe, expect, it } from 'bun:test'
2import {
3 describeCredentialReach,
4 formatCredentialReach,
5 unrelatedReachCount,
6} from '../../src/deploy/attach-credentials'
7import { TS_CLOUD_LABEL_PREFIX } from '../../src/drivers/hetzner/instance-sizes'
8
9const PROJECT_LABEL = `${TS_CLOUD_LABEL_PREFIX}/project`
10
11/** A server as a driver's `listServers()` returns it, labelled or not. */
12function 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. */
17const 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
25describe('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
73describe('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
85describe('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})