tenseleyflow/shithub / 5d52b2e

Browse files

actions/secrets: store orchestrator with secretbox round-trip (S41c)

Set/Get/List/Delete over workflow_secrets. Plaintext is sealed via
internal/auth/secretbox (ChaCha20Poly1305 AEAD) before INSERT;
ciphertext + nonce live in the bytea columns. Plaintext never lives
in postgres.

Scope is a small XOR struct (RepoID xor OrgID); the table CHECK
mirrors it. Helpers RepoScope/OrgScope keep the XOR honest at call
sites — no struct-literal traps.

Public API:
Deps.Set(ctx, scope, name, plaintext, createdBy) error
Deps.Get(ctx, scope, name) ([]byte, error)
Deps.List(ctx, scope) ([]Meta, error) — names+metadata, no value
Deps.Delete(ctx, scope, name) error — idempotent

Get() is for the runner-side claim resolver only (S41c-2). Web UI
consumes List() — public listing surface deliberately can't reach
plaintext or ciphertext.

Errors mapped:
ErrInvalidScope — programmer error (zero or both scope fields)
ErrInvalidName — name regex/length cap mismatch (mirrors DB CHECK)
ErrEmptyValue — empty plaintext (operators usually mean delete)
ErrNotFound — no row for (scope, name)
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
5d52b2e206f5c16d5d1ef079e6e92fab8181b247
Parents
e32e338
Tree
8c35d62

1 changed file

StatusFile+-
A internal/actions/secrets/store.go 268 0
internal/actions/secrets/store.goadded
@@ -0,0 +1,268 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package secrets owns the orchestrator over `workflow_secrets`. The
4
+// table holds AEAD-encrypted blobs (ChaCha20Poly1305 via
5
+// internal/auth/secretbox); plaintext never lives in postgres.
6
+//
7
+// Two scopes share the table:
8
+//
9
+//   - **Repo secrets**: visible only to workflows running in that repo.
10
+//   - **Org secrets**: visible to workflows running in any of the org's
11
+//     repos. Repo-scoped secrets shadow org secrets with the same name
12
+//     (resolution order is repo → org).
13
+//
14
+// The XOR is enforced by a CHECK on the table; the typed Scope here
15
+// is the in-Go mirror — exactly one of RepoID / OrgID is set. Callers
16
+// always go through Scope helpers (RepoScope, OrgScope) so the
17
+// XOR isn't a struct-literal trap.
18
+package secrets
19
+
20
+import (
21
+	"context"
22
+	"errors"
23
+	"fmt"
24
+	"log/slog"
25
+	"regexp"
26
+
27
+	"github.com/jackc/pgx/v5"
28
+	"github.com/jackc/pgx/v5/pgtype"
29
+	"github.com/jackc/pgx/v5/pgxpool"
30
+
31
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
32
+	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
33
+)
34
+
35
+// Deps wires the store against runtime infra.
36
+type Deps struct {
37
+	Pool   *pgxpool.Pool
38
+	Box    *secretbox.Box
39
+	Logger *slog.Logger
40
+}
41
+
42
+// Scope identifies which `workflow_secrets` row family a call targets.
43
+// Construct via RepoScope or OrgScope; RepoID and OrgID are mutually
44
+// exclusive (the table CHECK constraint enforces this server-side).
45
+type Scope struct {
46
+	RepoID int64
47
+	OrgID  int64
48
+}
49
+
50
+// RepoScope returns a repo-scoped Scope. Repo secrets are visible only
51
+// to workflows running in that repo.
52
+func RepoScope(id int64) Scope { return Scope{RepoID: id} }
53
+
54
+// OrgScope returns an org-scoped Scope. Org secrets are visible to
55
+// workflows running in any repo owned by the org.
56
+func OrgScope(id int64) Scope { return Scope{OrgID: id} }
57
+
58
+// IsRepo reports whether the scope addresses a repo. Mutex with IsOrg.
59
+func (s Scope) IsRepo() bool { return s.RepoID != 0 && s.OrgID == 0 }
60
+
61
+// IsOrg reports whether the scope addresses an org. Mutex with IsRepo.
62
+func (s Scope) IsOrg() bool { return s.OrgID != 0 && s.RepoID == 0 }
63
+
64
+// Meta is the public listing shape — no plaintext, no ciphertext.
65
+// The web UI + runner claim path consume Meta when listing names;
66
+// only Get returns the actual decrypted value.
67
+type Meta struct {
68
+	ID              int64
69
+	Name            string
70
+	CreatedByUserID int64 // 0 when null
71
+	CreatedAt       pgtype.Timestamptz
72
+	UpdatedAt       pgtype.Timestamptz
73
+}
74
+
75
+// Errors surfaced by the store. Callers (web handlers, runner API)
76
+// map these to HTTP status codes.
77
+var (
78
+	// ErrInvalidScope: zero-or-both Scope fields. Programmer error.
79
+	ErrInvalidScope = errors.New("secrets: scope must address exactly one of RepoID or OrgID")
80
+	// ErrInvalidName: name doesn't match the regex. User-recoverable.
81
+	ErrInvalidName = errors.New("secrets: name must match ^[A-Za-z_][A-Za-z0-9_]*$ and be 1..100 chars")
82
+	// ErrEmptyValue: no zero-length secrets — operators almost
83
+	// certainly mean "delete" if they pass empty.
84
+	ErrEmptyValue = errors.New("secrets: value must be non-empty (use Delete to remove)")
85
+	// ErrNotFound: no row with the given name in this scope.
86
+	ErrNotFound = errors.New("secrets: not found")
87
+)
88
+
89
+// nameRe mirrors the workflow_secrets_name_format CHECK in migration
90
+// 0045. Validating parser-side surfaces a user-friendly error before
91
+// the INSERT round-trip.
92
+var nameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
93
+
94
+// validateName enforces the regex + length cap. Returns ErrInvalidName
95
+// on mismatch.
96
+func validateName(name string) error {
97
+	if len(name) < 1 || len(name) > 100 {
98
+		return ErrInvalidName
99
+	}
100
+	if !nameRe.MatchString(name) {
101
+		return ErrInvalidName
102
+	}
103
+	return nil
104
+}
105
+
106
+// Set creates or updates a secret in scope. plaintext is encrypted
107
+// with the configured Box before INSERT — the DB never sees the raw
108
+// value. createdBy is the user's ID for audit; 0 when system-driven.
109
+func (d Deps) Set(ctx context.Context, scope Scope, name string, plaintext []byte, createdBy int64) error {
110
+	if !scope.IsRepo() && !scope.IsOrg() {
111
+		return ErrInvalidScope
112
+	}
113
+	if err := validateName(name); err != nil {
114
+		return err
115
+	}
116
+	if len(plaintext) == 0 {
117
+		return ErrEmptyValue
118
+	}
119
+	ciphertext, nonce, err := d.Box.Seal(plaintext)
120
+	if err != nil {
121
+		return fmt.Errorf("secrets: seal: %w", err)
122
+	}
123
+	q := actionsdb.New()
124
+	creator := pgtype.Int8{Int64: createdBy, Valid: createdBy != 0}
125
+	switch {
126
+	case scope.IsRepo():
127
+		_, err = q.UpsertRepoSecret(ctx, d.Pool, actionsdb.UpsertRepoSecretParams{
128
+			RepoID:          pgtype.Int8{Int64: scope.RepoID, Valid: true},
129
+			Name:            name,
130
+			Ciphertext:      ciphertext,
131
+			Nonce:           nonce,
132
+			CreatedByUserID: creator,
133
+		})
134
+	case scope.IsOrg():
135
+		_, err = q.UpsertOrgSecret(ctx, d.Pool, actionsdb.UpsertOrgSecretParams{
136
+			OrgID:           pgtype.Int8{Int64: scope.OrgID, Valid: true},
137
+			Name:            name,
138
+			Ciphertext:      ciphertext,
139
+			Nonce:           nonce,
140
+			CreatedByUserID: creator,
141
+		})
142
+	}
143
+	if err != nil {
144
+		return fmt.Errorf("secrets: upsert: %w", err)
145
+	}
146
+	return nil
147
+}
148
+
149
+// Get returns the decrypted plaintext for the secret named in scope.
150
+// Used only by the runner-side claim resolver (S41c-2) where the
151
+// runner has authorization to receive secret values for its job's
152
+// scope. **Never** call this from a web handler — the UI lists names
153
+// only.
154
+func (d Deps) Get(ctx context.Context, scope Scope, name string) ([]byte, error) {
155
+	if !scope.IsRepo() && !scope.IsOrg() {
156
+		return nil, ErrInvalidScope
157
+	}
158
+	if err := validateName(name); err != nil {
159
+		return nil, err
160
+	}
161
+	q := actionsdb.New()
162
+	var ct, nonce []byte
163
+	var err error
164
+	switch {
165
+	case scope.IsRepo():
166
+		row, qerr := q.GetRepoSecret(ctx, d.Pool, actionsdb.GetRepoSecretParams{
167
+			RepoID: pgtype.Int8{Int64: scope.RepoID, Valid: true},
168
+			Name:   name,
169
+		})
170
+		err = qerr
171
+		ct, nonce = row.Ciphertext, row.Nonce
172
+	case scope.IsOrg():
173
+		row, qerr := q.GetOrgSecret(ctx, d.Pool, actionsdb.GetOrgSecretParams{
174
+			OrgID: pgtype.Int8{Int64: scope.OrgID, Valid: true},
175
+			Name:  name,
176
+		})
177
+		err = qerr
178
+		ct, nonce = row.Ciphertext, row.Nonce
179
+	}
180
+	if err != nil {
181
+		if errors.Is(err, pgx.ErrNoRows) {
182
+			return nil, ErrNotFound
183
+		}
184
+		return nil, fmt.Errorf("secrets: get: %w", err)
185
+	}
186
+	plaintext, err := d.Box.Open(ct, nonce)
187
+	if err != nil {
188
+		return nil, fmt.Errorf("secrets: open: %w", err)
189
+	}
190
+	return plaintext, nil
191
+}
192
+
193
+// List returns the names + metadata for every secret in scope. No
194
+// ciphertext, no plaintext — the public listing shape only. Names are
195
+// sorted ascending for stable UI rendering.
196
+func (d Deps) List(ctx context.Context, scope Scope) ([]Meta, error) {
197
+	if !scope.IsRepo() && !scope.IsOrg() {
198
+		return nil, ErrInvalidScope
199
+	}
200
+	q := actionsdb.New()
201
+	switch {
202
+	case scope.IsRepo():
203
+		rows, err := q.ListRepoSecrets(ctx, d.Pool, pgtype.Int8{Int64: scope.RepoID, Valid: true})
204
+		if err != nil {
205
+			return nil, fmt.Errorf("secrets: list: %w", err)
206
+		}
207
+		out := make([]Meta, len(rows))
208
+		for i, r := range rows {
209
+			out[i] = Meta{
210
+				ID:              r.ID,
211
+				Name:            string(r.Name),
212
+				CreatedByUserID: int64ValueOrZero(r.CreatedByUserID),
213
+				CreatedAt:       r.CreatedAt,
214
+				UpdatedAt:       r.UpdatedAt,
215
+			}
216
+		}
217
+		return out, nil
218
+	case scope.IsOrg():
219
+		rows, err := q.ListOrgSecrets(ctx, d.Pool, pgtype.Int8{Int64: scope.OrgID, Valid: true})
220
+		if err != nil {
221
+			return nil, fmt.Errorf("secrets: list: %w", err)
222
+		}
223
+		out := make([]Meta, len(rows))
224
+		for i, r := range rows {
225
+			out[i] = Meta{
226
+				ID:              r.ID,
227
+				Name:            string(r.Name),
228
+				CreatedByUserID: int64ValueOrZero(r.CreatedByUserID),
229
+				CreatedAt:       r.CreatedAt,
230
+				UpdatedAt:       r.UpdatedAt,
231
+			}
232
+		}
233
+		return out, nil
234
+	}
235
+	return nil, ErrInvalidScope
236
+}
237
+
238
+// Delete removes a secret. Returns ErrNotFound when the row didn't
239
+// exist; idempotent at the SQL layer (DELETE WHERE).
240
+func (d Deps) Delete(ctx context.Context, scope Scope, name string) error {
241
+	if !scope.IsRepo() && !scope.IsOrg() {
242
+		return ErrInvalidScope
243
+	}
244
+	if err := validateName(name); err != nil {
245
+		return err
246
+	}
247
+	q := actionsdb.New()
248
+	switch {
249
+	case scope.IsRepo():
250
+		return q.DeleteRepoSecret(ctx, d.Pool, actionsdb.DeleteRepoSecretParams{
251
+			RepoID: pgtype.Int8{Int64: scope.RepoID, Valid: true},
252
+			Name:   name,
253
+		})
254
+	case scope.IsOrg():
255
+		return q.DeleteOrgSecret(ctx, d.Pool, actionsdb.DeleteOrgSecretParams{
256
+			OrgID: pgtype.Int8{Int64: scope.OrgID, Valid: true},
257
+			Name:  name,
258
+		})
259
+	}
260
+	return ErrInvalidScope
261
+}
262
+
263
+func int64ValueOrZero(p pgtype.Int8) int64 {
264
+	if p.Valid {
265
+		return p.Int64
266
+	}
267
+	return 0
268
+}