Go · 8574 bytes Raw Blame History
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 }
269