Commit 8149e7fc18
Unsigned
Layout: unified · split
docs/architecture.md added +266
| @@ -0,0 +1,266 @@ | |||
| 1 | # Architecture | ||
| 2 | |||
| 3 | gh-attest is a single [Cloudflare Worker](../src/index.ts) with three entry | ||
| 4 | points — an HTTP router, an hourly cron, and a queue consumer — backed by D1, | ||
| 5 | R2, and a Queue, all provisioned in Cloudflare's `eu` jurisdiction. It ingests | ||
| 6 | GitHub security posture as immutable timestamped snapshots and renders them into | ||
| 7 | auditor-ready evidence packages mapped to SOC 2 / ISO 27001 controls. | ||
| 8 | |||
| 9 | This document is the shape of the system. For *why* a given GitHub signal counts | ||
| 10 | as evidence for a given control, see [framework-mapping.md](framework-mapping.md). | ||
| 11 | |||
| 12 | ## System context | ||
| 13 | |||
| 14 | ```mermaid | ||
| 15 | flowchart LR | ||
| 16 | subgraph ext[External] | ||
| 17 | GH["GitHub App<br/>webhooks · OAuth · REST API"] | ||
| 18 | USER["User / Auditor<br/>browser"] | ||
| 19 | end | ||
| 20 | |||
| 21 | subgraph cf["Cloudflare Worker — src/index.ts"] | ||
| 22 | FETCH["fetch()<br/>HTTP router"] | ||
| 23 | CRON["scheduled()<br/>hourly cron"] | ||
| 24 | QUEUE["queue()<br/>export consumer"] | ||
| 25 | end | ||
| 26 | |||
| 27 | subgraph store["Storage — EU jurisdiction"] | ||
| 28 | D1[("D1<br/>gh-attest-db-eu")] | ||
| 29 | R2[("R2<br/>gh-attest-exports-eu")] | ||
| 30 | Q[["Queue<br/>generate-export"]] | ||
| 31 | end | ||
| 32 | |||
| 33 | GH -- "POST /webhooks/*" --> FETCH | ||
| 34 | USER -- "login · dashboard · exports" --> FETCH | ||
| 35 | CRON -- "poll (installation token)" --> GH | ||
| 36 | |||
| 37 | FETCH -- "snapshots · export rows" --> D1 | ||
| 38 | FETCH -- "enqueue job" --> Q | ||
| 39 | FETCH -- "stream download" --> R2 | ||
| 40 | |||
| 41 | CRON -- "snapshots · retention" --> D1 | ||
| 42 | CRON -- "retention delete" --> R2 | ||
| 43 | |||
| 44 | Q --> QUEUE | ||
| 45 | QUEUE -- "read evidence" --> D1 | ||
| 46 | QUEUE -- "write file" --> R2 | ||
| 47 | ``` | ||
| 48 | |||
| 49 | ## Entry points | ||
| 50 | |||
| 51 | | Handler | Trigger | Responsibility | Code | | ||
| 52 | | --- | --- | --- | --- | | ||
| 53 | | `fetch` | HTTP request | Webhooks, OAuth/session dashboard, export requests + downloads, admin ops | [index.ts](../src/index.ts) | | ||
| 54 | | `scheduled` | Cron `0 * * * *` (hourly) | Poll GitHub for state webhooks never announce; enforce retention | [index.ts](../src/index.ts) | | ||
| 55 | | `queue` | `generate-export` message | Render CSV/PDF off the request path into R2 | [index.ts](../src/index.ts) | | ||
| 56 | |||
| 57 | ### Routes and their auth | ||
| 58 | |||
| 59 | | Route | Auth | Purpose | | ||
| 60 | | --- | --- | --- | | ||
| 61 | | `POST /webhooks/github`, `/webhooks/marketplace` | HMAC (`GITHUB_WEBHOOK_SECRET`) | Ingest App / marketplace events | | ||
| 62 | | `GET /login`, `/callback`, `/logout` | OAuth state cookie | Dashboard sign-in | | ||
| 63 | | `GET /`, `/access-review` | Session cookie | Posture dashboard, membership diff | | ||
| 64 | | `POST /exports`, `/resync`, `/switch` | Session cookie | Dashboard actions (installation-scoped) | | ||
| 65 | | `GET /exports/:id[/download]` | Session cookie | Export status / file (scoped to installation) | | ||
| 66 | | `POST /admin/{poll,export,cleanup,purge}`, `GET /admin/export/:id` | Bearer (`ADMIN_TOKEN`) | Operations | | ||
| 67 | |||
| 68 | ## Ingestion — two paths into one table | ||
| 69 | |||
| 70 | Both paths write to the append-only `snapshots` table; nothing is ever updated | ||
| 71 | in place, which is what makes the table a point-in-time audit trail. | ||
| 72 | |||
| 73 | ### Webhooks (change events) | ||
| 74 | |||
| 75 | ```mermaid | ||
| 76 | sequenceDiagram | ||
| 77 | autonumber | ||
| 78 | participant GH as GitHub | ||
| 79 | participant W as Worker (fetch) | ||
| 80 | participant D1 as D1 | ||
| 81 | participant R2 as R2 | ||
| 82 | |||
| 83 | GH->>W: POST /webhooks/github (event + X-Hub-Signature-256) | ||
| 84 | W->>W: verifySignature(body, GITHUB_WEBHOOK_SECRET) | ||
| 85 | alt invalid signature | ||
| 86 | W-->>GH: 401 | ||
| 87 | else valid | ||
| 88 | W->>W: extractFact(event) → {resource, status} | ||
| 89 | W->>D1: upsert installations row | ||
| 90 | alt installation.deleted | ||
| 91 | W->>D1: DELETE all rows for installation | ||
| 92 | W->>R2: delete export objects | ||
| 93 | W-->>GH: 200 (purged) | ||
| 94 | else suspend / unsuspend | ||
| 95 | W->>D1: toggle suspended_at | ||
| 96 | W-->>GH: 200 | ||
| 97 | else normal event | ||
| 98 | W->>D1: INSERT snapshot (append-only) | ||
| 99 | W-->>GH: 200 | ||
| 100 | end | ||
| 101 | end | ||
| 102 | ``` | ||
| 103 | |||
| 104 | ### Hourly poll + retention (baseline / drift) | ||
| 105 | |||
| 106 | Webhooks only fire on change, so protection that existed *before* install, and | ||
| 107 | current membership, would never appear. The cron closes that gap and enforces | ||
| 108 | the retention windows in the same invocation. | ||
| 109 | |||
| 110 | ```mermaid | ||
| 111 | sequenceDiagram | ||
| 112 | autonumber | ||
| 113 | participant Cron as scheduled (hourly) | ||
| 114 | participant W as Worker | ||
| 115 | participant GH as GitHub REST | ||
| 116 | participant D1 as D1 | ||
| 117 | participant R2 as R2 | ||
| 118 | |||
| 119 | Cron->>W: fire | ||
| 120 | par Poll every active installation | ||
| 121 | W->>GH: app JWT → installation token | ||
| 122 | W->>GH: repos · branch protection · rulesets · org/team members | ||
| 123 | GH-->>W: current state | ||
| 124 | W->>D1: INSERT snapshots (one captured_at per batch) | ||
| 125 | and Retention cleanup | ||
| 126 | W->>D1: SELECT expired export r2_keys | ||
| 127 | W->>R2: delete expired objects | ||
| 128 | W->>D1: DELETE exports > 90d, snapshots > 396d | ||
| 129 | end | ||
| 130 | ``` | ||
| 131 | |||
| 132 | ## Export pipeline | ||
| 133 | |||
| 134 | Rendering runs off the request path via the Queue because PDF / large CSV can | ||
| 135 | exceed request CPU limits. The `exports` row is the job's state machine | ||
| 136 | (`queued → processing → done | error`). | ||
| 137 | |||
| 138 | ```mermaid | ||
| 139 | sequenceDiagram | ||
| 140 | autonumber | ||
| 141 | participant U as User (session) | ||
| 142 | participant W as Worker (fetch) | ||
| 143 | participant D1 as D1 | ||
| 144 | participant Q as Queue | ||
| 145 | participant C as Worker (queue) | ||
| 146 | participant R2 as R2 | ||
| 147 | |||
| 148 | U->>W: POST /exports (framework, format) | ||
| 149 | W->>D1: INSERT exports (status=queued) | ||
| 150 | W->>Q: send job | ||
| 151 | W-->>U: 303 redirect to dashboard | ||
| 152 | |||
| 153 | Q->>C: deliver job | ||
| 154 | C->>D1: status=processing | ||
| 155 | C->>D1: buildEvidenceRows — snapshots ⋈ control_mappings | ||
| 156 | C->>C: renderCsv / renderPdf | ||
| 157 | C->>R2: put file at exports/{installation}/{jobId}.{fmt} | ||
| 158 | C->>D1: status=done, r2_key | ||
| 159 | Note over C,D1: render failure → status=error (deterministic, not retried) | ||
| 160 | |||
| 161 | U->>W: GET /exports/:id/download | ||
| 162 | W->>D1: lookup scoped to session installation | ||
| 163 | W->>R2: get object | ||
| 164 | R2-->>W: file body | ||
| 165 | W-->>U: stream (Content-Disposition: attachment) | ||
| 166 | ``` | ||
| 167 | |||
| 168 | ## Data model | ||
| 169 | |||
| 170 | `control_mappings` has **no foreign key** to `snapshots`. Mapping is a join on | ||
| 171 | `(resource, status)` performed at query/export time — so a mapping can be | ||
| 172 | corrected without re-ingesting webhook history. A mapping row with `status = | ||
| 173 | NULL` matches any status for that resource. | ||
| 174 | |||
| 175 | ```mermaid | ||
| 176 | erDiagram | ||
| 177 | installations ||--o{ snapshots : has | ||
| 178 | installations ||--o{ exports : has | ||
| 179 | snapshots }o..o{ control_mappings : "query-time join on (resource, status)" | ||
| 180 | |||
| 181 | installations { | ||
| 182 | integer installation_id PK | ||
| 183 | text org_login | ||
| 184 | text installed_at | ||
| 185 | text suspended_at "null unless suspended" | ||
| 186 | } | ||
| 187 | snapshots { | ||
| 188 | integer id PK | ||
| 189 | integer installation_id FK | ||
| 190 | text repo "null for org-level facts" | ||
| 191 | text subject "member/team for access facts" | ||
| 192 | text resource "e.g. branch_protection" | ||
| 193 | text status "e.g. enabled | open | added" | ||
| 194 | text raw_payload "original JSON, audit trail" | ||
| 195 | text captured_at | ||
| 196 | } | ||
| 197 | control_mappings { | ||
| 198 | integer id PK | ||
| 199 | text resource | ||
| 200 | text status "null matches any" | ||
| 201 | text framework "soc2 | iso27001" | ||
| 202 | text control_id "e.g. CC8.1 | A.8.32" | ||
| 203 | text posture "positive | negative | informational" | ||
| 204 | text rationale | ||
| 205 | } | ||
| 206 | exports { | ||
| 207 | text id PK "uuid" | ||
| 208 | integer installation_id FK | ||
| 209 | text framework | ||
| 210 | text format "csv | pdf" | ||
| 211 | text status "queued|processing|done|error" | ||
| 212 | text r2_key "null until rendered" | ||
| 213 | text error | ||
| 214 | text created_at | ||
| 215 | text completed_at | ||
| 216 | } | ||
| 217 | ``` | ||
| 218 | |||
| 219 | ### Evidence query | ||
| 220 | |||
| 221 | `buildEvidenceRows` ([exporter.ts](../src/exporter.ts)) reduces the append-only | ||
| 222 | table to current posture, then attaches controls: | ||
| 223 | |||
| 224 | ```mermaid | ||
| 225 | flowchart TB | ||
| 226 | S[("snapshots<br/>append-only")] --> L["latest row per<br/>(repo, subject, resource)"] | ||
| 227 | L --> J{{"JOIN control_mappings<br/>on resource + status"}} | ||
| 228 | CM[("control_mappings")] --> J | ||
| 229 | J --> F["filter by framework<br/>(soc2 | iso27001 | all)"] | ||
| 230 | F --> E["evidence rows<br/>control · posture · rationale"] | ||
| 231 | E --> CSV["renderCsv"] | ||
| 232 | E --> PDF["renderPdf"] | ||
| 233 | ``` | ||
| 234 | |||
| 235 | Unmapped `(resource, status)` pairs (e.g. `unavailable`, raw `push`) simply | ||
| 236 | produce no rows — no evidence in either direction. | ||
| 237 | |||
| 238 | ## Boundaries & isolation | ||
| 239 | |||
| 240 | - **Multi-tenant scoping.** Every session route is scoped to the session's | ||
| 241 | `installationId`; the allowed set lives in the signed session, so a tampered | ||
| 242 | id can't widen access. Export downloads are looked up with an installation | ||
| 243 | filter, so one org can't read another's file by guessing its UUID. | ||
| 244 | - **Three auth schemes.** HMAC for webhooks, signed session cookies | ||
| 245 | (`SESSION_SECRET`) for the dashboard, timing-safe bearer (`ADMIN_TOKEN`) for | ||
| 246 | `/admin/*`. | ||
| 247 | - **Data residency.** D1, R2, and the Queue are all in the `eu` jurisdiction; | ||
| 248 | see [wrangler.jsonc](../wrangler.jsonc). No source code or access tokens are | ||
| 249 | stored — only posture facts and their raw event JSON. | ||
| 250 | - **Deletion.** Uninstall (`installation.deleted`) purges all D1 rows and R2 | ||
| 251 | objects for the installation; `/admin/purge` does the same on request. | ||
| 252 | |||
| 253 | ## Where things live | ||
| 254 | |||
| 255 | | Concern | File | | ||
| 256 | | --- | --- | | ||
| 257 | | Router, handlers, retention, purge | [src/index.ts](../src/index.ts) | | ||
| 258 | | Webhook verification + fact extraction | [src/webhook.ts](../src/webhook.ts) | | ||
| 259 | | GitHub polling (protection, rulesets, access) | [src/poller.ts](../src/poller.ts) | | ||
| 260 | | App JWT + installation tokens | [src/github-app.ts](../src/github-app.ts) | | ||
| 261 | | OAuth + session sign/verify | [src/auth.ts](../src/auth.ts) | | ||
| 262 | | Evidence query + CSV/PDF rendering | [src/exporter.ts](../src/exporter.ts) | | ||
| 263 | | Access-review diff | [src/access-review.ts](../src/access-review.ts) | | ||
| 264 | | Dashboard HTML | [src/dashboard.ts](../src/dashboard.ts) | | ||
| 265 | | Schema + control mappings | [migrations/](../migrations) | | ||
| 266 | ``` | ||