Skip to content

Commit d79e90a

Browse files
committed
fix(handler): gate /api/audit on Universe staff team
1 parent d5f24e0 commit d79e90a

7 files changed

Lines changed: 96 additions & 18 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ VALKEY_ADDR=localhost:6379
4747
# GH_REPO_ORG=freeCodeCamp-Universe # org repos are created in + teams gate repo authz
4848
# REPO_CREATE_AUTHZ_TEAM=staff # gates POST /api/repo
4949
# REPO_APPROVE_AUTHZ_TEAM=none # gates approve/reject/delete; production must override
50+
# AUDIT_READ_AUTHZ_TEAM=staff # gates GET /api/audit; team in GH_REPO_ORG, probed via Universe-org client
5051
# GH_APP_ID= # Apollo-11 GitHub App id (digits only)
5152
# GH_APP_INSTALLATION_ID= # App installation id (digits only)
5253
# GH_APP_PRIVATE_KEY= # App private key PEM (PKCS#1 or PKCS#8)

cmd/artemis/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ func run() error {
270270
RepoOrg: cfg.Repo.Org,
271271
RepoCreateAuthzTeam: cfg.Repo.CreateAuthzTeam,
272272
RepoApproveAuthzTeam: cfg.Repo.ApproveAuthzTeam,
273+
AuditReadAuthzTeam: cfg.Repo.AuditReadAuthzTeam,
273274
NewDeployID: r2.NewDeployID,
274275
Now: time.Now,
275276
}

docs/README.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ POST /api/deploy/{deployId}/finalize { mode } → { url }
4040

4141
`/api/repo*` is mounted only when `RepoEnabled()` is true (Apollo-11 App credentials configured — see Configuration). `DELETE /api/site/{slug}?purge=true` additionally moves the site's R2 prefix to `_trash/` and records a tombstone (gated the same as the plain delete); the bare `DELETE` only removes the registry row. `POST /api/site/{site}/deploys/{deployId}/restore` reverses a `DELETE .../deploys/{deployId}` tombstone, moving the bytes back from `_trash/` and re-marking the deploy active; `GET /api/site/{site}/trash` lists the site's tombstoned deploys with their purge-eligibility `expiresAt` (`CLEANUP_RECOVERY_DAYS` out from `trashedAt`).
4242

43-
`GET /api/audit` reads the durable, append-only `audit_log` — every privileged action (deploy, site, repo lifecycle, GC tombstone/reconcile) attributed to an actor. Filter by `site` / `actor` / `action` / `since` (RFC3339), paginated (`limit` default 100, max 500; `offset`), newest-first. It replaces the raw-`psql`-on-prod path for reading the trail. From the CLI: `universe audit ls [--actor --action --site --since --limit] [--json]` (universe-cli release follows artemis v1.5.0, since it depends on the deployed endpoint).
43+
`GET /api/audit` reads the durable, append-only `audit_log` — every privileged action (deploy, site, repo lifecycle, GC tombstone/reconcile) attributed to an actor. Filter by `site` / `actor` / `action` / `since` (RFC3339), paginated (`limit` default 100, max 500; `offset`), newest-first. It replaces the raw-`psql`-on-prod path for reading the trail. Because the trail is cross-tenant, the endpoint is team-gated: the caller must be on the Universe-org staff team (`AUDIT_READ_AUTHZ_TEAM`, default `staff`) — not merely any authenticated GitHub bearer. From the CLI: `universe audit ls [--actor --action --site --since --limit] [--json]` (universe-cli release follows artemis v1.5.0, since it depends on the deployed endpoint).
4444

4545
Auth headers (`/api/*` except `/healthz`, `/readyz`):
4646

@@ -49,7 +49,7 @@ Auth headers (`/api/*` except `/healthz`, `/readyz`):
4949
| `GET /api/*`, `POST /api/deploy/init`, `POST /api/site/*`, `POST`/`GET`/`DELETE /api/repo*` | GitHub token (PAT / OIDC) |
5050
| `PUT /api/deploy/{deployId}/upload`, `POST /api/deploy/{deployId}/finalize` | Deploy-session JWT (HS256, ≤15 min, scoped to one `(login, site, deployId)`) |
5151

52-
Team-gated beyond the base GitHub-bearer check: `POST /api/site/register`, `PATCH /api/site/{slug}`, `DELETE /api/site/{slug}` (`REGISTRY_AUTHZ_TEAM`); `POST /api/repo` (`REPO_CREATE_AUTHZ_TEAM`); `POST /api/repo/{id}/approve`, `POST /api/repo/{id}/reject`, `DELETE /api/repo/{id}` (`REPO_APPROVE_AUTHZ_TEAM`). All other `/api/*` reads are open to any authenticated GitHub bearer.
52+
Team-gated beyond the base GitHub-bearer check: `POST /api/site/register`, `PATCH /api/site/{slug}`, `DELETE /api/site/{slug}` (`REGISTRY_AUTHZ_TEAM`); `POST /api/repo` (`REPO_CREATE_AUTHZ_TEAM`); `POST /api/repo/{id}/approve`, `POST /api/repo/{id}/reject`, `DELETE /api/repo/{id}` (`REPO_APPROVE_AUTHZ_TEAM`); `GET /api/audit` (`AUDIT_READ_AUTHZ_TEAM`, the sole team-gated read — cross-tenant trail). All other `/api/*` reads are open to any authenticated GitHub bearer.
5353

5454
## Configuration (env-driven)
5555

@@ -91,14 +91,15 @@ Loaded + validated in `internal/config/config.go` (`Load()` — fails fast on th
9191

9292
**Repo-creation (Apollo-11, feature-gated)**
9393

94-
| Variable | Default | Description |
95-
| ------------------------- | ---------------------------- | ------------------------------------------------------------------------------- |
96-
| `GH_REPO_ORG` | `freeCodeCamp-Universe` | Org repos are created in + whose teams gate repo authz (distinct from `GH_ORG`) |
97-
| `REPO_CREATE_AUTHZ_TEAM` | `staff` | GH team gating `POST /api/repo` |
98-
| `REPO_APPROVE_AUTHZ_TEAM` | `none` | GH team gating approve/reject/delete; placeholder — production must override |
99-
| `GH_APP_ID` | _(empty → repo feature off)_ | Apollo-11 GitHub App id (numeric string) |
100-
| `GH_APP_INSTALLATION_ID` | _(empty)_ | App installation id (numeric string) |
101-
| `GH_APP_PRIVATE_KEY` | _(empty)_ | App private key PEM (PKCS#1 or PKCS#8) |
94+
| Variable | Default | Description |
95+
| ------------------------- | ---------------------------- | -------------------------------------------------------------------------------------- |
96+
| `GH_REPO_ORG` | `freeCodeCamp-Universe` | Org repos are created in + whose teams gate repo authz (distinct from `GH_ORG`) |
97+
| `REPO_CREATE_AUTHZ_TEAM` | `staff` | GH team gating `POST /api/repo` |
98+
| `REPO_APPROVE_AUTHZ_TEAM` | `none` | GH team gating approve/reject/delete; placeholder — production must override |
99+
| `AUDIT_READ_AUTHZ_TEAM` | `staff` | GH team (in `GH_REPO_ORG`) gating `GET /api/audit`; probed via the Universe-org client |
100+
| `GH_APP_ID` | _(empty → repo feature off)_ | Apollo-11 GitHub App id (numeric string) |
101+
| `GH_APP_INSTALLATION_ID` | _(empty)_ | App installation id (numeric string) |
102+
| `GH_APP_PRIVATE_KEY` | _(empty)_ | App private key PEM (PKCS#1 or PKCS#8) |
102103

103104
`GH_APP_ID` / `GH_APP_INSTALLATION_ID` / `GH_APP_PRIVATE_KEY` are all-or-none: set all three to enable the `/api/repo*` self-service repo-creation feature, or none. The two ids must be digit-only strings — `validate()` rejects a malformed value at boot (a YAML int sealed in sops renders as scientific notation through Helm `quote`; seal them as strings).
104105

internal/config/config.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,9 @@ type RepoConfig struct {
152152
// CreateAuthzTeam gates POST /api/repo. Default "staff". List, get,
153153
// and templates are open to any GitHub bearer (no team gate).
154154
// Both are slugs in Org.
155-
CreateAuthzTeam string
156-
ApproveAuthzTeam string
155+
CreateAuthzTeam string
156+
ApproveAuthzTeam string
157+
AuditReadAuthzTeam string
157158

158159
App GitHubAppConfig
159160
}
@@ -185,6 +186,7 @@ const (
185186
defaultRepoOrg = "freeCodeCamp-Universe"
186187
defaultRepoCreateAuthzTeam = "staff"
187188
defaultRepoApproveAuthzTeam = "none"
189+
defaultAuditReadAuthzTeam = "staff"
188190
defaultSentryTracesSampleRate = 0.2
189191
defaultPGConnectRetryWindow = 45 * time.Second
190192
)
@@ -226,9 +228,10 @@ func Load() (*Config, error) {
226228
AuthzTeam: defaultRegistryAuthzTeam,
227229
},
228230
Repo: RepoConfig{
229-
Org: defaultRepoOrg,
230-
CreateAuthzTeam: defaultRepoCreateAuthzTeam,
231-
ApproveAuthzTeam: defaultRepoApproveAuthzTeam,
231+
Org: defaultRepoOrg,
232+
CreateAuthzTeam: defaultRepoCreateAuthzTeam,
233+
ApproveAuthzTeam: defaultRepoApproveAuthzTeam,
234+
AuditReadAuthzTeam: defaultAuditReadAuthzTeam,
232235
},
233236
Sentry: SentryConfig{
234237
TracesSampleRate: defaultSentryTracesSampleRate,
@@ -320,6 +323,9 @@ func Load() (*Config, error) {
320323
if v, ok := os.LookupEnv("REPO_APPROVE_AUTHZ_TEAM"); ok && v != "" {
321324
cfg.Repo.ApproveAuthzTeam = v
322325
}
326+
if v, ok := os.LookupEnv("AUDIT_READ_AUTHZ_TEAM"); ok && v != "" {
327+
cfg.Repo.AuditReadAuthzTeam = v
328+
}
323329
cfg.Repo.App.AppID = os.Getenv("GH_APP_ID")
324330
cfg.Repo.App.InstallationID = os.Getenv("GH_APP_INSTALLATION_ID")
325331
cfg.Repo.App.PrivateKeyPEM = os.Getenv("GH_APP_PRIVATE_KEY")
@@ -422,6 +428,9 @@ func (c *Config) validate() error {
422428
if c.Repo.ApproveAuthzTeam == "" {
423429
return fmt.Errorf("REPO_APPROVE_AUTHZ_TEAM must not be empty")
424430
}
431+
if strings.TrimSpace(c.Repo.AuditReadAuthzTeam) == "" {
432+
return fmt.Errorf("AUDIT_READ_AUTHZ_TEAM must not be empty")
433+
}
425434
// Repo App credentials are optional (feature off when absent), but
426435
// partial config is a misconfiguration — fail fast rather than boot
427436
// a half-wired feature.

internal/handler/audit_list.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ func (h *Handlers) AuditList(w http.ResponseWriter, r *http.Request) {
3939
writeError(w, http.StatusServiceUnavailable, "audit_unavailable", "audit log is not configured")
4040
return
4141
}
42+
if err := h.requireAuditReadAuthz(w, r); err != nil {
43+
return
44+
}
4245
q := r.URL.Query()
4346
f := pg.AuditFilter{
4447
Site: q.Get("site"),
@@ -81,3 +84,26 @@ func (h *Handlers) AuditList(w http.ResponseWriter, r *http.Request) {
8184
}
8285
writeJSON(w, http.StatusOK, rows)
8386
}
87+
88+
func (h *Handlers) requireAuditReadAuthz(w http.ResponseWriter, r *http.Request) error {
89+
if h.AuditReadAuthzTeam == "" {
90+
writeError(w, http.StatusInternalServerError, "misconfigured", "audit authz team not set")
91+
return errBadRequest
92+
}
93+
if h.RepoGH == nil {
94+
writeError(w, http.StatusInternalServerError, "misconfigured", "universe-org github client not configured")
95+
return errBadRequest
96+
}
97+
login := LoginFromContext(r.Context())
98+
token := GitHubTokenFromContext(r.Context())
99+
ok, err := h.RepoGH.AuthorizeForSite(r.Context(), token, login, []string{h.AuditReadAuthzTeam})
100+
if err != nil {
101+
writeGitHubProbeError(w, err)
102+
return err
103+
}
104+
if !ok {
105+
writeError(w, http.StatusForbidden, "user_unauthorized", "caller is not on the required team")
106+
return errBadRequest
107+
}
108+
return nil
109+
}

internal/handler/audit_list_test.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ func auditHandlers(t *testing.T, fa *fakeAudit) *Handlers {
1818
t.Helper()
1919
h, _ := newTestHandlers(t, staffCallerGH(), standardSites(), newFakeR2())
2020
h.Audit = fa
21+
h.RepoGH = staffCallerGH()
22+
h.AuditReadAuthzTeam = "staff"
2123
return h
2224
}
2325

@@ -67,17 +69,54 @@ func TestAuditList_InvalidLimit(t *testing.T) {
6769
assert.Equal(t, "invalid_limit", env["error"]["code"])
6870
}
6971

70-
func TestAuditList_AnyBearerNoTeamGate(t *testing.T) {
72+
func TestAuditList_NonStaffCallerIs403(t *testing.T) {
7173
nobodyGH := &fakeGH{
7274
tokenLogins: map[string]string{"tok": "nobody"},
7375
userTeams: map[string]map[string]bool{"nobody": {}},
7476
}
7577
h, _ := newTestHandlers(t, nobodyGH, standardSites(), newFakeR2())
7678
h.Audit = &fakeAudit{}
79+
h.RepoGH = nobodyGH
80+
h.AuditReadAuthzTeam = "staff"
7781

7882
w := getAudit(h, "/api/audit")
79-
require.Equal(t, http.StatusOK, w.Code, w.Body.String(),
80-
"audit read is open to any authenticated GitHub bearer, no team gate")
83+
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String(),
84+
"cross-tenant audit read must require Universe staff membership, not any GitHub bearer")
85+
var env map[string]map[string]string
86+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &env))
87+
assert.Equal(t, "user_unauthorized", env["error"]["code"])
88+
}
89+
90+
func TestAuditList_StaffInBaseOrgButNotUniverseIs403(t *testing.T) {
91+
baseOrgStaff := &fakeGH{
92+
tokenLogins: map[string]string{"tok": "alice"},
93+
userTeams: map[string]map[string]bool{"alice": {"staff": true}},
94+
}
95+
universeNonStaff := &fakeGH{
96+
tokenLogins: map[string]string{"tok": "alice"},
97+
userTeams: map[string]map[string]bool{"alice": {}},
98+
}
99+
h, _ := newTestHandlers(t, baseOrgStaff, standardSites(), newFakeR2())
100+
h.Audit = &fakeAudit{}
101+
h.RepoGH = universeNonStaff
102+
h.AuditReadAuthzTeam = "staff"
103+
104+
w := getAudit(h, "/api/audit")
105+
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String(),
106+
"gate must probe RepoGH (Universe org), not GH (base org): base-org staff who is not Universe staff is denied")
107+
}
108+
109+
func TestAuditList_MisconfiguredNoRepoGHIs500(t *testing.T) {
110+
h, _ := newTestHandlers(t, staffCallerGH(), standardSites(), newFakeR2())
111+
h.Audit = &fakeAudit{}
112+
h.AuditReadAuthzTeam = "staff"
113+
114+
w := getAudit(h, "/api/audit")
115+
require.Equal(t, http.StatusInternalServerError, w.Code, w.Body.String(),
116+
"RepoGH nil: Universe membership probe cannot run, gate must fail closed not fall open")
117+
var env map[string]map[string]string
118+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &env))
119+
assert.Equal(t, "misconfigured", env["error"]["code"])
81120
}
82121

83122
func TestAuditList_NilStoreIs503(t *testing.T) {

internal/handler/handler.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ type Handlers struct {
144144
RepoOrg string
145145
RepoCreateAuthzTeam string
146146
RepoApproveAuthzTeam string
147+
AuditReadAuthzTeam string
147148
NewDeployID func(sha string) string
148149
Now func() time.Time
149150
PublicURLForSite func(site, mode string) string // e.g. preview → "https://www.preview.freecode.camp"

0 commit comments

Comments
 (0)