tenseleyflow/shithub / 59d5619

Browse files

web: add actions secrets and variables settings (S41c)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
59d56193691e2d07d3197d503d9fa3e344b81bc9
Parents
1bfb40f
Tree
4e4e3ed

20 changed files

StatusFile+-
M docs/internal/actions-schema.md 36 0
M docs/internal/permissions.md 1 0
M internal/auth/audit/audit.go 8 0
M internal/auth/policy/actions.go 2 1
M internal/auth/policy/policy.go 1 1
M internal/auth/policy/policy_test.go 1 1
M internal/web/handlers/handlers.go 6 0
M internal/web/handlers/orgs/avatar_test.go 2 1
M internal/web/handlers/orgs/orgs.go 28 11
A internal/web/handlers/orgs/settings_actions.go 211 0
A internal/web/handlers/orgs/settings_actions_test.go 124 0
M internal/web/handlers/repo/repo_test.go 8 6
A internal/web/handlers/repo/settings_actions.go 204 0
A internal/web/handlers/repo/settings_actions_test.go 151 0
M internal/web/handlers/repo/settings_general.go 2 0
M internal/web/orgs_wiring.go 14 0
M internal/web/server.go 6 0
M internal/web/templates/_repo_settings_nav.html 6 0
A internal/web/templates/orgs/settings_secrets.html 87 0
A internal/web/templates/repo/settings_secrets.html 82 0
docs/internal/actions-schema.mdmodified
@@ -437,6 +437,42 @@ defer to S41g where the lifecycle work touches that surface anyway.
437437
 - `workflow_run` webhook events. S41h adds the webhook event family
438438
   + atom feed.
439439
 
440
+## Secrets + variables settings surface (S41c)
441
+
442
+S41c wires the previously schema-only `workflow_secrets` and
443
+`actions_variables` tables into repo/org settings.
444
+
445
+Repository routes are gated through
446
+`policy.ActionRepoSettingsActions` (`repo:settings:actions`, admin
447
+role minimum):
448
+
449
+- `GET /{owner}/{repo}/settings/secrets/actions`
450
+- `POST /{owner}/{repo}/settings/secrets/actions`
451
+- `POST /{owner}/{repo}/settings/secrets/actions/{name}/delete`
452
+- `GET /{owner}/{repo}/settings/variables/actions`
453
+- `POST /{owner}/{repo}/settings/variables/actions`
454
+- `POST /{owner}/{repo}/settings/variables/actions/{name}/delete`
455
+
456
+Organization routes follow the existing org-settings prefix and are
457
+owner-only:
458
+
459
+- `GET /organizations/{org}/settings/secrets/actions`
460
+- `POST /organizations/{org}/settings/secrets/actions`
461
+- `POST /organizations/{org}/settings/secrets/actions/{name}/delete`
462
+- `GET /organizations/{org}/settings/variables/actions`
463
+- `POST /organizations/{org}/settings/variables/actions`
464
+- `POST /organizations/{org}/settings/variables/actions/{name}/delete`
465
+
466
+Secrets are sealed through `internal/auth/secretbox` using the
467
+operator-managed `Auth.TOTPKeyB64` root key. Secret list pages render
468
+names/metadata only; the plaintext value is accepted once on create or
469
+rotation and never rendered back. Variables are non-secret plaintext
470
+configuration, so settings pages render their values. Both stores use
471
+the same name grammar as the database constraints:
472
+`^[A-Za-z_][A-Za-z0-9_]*$`, 1-100 characters. Variables additionally
473
+enforce the 4096-character value cap in Go before hitting the DB
474
+constraint.
475
+
440476
 ## What S41a deliberately doesn't do
441477
 
442478
 - No trigger pipeline. `domain_events` aren't matched against `on:`
docs/internal/permissions.mdmodified
@@ -51,6 +51,7 @@ The complete map (also enforced by the matrix test):
5151
 | `repo:settings:general`               | `maintain`       |
5252
 | `repo:settings:collaborators`         | `admin`          |
5353
 | `repo:settings:branches`              | `maintain`       |
54
+| `repo:settings:actions`               | `admin`          |
5455
 | `repo:archive`                        | `admin`          |
5556
 | `repo:delete`                         | `admin`          |
5657
 | `repo:transfer`                       | `admin`          |
internal/auth/audit/audit.gomodified
@@ -83,6 +83,13 @@ const (
8383
 	ActionWebhookPinged      Action = "webhook_pinged"
8484
 	ActionWebhookRedelivered Action = "webhook_redelivered"
8585
 
86
+	// S41c — Actions secret/variable lifecycle. Metadata must include
87
+	// names only, never secret values.
88
+	ActionActionsSecretSet       Action = "actions_secret_set"
89
+	ActionActionsSecretDeleted   Action = "actions_secret_deleted"
90
+	ActionActionsVariableSet     Action = "actions_variable_set"
91
+	ActionActionsVariableDeleted Action = "actions_variable_deleted"
92
+
8693
 	// S34 — site admin actions. Always recorded with the real admin's
8794
 	// id in actor_id; impersonation flows additionally carry the
8895
 	// impersonated user's id in meta.impersonated_user_id.
@@ -108,6 +115,7 @@ type Target string
108115
 const (
109116
 	TargetUser  Target = "user"
110117
 	TargetRepo  Target = "repo"
118
+	TargetOrg   Target = "org"
111119
 	TargetIssue Target = "issue"
112120
 	TargetPull  Target = "pull"
113121
 )
internal/auth/policy/actions.gomodified
@@ -31,6 +31,7 @@ const (
3131
 	ActionRepoSettingsGeneral       Action = "repo:settings:general"
3232
 	ActionRepoSettingsCollaborators Action = "repo:settings:collaborators"
3333
 	ActionRepoSettingsBranches      Action = "repo:settings:branches"
34
+	ActionRepoSettingsActions       Action = "repo:settings:actions"
3435
 
3536
 	ActionRepoArchive    Action = "repo:archive"
3637
 	ActionRepoDelete     Action = "repo:delete"
@@ -76,7 +77,7 @@ const (
7677
 // the author to think through every actor archetype.
7778
 var AllActions = []Action{
7879
 	ActionRepoRead, ActionRepoWrite, ActionRepoAdmin,
79
-	ActionRepoSettingsGeneral, ActionRepoSettingsCollaborators, ActionRepoSettingsBranches,
80
+	ActionRepoSettingsGeneral, ActionRepoSettingsCollaborators, ActionRepoSettingsBranches, ActionRepoSettingsActions,
8081
 	ActionRepoArchive, ActionRepoDelete, ActionRepoTransfer, ActionRepoVisibility,
8182
 	ActionIssueRead, ActionIssueCreate, ActionIssueComment, ActionIssueClose, ActionIssueLabel, ActionIssueAssign,
8283
 	ActionPullRead, ActionPullCreate, ActionPullMerge, ActionPullReview, ActionPullClose,
internal/auth/policy/policy.gomodified
@@ -437,7 +437,7 @@ func minRoleFor(action Action) Role {
437437
 		return RoleMaintain
438438
 
439439
 	// Admin tier — destructive and ownership-changing actions.
440
-	case ActionRepoAdmin, ActionRepoSettingsCollaborators,
440
+	case ActionRepoAdmin, ActionRepoSettingsCollaborators, ActionRepoSettingsActions,
441441
 		ActionRepoArchive, ActionRepoDelete, ActionRepoTransfer, ActionRepoVisibility,
442442
 		ActionPullMerge:
443443
 		return RoleAdmin
internal/auth/policy/policy_test.gomodified
@@ -220,7 +220,7 @@ func mirrorMinRoleFor(a policy.Action) policy.Role {
220220
 		return policy.RoleWrite
221221
 	case policy.ActionRepoSettingsGeneral, policy.ActionRepoSettingsBranches:
222222
 		return policy.RoleMaintain
223
-	case policy.ActionRepoAdmin, policy.ActionRepoSettingsCollaborators,
223
+	case policy.ActionRepoAdmin, policy.ActionRepoSettingsCollaborators, policy.ActionRepoSettingsActions,
224224
 		policy.ActionRepoArchive, policy.ActionRepoDelete, policy.ActionRepoTransfer, policy.ActionRepoVisibility,
225225
 		policy.ActionPullMerge:
226226
 		return policy.RoleAdmin
internal/web/handlers/handlers.gomodified
@@ -86,6 +86,9 @@ type Deps struct {
8686
 	// the deferred-tab placeholders (webhooks, keys, notifications,
8787
 	// tags) under /{owner}/{repo}/settings/* (S32). Auth-required.
8888
 	RepoSettingsGeneralMounter func(chi.Router)
89
+	// RepoSettingsActionsMounter registers Actions secrets + variables
90
+	// settings under /{owner}/{repo}/settings/* (S41c). Auth-required.
91
+	RepoSettingsActionsMounter func(chi.Router)
8992
 	// RepoWebhooksMounter registers the per-repo webhook CRUD +
9093
 	// delivery views under /{owner}/{repo}/settings/webhooks/* (S33).
9194
 	// Auth-required.
@@ -278,6 +281,9 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
278281
 		if deps.RepoSettingsGeneralMounter != nil {
279282
 			deps.RepoSettingsGeneralMounter(r)
280283
 		}
284
+		if deps.RepoSettingsActionsMounter != nil {
285
+			deps.RepoSettingsActionsMounter(r)
286
+		}
281287
 		// Webhooks (S33) — register BEFORE the general mounter so the
282288
 		// /settings/webhooks GET resolves to the new CRUD list, not
283289
 		// any stale placeholder.
internal/web/handlers/orgs/avatar_test.gomodified
@@ -5,6 +5,7 @@ package orgs_test
55
 import (
66
 	"bytes"
77
 	"context"
8
+	"html"
89
 	"image"
910
 	"image/color"
1011
 	"image/png"
@@ -256,7 +257,7 @@ func TestOrgSettingsDeleteRequiresSlugConfirmation(t *testing.T) {
256257
 	if resp.StatusCode != http.StatusOK {
257258
 		t.Fatalf("wrong confirmation status=%d body=%s", resp.StatusCode, body)
258259
 	}
259
-	if !strings.Contains(string(body), "ERROR=Enter this organization's name to confirm deletion.") {
260
+	if !strings.Contains(html.UnescapeString(string(body)), "ERROR=Enter this organization's name to confirm deletion.") {
260261
 		t.Fatalf("expected confirmation error, got %s", body)
261262
 	}
262263
 	org, err := q.GetOrgBySlugIncludingDeleted(ctx, pool, "tenseleyFlow")
internal/web/handlers/orgs/orgs.gomodified
@@ -4,13 +4,16 @@
44
 //
55
 //	GET  /organizations/new            create form
66
 //	POST /organizations                create submit
7
-//	GET  /{org}/people                 members + pending invites + invite form
8
-//	POST /{org}/people/invite          invite by username or email
9
-//	POST /{org}/people/{user}/role     change role
10
-//	POST /{org}/people/{user}/remove   remove member
11
-//	GET  /invitations/{token}          accept/decline view
12
-//	POST /invitations/{token}/accept   accept
13
-//	POST /invitations/{token}/decline  decline
7
+//	GET  /{org}/people                                      members + pending invites + invite form
8
+//	POST /{org}/people/invite                               invite by username or email
9
+//	POST /{org}/people/{user}/role                          change role
10
+//	POST /{org}/people/{user}/remove                        remove member
11
+//	GET  /organizations/{org}/settings/profile              profile settings
12
+//	GET  /organizations/{org}/settings/{secrets,variables}/actions
13
+//	POST /organizations/{org}/settings/{secrets,variables}/actions
14
+//	GET  /invitations/{token}                               accept/decline view
15
+//	POST /invitations/{token}/accept                        accept
16
+//	POST /invitations/{token}/decline                       decline
1417
 //
1518
 // Profile rendering for /{org} is dispatched from the existing
1619
 // /{username} catch-all in internal/web/handlers/profile via the
@@ -29,7 +32,9 @@ import (
2932
 	"github.com/go-chi/chi/v5"
3033
 	"github.com/jackc/pgx/v5/pgxpool"
3134
 
35
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
3236
 	authemail "github.com/tenseleyFlow/shithub/internal/auth/email"
37
+	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
3338
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
3439
 	"github.com/tenseleyFlow/shithub/internal/orgs"
3540
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
@@ -47,6 +52,8 @@ type Deps struct {
4752
 	SiteName    string
4853
 	BaseURL     string
4954
 	ObjectStore storage.ObjectStore
55
+	SecretBox   *secretbox.Box
56
+	Audit       *audit.Recorder
5057
 }
5158
 
5259
 // Handlers groups the org surface handlers.
@@ -62,13 +69,17 @@ func New(d Deps) (*Handlers, error) {
6269
 	if d.Pool == nil {
6370
 		return nil, errors.New("orgs handlers: nil Pool")
6471
 	}
72
+	if d.Audit == nil {
73
+		d.Audit = audit.NewRecorder()
74
+	}
6575
 	return &Handlers{d: d}, nil
6676
 }
6777
 
68
-// MountCreate registers /organizations/new + POST /organizations.
69
-// Caller wraps these in RequireUser since both require a logged-in
70
-// creator. The /organizations prefix is on the auth-reserved list so
71
-// it never shadows a user/org slug.
78
+// MountCreate registers /organizations/new, POST /organizations, and
79
+// organization settings routes under /organizations/{org}/settings/*.
80
+// Caller wraps these in RequireUser since they require a logged-in
81
+// actor. The /organizations prefix is on the auth-reserved list so it
82
+// never shadows a user/org slug.
7283
 func (h *Handlers) MountCreate(r chi.Router) {
7384
 	r.Get("/organizations/new", h.newForm)
7485
 	r.Post("/organizations", h.createSubmit)
@@ -77,6 +88,12 @@ func (h *Handlers) MountCreate(r chi.Router) {
7788
 	r.Post("/organizations/{org}/settings/profile/avatar", h.settingsAvatarUpload)
7889
 	r.Post("/organizations/{org}/settings/profile/avatar/remove", h.settingsAvatarRemove)
7990
 	r.Post("/organizations/{org}/settings/delete", h.settingsDelete)
91
+	r.Get("/organizations/{org}/settings/secrets/actions", h.settingsActionsSecrets)
92
+	r.Post("/organizations/{org}/settings/secrets/actions", h.settingsActionsSecretSet)
93
+	r.Post("/organizations/{org}/settings/secrets/actions/{name}/delete", h.settingsActionsSecretDelete)
94
+	r.Get("/organizations/{org}/settings/variables/actions", h.settingsActionsVariables)
95
+	r.Post("/organizations/{org}/settings/variables/actions", h.settingsActionsVariableSet)
96
+	r.Post("/organizations/{org}/settings/variables/actions/{name}/delete", h.settingsActionsVariableDelete)
8097
 }
8198
 
8299
 // MountOrgRoutes registers the per-org surface under /{org}/people
internal/web/handlers/orgs/settings_actions.goadded
@@ -0,0 +1,211 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs
4
+
5
+import (
6
+	"errors"
7
+	"net/http"
8
+	"strings"
9
+
10
+	"github.com/go-chi/chi/v5"
11
+
12
+	"github.com/tenseleyFlow/shithub/internal/actions/secrets"
13
+	actionsvars "github.com/tenseleyFlow/shithub/internal/actions/variables"
14
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
15
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
16
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
17
+)
18
+
19
+func (h *Handlers) settingsActionsSecrets(w http.ResponseWriter, r *http.Request) {
20
+	org, ok := h.loadOrgSettingsOwner(w, r)
21
+	if !ok {
22
+		return
23
+	}
24
+	h.renderOrgActionsSettings(w, r, org, "secrets", "", orgActionsNoticeMessage(r.URL.Query().Get("notice")))
25
+}
26
+
27
+func (h *Handlers) settingsActionsSecretSet(w http.ResponseWriter, r *http.Request) {
28
+	org, ok := h.loadOrgSettingsOwner(w, r)
29
+	if !ok {
30
+		return
31
+	}
32
+	if h.d.SecretBox == nil {
33
+		http.Error(w, "actions secret key not configured", http.StatusServiceUnavailable)
34
+		return
35
+	}
36
+	if err := r.ParseForm(); err != nil {
37
+		http.Error(w, "form parse", http.StatusBadRequest)
38
+		return
39
+	}
40
+	viewer := middleware.CurrentUserFromContext(r.Context())
41
+	name := strings.TrimSpace(r.PostFormValue("name"))
42
+	value := []byte(r.PostFormValue("value"))
43
+	err := secrets.Deps{Pool: h.d.Pool, Box: h.d.SecretBox}.Set(r.Context(), secrets.OrgScope(org.ID), name, value, viewer.ID)
44
+	if err != nil {
45
+		h.renderOrgActionsSettings(w, r, org, "secrets", friendlyOrgActionsSecretError(err), "")
46
+		return
47
+	}
48
+	h.recordOrgActionsAudit(r, viewer, audit.ActionActionsSecretSet, org.ID, name)
49
+	http.Redirect(w, r, orgActionsSettingsPath(org.Slug, "secrets")+"?notice=saved", http.StatusSeeOther)
50
+}
51
+
52
+func (h *Handlers) settingsActionsSecretDelete(w http.ResponseWriter, r *http.Request) {
53
+	org, ok := h.loadOrgSettingsOwner(w, r)
54
+	if !ok {
55
+		return
56
+	}
57
+	name := chi.URLParam(r, "name")
58
+	err := secrets.Deps{Pool: h.d.Pool, Box: h.d.SecretBox}.Delete(r.Context(), secrets.OrgScope(org.ID), name)
59
+	if err != nil {
60
+		if errors.Is(err, secrets.ErrInvalidName) {
61
+			http.Error(w, "bad secret name", http.StatusBadRequest)
62
+			return
63
+		}
64
+		http.Error(w, "delete failed", http.StatusInternalServerError)
65
+		return
66
+	}
67
+	viewer := middleware.CurrentUserFromContext(r.Context())
68
+	h.recordOrgActionsAudit(r, viewer, audit.ActionActionsSecretDeleted, org.ID, name)
69
+	http.Redirect(w, r, orgActionsSettingsPath(org.Slug, "secrets")+"?notice=deleted", http.StatusSeeOther)
70
+}
71
+
72
+func (h *Handlers) settingsActionsVariables(w http.ResponseWriter, r *http.Request) {
73
+	org, ok := h.loadOrgSettingsOwner(w, r)
74
+	if !ok {
75
+		return
76
+	}
77
+	h.renderOrgActionsSettings(w, r, org, "variables", "", orgActionsNoticeMessage(r.URL.Query().Get("notice")))
78
+}
79
+
80
+func (h *Handlers) settingsActionsVariableSet(w http.ResponseWriter, r *http.Request) {
81
+	org, ok := h.loadOrgSettingsOwner(w, r)
82
+	if !ok {
83
+		return
84
+	}
85
+	if err := r.ParseForm(); err != nil {
86
+		http.Error(w, "form parse", http.StatusBadRequest)
87
+		return
88
+	}
89
+	viewer := middleware.CurrentUserFromContext(r.Context())
90
+	name := strings.TrimSpace(r.PostFormValue("name"))
91
+	value := r.PostFormValue("value")
92
+	err := actionsvars.Deps{Pool: h.d.Pool}.Set(r.Context(), actionsvars.OrgScope(org.ID), name, value, viewer.ID)
93
+	if err != nil {
94
+		h.renderOrgActionsSettings(w, r, org, "variables", friendlyOrgActionsVariableError(err), "")
95
+		return
96
+	}
97
+	h.recordOrgActionsAudit(r, viewer, audit.ActionActionsVariableSet, org.ID, name)
98
+	http.Redirect(w, r, orgActionsSettingsPath(org.Slug, "variables")+"?notice=saved", http.StatusSeeOther)
99
+}
100
+
101
+func (h *Handlers) settingsActionsVariableDelete(w http.ResponseWriter, r *http.Request) {
102
+	org, ok := h.loadOrgSettingsOwner(w, r)
103
+	if !ok {
104
+		return
105
+	}
106
+	name := chi.URLParam(r, "name")
107
+	err := actionsvars.Deps{Pool: h.d.Pool}.Delete(r.Context(), actionsvars.OrgScope(org.ID), name)
108
+	if err != nil {
109
+		if errors.Is(err, actionsvars.ErrInvalidName) {
110
+			http.Error(w, "bad variable name", http.StatusBadRequest)
111
+			return
112
+		}
113
+		http.Error(w, "delete failed", http.StatusInternalServerError)
114
+		return
115
+	}
116
+	viewer := middleware.CurrentUserFromContext(r.Context())
117
+	h.recordOrgActionsAudit(r, viewer, audit.ActionActionsVariableDeleted, org.ID, name)
118
+	http.Redirect(w, r, orgActionsSettingsPath(org.Slug, "variables")+"?notice=deleted", http.StatusSeeOther)
119
+}
120
+
121
+func (h *Handlers) loadOrgSettingsOwner(w http.ResponseWriter, r *http.Request) (orgsdb.Org, bool) {
122
+	org, ok := h.orgFromSlug(w, r)
123
+	if !ok {
124
+		return orgsdb.Org{}, false
125
+	}
126
+	viewer := middleware.CurrentUserFromContext(r.Context())
127
+	if !h.requireOrgOwner(w, r, org.ID, viewer) {
128
+		return orgsdb.Org{}, false
129
+	}
130
+	return org, true
131
+}
132
+
133
+func (h *Handlers) renderOrgActionsSettings(w http.ResponseWriter, r *http.Request, org orgsdb.Org, kind, errMsg, notice string) {
134
+	data := map[string]any{
135
+		"CSRFToken":  middleware.CSRFTokenForRequest(r),
136
+		"Org":        org,
137
+		"Kind":       kind,
138
+		"Error":      errMsg,
139
+		"Notice":     notice,
140
+		"FormAction": orgActionsSettingsPath(org.Slug, kind),
141
+	}
142
+	switch kind {
143
+	case "secrets":
144
+		items, err := secrets.Deps{Pool: h.d.Pool, Box: h.d.SecretBox}.List(r.Context(), secrets.OrgScope(org.ID))
145
+		if err != nil {
146
+			h.d.Logger.WarnContext(r.Context(), "actions secrets: list org", "org_id", org.ID, "error", err)
147
+			items = nil
148
+		}
149
+		data["Title"] = org.Slug + " · Actions secrets"
150
+		data["Heading"] = "Actions secrets"
151
+		data["IsSecrets"] = true
152
+		data["Secrets"] = items
153
+		data["SecretDisabled"] = h.d.SecretBox == nil
154
+	case "variables":
155
+		items, err := actionsvars.Deps{Pool: h.d.Pool}.List(r.Context(), actionsvars.OrgScope(org.ID))
156
+		if err != nil {
157
+			h.d.Logger.WarnContext(r.Context(), "actions variables: list org", "org_id", org.ID, "error", err)
158
+			items = nil
159
+		}
160
+		data["Title"] = org.Slug + " · Actions variables"
161
+		data["Heading"] = "Actions variables"
162
+		data["Variables"] = items
163
+	}
164
+	h.d.Render.RenderPage(w, r, "orgs/settings_secrets", data)
165
+}
166
+
167
+func orgActionsSettingsPath(slug, kind string) string {
168
+	return "/organizations/" + slug + "/settings/" + kind + "/actions"
169
+}
170
+
171
+func friendlyOrgActionsSecretError(err error) string {
172
+	switch {
173
+	case errors.Is(err, secrets.ErrInvalidName):
174
+		return "Name must start with a letter or underscore and contain only letters, numbers, and underscores."
175
+	case errors.Is(err, secrets.ErrEmptyValue):
176
+		return "Secret value is required."
177
+	case errors.Is(err, secrets.ErrInvalidScope):
178
+		return "Invalid secret scope."
179
+	default:
180
+		return "Could not save secret."
181
+	}
182
+}
183
+
184
+func friendlyOrgActionsVariableError(err error) string {
185
+	switch {
186
+	case errors.Is(err, actionsvars.ErrInvalidName):
187
+		return "Name must start with a letter or underscore and contain only letters, numbers, and underscores."
188
+	case errors.Is(err, actionsvars.ErrValueTooLong):
189
+		return "Variable value must be 4096 characters or fewer."
190
+	case errors.Is(err, actionsvars.ErrInvalidScope):
191
+		return "Invalid variable scope."
192
+	default:
193
+		return "Could not save variable."
194
+	}
195
+}
196
+
197
+func orgActionsNoticeMessage(code string) string {
198
+	switch code {
199
+	case "saved":
200
+		return "Settings saved."
201
+	case "deleted":
202
+		return "Deleted."
203
+	default:
204
+		return ""
205
+	}
206
+}
207
+
208
+func (h *Handlers) recordOrgActionsAudit(r *http.Request, viewer middleware.CurrentUser, action audit.Action, orgID int64, name string) {
209
+	actor, meta := viewer.AuditActor(map[string]any{"name": name})
210
+	_ = h.d.Audit.Record(r.Context(), h.d.Pool, actor, action, audit.TargetOrg, orgID, meta)
211
+}
internal/web/handlers/orgs/settings_actions_test.goadded
@@ -0,0 +1,124 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs_test
4
+
5
+import (
6
+	"context"
7
+	"io"
8
+	"log/slog"
9
+	"net/http"
10
+	"net/http/httptest"
11
+	"net/url"
12
+	"strings"
13
+	"testing"
14
+	"testing/fstest"
15
+
16
+	"github.com/go-chi/chi/v5"
17
+	"github.com/jackc/pgx/v5/pgxpool"
18
+
19
+	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
20
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
21
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
22
+	orgsh "github.com/tenseleyFlow/shithub/internal/web/handlers/orgs"
23
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
24
+	"github.com/tenseleyFlow/shithub/internal/web/render"
25
+)
26
+
27
+func TestOrgActionsSettingsSecretAndVariableCRUD(t *testing.T) {
28
+	t.Parallel()
29
+	pool := dbtest.NewTestDB(t)
30
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
31
+	orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme")
32
+	h := newOrgActionsHandler(t, pool)
33
+	mux := chi.NewRouter()
34
+	mux.Use(func(next http.Handler) http.Handler {
35
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
36
+			viewer := middleware.CurrentUser{ID: ownerID, Username: "owner"}
37
+			next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer)))
38
+		})
39
+	})
40
+	h.MountCreate(mux)
41
+
42
+	resp := httptest.NewRecorder()
43
+	req := newOrgFormRequest(http.MethodPost, "/organizations/acme/settings/secrets/actions", url.Values{
44
+		"name":  {"ORG_TOKEN"},
45
+		"value": {"super-secret"},
46
+	})
47
+	mux.ServeHTTP(resp, req)
48
+	if resp.Code != http.StatusSeeOther {
49
+		t.Fatalf("POST org secret status=%d body=%s", resp.Code, resp.Body.String())
50
+	}
51
+	var ciphertext []byte
52
+	if err := pool.QueryRow(context.Background(),
53
+		`SELECT ciphertext FROM workflow_secrets WHERE org_id = $1 AND name = $2`,
54
+		orgID, "ORG_TOKEN").Scan(&ciphertext); err != nil {
55
+		t.Fatalf("query org secret: %v", err)
56
+	}
57
+	if strings.Contains(string(ciphertext), "super-secret") {
58
+		t.Fatal("plaintext appeared in org workflow_secrets.ciphertext")
59
+	}
60
+
61
+	resp = httptest.NewRecorder()
62
+	req = newOrgFormRequest(http.MethodPost, "/organizations/acme/settings/variables/actions", url.Values{
63
+		"name":  {"REGISTRY"},
64
+		"value": {"registry.internal"},
65
+	})
66
+	mux.ServeHTTP(resp, req)
67
+	if resp.Code != http.StatusSeeOther {
68
+		t.Fatalf("POST org variable status=%d body=%s", resp.Code, resp.Body.String())
69
+	}
70
+	resp = httptest.NewRecorder()
71
+	req = httptest.NewRequest(http.MethodGet, "/organizations/acme/settings/variables/actions", nil)
72
+	mux.ServeHTTP(resp, req)
73
+	if resp.Code != http.StatusOK {
74
+		t.Fatalf("GET org variable status=%d body=%s", resp.Code, resp.Body.String())
75
+	}
76
+	if got := resp.Body.String(); !strings.Contains(got, "VAR=REGISTRY:registry.internal;") {
77
+		t.Fatalf("org variable missing from list: %s", got)
78
+	}
79
+}
80
+
81
+func newOrgActionsHandler(t *testing.T, pool *pgxpool.Pool) *orgsh.Handlers {
82
+	t.Helper()
83
+	tmplFS := fstest.MapFS{
84
+		"_layout.html":               {Data: []byte(`{{ define "layout" }}{{ template "page" . }}{{ end }}`)},
85
+		"orgs/settings_profile.html": {Data: []byte(`{{ define "page" }}profile{{ end }}`)},
86
+		"orgs/settings_secrets.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ range .Secrets }}SECRET={{ .Name }};{{ end }}{{ range .Variables }}VAR={{ .Name }}:{{ .Value }};{{ end }}{{ end }}`)},
87
+		"errors/403.html":            {Data: []byte(`{{ define "page" }}403{{ end }}`)},
88
+		"errors/404.html":            {Data: []byte(`{{ define "page" }}404{{ end }}`)},
89
+		"errors/500.html":            {Data: []byte(`{{ define "page" }}500{{ end }}`)},
90
+	}
91
+	rr, err := render.New(tmplFS, render.Options{})
92
+	if err != nil {
93
+		t.Fatalf("render.New: %v", err)
94
+	}
95
+	key, err := secretbox.GenerateKey()
96
+	if err != nil {
97
+		t.Fatalf("GenerateKey: %v", err)
98
+	}
99
+	box, err := secretbox.FromBytes(key)
100
+	if err != nil {
101
+		t.Fatalf("FromBytes: %v", err)
102
+	}
103
+	h, err := orgsh.New(orgsh.Deps{
104
+		Logger:      slog.New(slog.NewTextHandler(io.Discard, nil)),
105
+		Render:      rr,
106
+		Pool:        pool,
107
+		ObjectStore: storage.NewMemoryStore(),
108
+		SecretBox:   box,
109
+	})
110
+	if err != nil {
111
+		t.Fatalf("orgsh.New: %v", err)
112
+	}
113
+	return h
114
+}
115
+
116
+func newOrgFormRequest(method, target string, form url.Values) *http.Request {
117
+	body := ""
118
+	if form != nil {
119
+		body = form.Encode()
120
+	}
121
+	req := httptest.NewRequest(method, target, strings.NewReader(body))
122
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
123
+	return req
124
+}
internal/web/handlers/repo/repo_test.gomodified
@@ -137,12 +137,14 @@ func minimalTemplatesFS() fstest.MapFS {
137137
 	layout := []byte(`{{ define "layout" }}{{ template "page" . }}{{ end }}`)
138138
 	body := []byte(`{{ define "page" }}{{ .StatusText }}: {{ .Message }}{{ end }}`)
139139
 	return fstest.MapFS{
140
-		"_layout.html":    {Data: layout},
141
-		"errors/403.html": {Data: body},
142
-		"errors/404.html": {Data: body},
143
-		"errors/429.html": {Data: body},
144
-		"errors/500.html": {Data: body},
145
-		"repo/new.html":   {Data: []byte(`{{ define "page" }}OWNERS={{ range .Owners }}{{ .Token }}:{{ if eq .Token $.Form.Owner }}selected{{ end }}:{{ .Slug }};{{ end }}{{ end }}`)},
140
+		"_layout.html":               {Data: layout},
141
+		"_repo_settings_nav.html":    {Data: []byte(`{{ define "repo-settings-nav" }}NAV{{ end }}`)},
142
+		"errors/403.html":            {Data: body},
143
+		"errors/404.html":            {Data: body},
144
+		"errors/429.html":            {Data: body},
145
+		"errors/500.html":            {Data: body},
146
+		"repo/new.html":              {Data: []byte(`{{ define "page" }}OWNERS={{ range .Owners }}{{ .Token }}:{{ if eq .Token $.Form.Owner }}selected{{ end }}:{{ .Slug }};{{ end }}{{ end }}`)},
147
+		"repo/settings_secrets.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ range .Secrets }}SECRET={{ .Name }};{{ end }}{{ range .Variables }}VAR={{ .Name }}:{{ .Value }};{{ end }}{{ end }}`)},
146148
 	}
147149
 }
148150
 
internal/web/handlers/repo/settings_actions.goadded
@@ -0,0 +1,204 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"errors"
7
+	"net/http"
8
+	"strings"
9
+
10
+	"github.com/go-chi/chi/v5"
11
+
12
+	"github.com/tenseleyFlow/shithub/internal/actions/secrets"
13
+	actionsvars "github.com/tenseleyFlow/shithub/internal/actions/variables"
14
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
15
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
16
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
17
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
18
+)
19
+
20
+// MountSettingsActions registers the Actions secrets + variables settings
21
+// routes. Caller wraps with RequireUser; per-route policy gates inside.
22
+func (h *Handlers) MountSettingsActions(r chi.Router) {
23
+	r.Get("/{owner}/{repo}/settings/secrets/actions", h.settingsActionsSecrets)
24
+	r.Post("/{owner}/{repo}/settings/secrets/actions", h.settingsActionsSecretSet)
25
+	r.Post("/{owner}/{repo}/settings/secrets/actions/{name}/delete", h.settingsActionsSecretDelete)
26
+	r.Get("/{owner}/{repo}/settings/variables/actions", h.settingsActionsVariables)
27
+	r.Post("/{owner}/{repo}/settings/variables/actions", h.settingsActionsVariableSet)
28
+	r.Post("/{owner}/{repo}/settings/variables/actions/{name}/delete", h.settingsActionsVariableDelete)
29
+}
30
+
31
+func (h *Handlers) settingsActionsSecrets(w http.ResponseWriter, r *http.Request) {
32
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsActions)
33
+	if !ok {
34
+		return
35
+	}
36
+	h.renderRepoActionsSettings(w, r, row, owner.Username, "secrets", "", settingsNoticeMessage(r.URL.Query().Get("notice")))
37
+}
38
+
39
+func (h *Handlers) settingsActionsSecretSet(w http.ResponseWriter, r *http.Request) {
40
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsActions)
41
+	if !ok {
42
+		return
43
+	}
44
+	if h.d.SecretBox == nil {
45
+		http.Error(w, "actions secret key not configured", http.StatusServiceUnavailable)
46
+		return
47
+	}
48
+	if err := r.ParseForm(); err != nil {
49
+		http.Error(w, "form parse", http.StatusBadRequest)
50
+		return
51
+	}
52
+	viewer := middleware.CurrentUserFromContext(r.Context())
53
+	name := strings.TrimSpace(r.PostFormValue("name"))
54
+	value := []byte(r.PostFormValue("value"))
55
+	err := secrets.Deps{Pool: h.d.Pool, Box: h.d.SecretBox}.Set(r.Context(), secrets.RepoScope(row.ID), name, value, viewer.ID)
56
+	if err != nil {
57
+		h.renderRepoActionsSettings(w, r, row, owner.Username, "secrets", friendlyActionsSecretError(err), "")
58
+		return
59
+	}
60
+	h.recordRepoActionsAudit(r, viewer, audit.ActionActionsSecretSet, row.ID, name)
61
+	http.Redirect(w, r, repoActionsSettingsPath(owner.Username, row.Name, "secrets")+"?notice=saved", http.StatusSeeOther)
62
+}
63
+
64
+func (h *Handlers) settingsActionsSecretDelete(w http.ResponseWriter, r *http.Request) {
65
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsActions)
66
+	if !ok {
67
+		return
68
+	}
69
+	name := chi.URLParam(r, "name")
70
+	err := secrets.Deps{Pool: h.d.Pool, Box: h.d.SecretBox}.Delete(r.Context(), secrets.RepoScope(row.ID), name)
71
+	if err != nil {
72
+		if errors.Is(err, secrets.ErrInvalidName) {
73
+			http.Error(w, "bad secret name", http.StatusBadRequest)
74
+			return
75
+		}
76
+		http.Error(w, "delete failed", http.StatusInternalServerError)
77
+		return
78
+	}
79
+	viewer := middleware.CurrentUserFromContext(r.Context())
80
+	h.recordRepoActionsAudit(r, viewer, audit.ActionActionsSecretDeleted, row.ID, name)
81
+	http.Redirect(w, r, repoActionsSettingsPath(owner.Username, row.Name, "secrets")+"?notice=deleted", http.StatusSeeOther)
82
+}
83
+
84
+func (h *Handlers) settingsActionsVariables(w http.ResponseWriter, r *http.Request) {
85
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsActions)
86
+	if !ok {
87
+		return
88
+	}
89
+	h.renderRepoActionsSettings(w, r, row, owner.Username, "variables", "", settingsNoticeMessage(r.URL.Query().Get("notice")))
90
+}
91
+
92
+func (h *Handlers) settingsActionsVariableSet(w http.ResponseWriter, r *http.Request) {
93
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsActions)
94
+	if !ok {
95
+		return
96
+	}
97
+	if err := r.ParseForm(); err != nil {
98
+		http.Error(w, "form parse", http.StatusBadRequest)
99
+		return
100
+	}
101
+	viewer := middleware.CurrentUserFromContext(r.Context())
102
+	name := strings.TrimSpace(r.PostFormValue("name"))
103
+	value := r.PostFormValue("value")
104
+	err := actionsvars.Deps{Pool: h.d.Pool}.Set(r.Context(), actionsvars.RepoScope(row.ID), name, value, viewer.ID)
105
+	if err != nil {
106
+		h.renderRepoActionsSettings(w, r, row, owner.Username, "variables", friendlyActionsVariableError(err), "")
107
+		return
108
+	}
109
+	h.recordRepoActionsAudit(r, viewer, audit.ActionActionsVariableSet, row.ID, name)
110
+	http.Redirect(w, r, repoActionsSettingsPath(owner.Username, row.Name, "variables")+"?notice=saved", http.StatusSeeOther)
111
+}
112
+
113
+func (h *Handlers) settingsActionsVariableDelete(w http.ResponseWriter, r *http.Request) {
114
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsActions)
115
+	if !ok {
116
+		return
117
+	}
118
+	name := chi.URLParam(r, "name")
119
+	err := actionsvars.Deps{Pool: h.d.Pool}.Delete(r.Context(), actionsvars.RepoScope(row.ID), name)
120
+	if err != nil {
121
+		if errors.Is(err, actionsvars.ErrInvalidName) {
122
+			http.Error(w, "bad variable name", http.StatusBadRequest)
123
+			return
124
+		}
125
+		http.Error(w, "delete failed", http.StatusInternalServerError)
126
+		return
127
+	}
128
+	viewer := middleware.CurrentUserFromContext(r.Context())
129
+	h.recordRepoActionsAudit(r, viewer, audit.ActionActionsVariableDeleted, row.ID, name)
130
+	http.Redirect(w, r, repoActionsSettingsPath(owner.Username, row.Name, "variables")+"?notice=deleted", http.StatusSeeOther)
131
+}
132
+
133
+func (h *Handlers) renderRepoActionsSettings(w http.ResponseWriter, r *http.Request, row reposdb.Repo, owner, kind, errMsg, notice string) {
134
+	data := map[string]any{
135
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
136
+		"Owner":     owner,
137
+		"Repo":      row,
138
+		"Kind":      kind,
139
+		"Error":     errMsg,
140
+		"Notice":    notice,
141
+	}
142
+	switch kind {
143
+	case "secrets":
144
+		items, err := secrets.Deps{Pool: h.d.Pool, Box: h.d.SecretBox}.List(r.Context(), secrets.RepoScope(row.ID))
145
+		if err != nil {
146
+			h.d.Logger.WarnContext(r.Context(), "actions secrets: list repo", "repo_id", row.ID, "error", err)
147
+			items = nil
148
+		}
149
+		data["Title"] = "Actions secrets · " + row.Name
150
+		data["Heading"] = "Actions secrets"
151
+		data["IsSecrets"] = true
152
+		data["Secrets"] = items
153
+		data["SecretDisabled"] = h.d.SecretBox == nil
154
+		data["SettingsActive"] = "actions-secrets"
155
+		data["FormAction"] = repoActionsSettingsPath(owner, row.Name, "secrets")
156
+	case "variables":
157
+		items, err := actionsvars.Deps{Pool: h.d.Pool}.List(r.Context(), actionsvars.RepoScope(row.ID))
158
+		if err != nil {
159
+			h.d.Logger.WarnContext(r.Context(), "actions variables: list repo", "repo_id", row.ID, "error", err)
160
+			items = nil
161
+		}
162
+		data["Title"] = "Actions variables · " + row.Name
163
+		data["Heading"] = "Actions variables"
164
+		data["Variables"] = items
165
+		data["SettingsActive"] = "actions-variables"
166
+		data["FormAction"] = repoActionsSettingsPath(owner, row.Name, "variables")
167
+	}
168
+	h.d.Render.RenderPage(w, r, "repo/settings_secrets", data)
169
+}
170
+
171
+func repoActionsSettingsPath(owner, repoName, kind string) string {
172
+	return "/" + owner + "/" + repoName + "/settings/" + kind + "/actions"
173
+}
174
+
175
+func friendlyActionsSecretError(err error) string {
176
+	switch {
177
+	case errors.Is(err, secrets.ErrInvalidName):
178
+		return "Name must start with a letter or underscore and contain only letters, numbers, and underscores."
179
+	case errors.Is(err, secrets.ErrEmptyValue):
180
+		return "Secret value is required."
181
+	case errors.Is(err, secrets.ErrInvalidScope):
182
+		return "Invalid secret scope."
183
+	default:
184
+		return "Could not save secret."
185
+	}
186
+}
187
+
188
+func friendlyActionsVariableError(err error) string {
189
+	switch {
190
+	case errors.Is(err, actionsvars.ErrInvalidName):
191
+		return "Name must start with a letter or underscore and contain only letters, numbers, and underscores."
192
+	case errors.Is(err, actionsvars.ErrValueTooLong):
193
+		return "Variable value must be 4096 characters or fewer."
194
+	case errors.Is(err, actionsvars.ErrInvalidScope):
195
+		return "Invalid variable scope."
196
+	default:
197
+		return "Could not save variable."
198
+	}
199
+}
200
+
201
+func (h *Handlers) recordRepoActionsAudit(r *http.Request, viewer middleware.CurrentUser, action audit.Action, repoID int64, name string) {
202
+	actor, meta := viewer.AuditActor(map[string]any{"name": name})
203
+	_ = h.d.Audit.Record(r.Context(), h.d.Pool, actor, action, audit.TargetRepo, repoID, meta)
204
+}
internal/web/handlers/repo/settings_actions_test.goadded
@@ -0,0 +1,151 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"context"
7
+	"net/http"
8
+	"net/http/httptest"
9
+	"net/url"
10
+	"strings"
11
+	"testing"
12
+
13
+	"github.com/go-chi/chi/v5"
14
+
15
+	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
16
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
17
+)
18
+
19
+func TestSettingsActionsRepoSecretCRUDDoesNotRenderPlaintext(t *testing.T) {
20
+	t.Parallel()
21
+	f := newRepoFixture(t)
22
+	f.handlers.d.SecretBox = testSecretBox(t)
23
+	mux := f.actionsSettingsMux(f.owner.ID, f.owner.Username)
24
+
25
+	resp := httptest.NewRecorder()
26
+	req := newFormRequest(http.MethodPost, "/alice/public-repo/settings/secrets/actions", url.Values{
27
+		"name":  {"DEPLOY_KEY"},
28
+		"value": {"hunter2"},
29
+	})
30
+	mux.ServeHTTP(resp, req)
31
+	if resp.Code != http.StatusSeeOther {
32
+		t.Fatalf("POST secret status=%d body=%s", resp.Code, resp.Body.String())
33
+	}
34
+
35
+	var ciphertext []byte
36
+	if err := f.pool.QueryRow(context.Background(),
37
+		`SELECT ciphertext FROM workflow_secrets WHERE repo_id = $1 AND name = $2`,
38
+		f.publicRepo.ID, "DEPLOY_KEY").Scan(&ciphertext); err != nil {
39
+		t.Fatalf("query ciphertext: %v", err)
40
+	}
41
+	if strings.Contains(string(ciphertext), "hunter2") {
42
+		t.Fatal("plaintext appeared in workflow_secrets.ciphertext")
43
+	}
44
+
45
+	resp = httptest.NewRecorder()
46
+	req = httptest.NewRequest(http.MethodGet, "/alice/public-repo/settings/secrets/actions", nil)
47
+	mux.ServeHTTP(resp, req)
48
+	if resp.Code != http.StatusOK {
49
+		t.Fatalf("GET secret list status=%d body=%s", resp.Code, resp.Body.String())
50
+	}
51
+	body := resp.Body.String()
52
+	if !strings.Contains(body, "SECRET=DEPLOY_KEY;") {
53
+		t.Fatalf("secret name missing from list: %s", body)
54
+	}
55
+	if strings.Contains(body, "hunter2") {
56
+		t.Fatalf("secret plaintext leaked in list body: %s", body)
57
+	}
58
+
59
+	resp = httptest.NewRecorder()
60
+	req = newFormRequest(http.MethodPost, "/alice/public-repo/settings/secrets/actions/DEPLOY_KEY/delete", nil)
61
+	mux.ServeHTTP(resp, req)
62
+	if resp.Code != http.StatusSeeOther {
63
+		t.Fatalf("DELETE secret status=%d body=%s", resp.Code, resp.Body.String())
64
+	}
65
+	var count int
66
+	if err := f.pool.QueryRow(context.Background(),
67
+		`SELECT count(*) FROM workflow_secrets WHERE repo_id = $1 AND name = $2`,
68
+		f.publicRepo.ID, "DEPLOY_KEY").Scan(&count); err != nil {
69
+		t.Fatalf("count secrets: %v", err)
70
+	}
71
+	if count != 0 {
72
+		t.Fatalf("secret row count=%d, want 0", count)
73
+	}
74
+}
75
+
76
+func TestSettingsActionsRepoVariableCRUDRendersValue(t *testing.T) {
77
+	t.Parallel()
78
+	f := newRepoFixture(t)
79
+	mux := f.actionsSettingsMux(f.owner.ID, f.owner.Username)
80
+
81
+	resp := httptest.NewRecorder()
82
+	req := newFormRequest(http.MethodPost, "/alice/public-repo/settings/variables/actions", url.Values{
83
+		"name":  {"IMAGE_TAG"},
84
+		"value": {"2026.05"},
85
+	})
86
+	mux.ServeHTTP(resp, req)
87
+	if resp.Code != http.StatusSeeOther {
88
+		t.Fatalf("POST variable status=%d body=%s", resp.Code, resp.Body.String())
89
+	}
90
+
91
+	resp = httptest.NewRecorder()
92
+	req = httptest.NewRequest(http.MethodGet, "/alice/public-repo/settings/variables/actions", nil)
93
+	mux.ServeHTTP(resp, req)
94
+	if resp.Code != http.StatusOK {
95
+		t.Fatalf("GET variable list status=%d body=%s", resp.Code, resp.Body.String())
96
+	}
97
+	if got := resp.Body.String(); !strings.Contains(got, "VAR=IMAGE_TAG:2026.05;") {
98
+		t.Fatalf("variable missing from list: %s", got)
99
+	}
100
+
101
+	resp = httptest.NewRecorder()
102
+	req = newFormRequest(http.MethodPost, "/alice/public-repo/settings/variables/actions/IMAGE_TAG/delete", nil)
103
+	mux.ServeHTTP(resp, req)
104
+	if resp.Code != http.StatusSeeOther {
105
+		t.Fatalf("DELETE variable status=%d body=%s", resp.Code, resp.Body.String())
106
+	}
107
+	var count int
108
+	if err := f.pool.QueryRow(context.Background(),
109
+		`SELECT count(*) FROM actions_variables WHERE repo_id = $1 AND name = $2`,
110
+		f.publicRepo.ID, "IMAGE_TAG").Scan(&count); err != nil {
111
+		t.Fatalf("count variables: %v", err)
112
+	}
113
+	if count != 0 {
114
+		t.Fatalf("variable row count=%d, want 0", count)
115
+	}
116
+}
117
+
118
+func (f *repoFixture) actionsSettingsMux(userID int64, username string) http.Handler {
119
+	mux := chi.NewRouter()
120
+	mux.Use(func(next http.Handler) http.Handler {
121
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122
+			viewer := middleware.CurrentUser{ID: userID, Username: username}
123
+			next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer)))
124
+		})
125
+	})
126
+	f.handlers.MountSettingsActions(mux)
127
+	return mux
128
+}
129
+
130
+func newFormRequest(method, target string, form url.Values) *http.Request {
131
+	body := ""
132
+	if form != nil {
133
+		body = form.Encode()
134
+	}
135
+	req := httptest.NewRequest(method, target, strings.NewReader(body))
136
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
137
+	return req
138
+}
139
+
140
+func testSecretBox(t *testing.T) *secretbox.Box {
141
+	t.Helper()
142
+	key, err := secretbox.GenerateKey()
143
+	if err != nil {
144
+		t.Fatalf("GenerateKey: %v", err)
145
+	}
146
+	box, err := secretbox.FromBytes(key)
147
+	if err != nil {
148
+		t.Fatalf("FromBytes: %v", err)
149
+	}
150
+	return box
151
+}
internal/web/handlers/repo/settings_general.gomodified
@@ -414,6 +414,8 @@ func settingsNoticeMessage(code string) string {
414414
 	switch code {
415415
 	case "saved":
416416
 		return "Settings saved."
417
+	case "deleted":
418
+		return "Deleted."
417419
 	case "":
418420
 		return ""
419421
 	default:
internal/web/orgs_wiring.gomodified
@@ -12,7 +12,9 @@ import (
1212
 
1313
 	"github.com/jackc/pgx/v5/pgxpool"
1414
 
15
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
1516
 	"github.com/tenseleyFlow/shithub/internal/auth/email"
17
+	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
1618
 	"github.com/tenseleyFlow/shithub/internal/infra/config"
1719
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
1820
 	orgshandlers "github.com/tenseleyFlow/shithub/internal/web/handlers/orgs"
@@ -33,6 +35,16 @@ func buildOrgHandlers(
3335
 		return nil, err
3436
 	}
3537
 	sender, _ := pickOrgsEmailSender(cfg)
38
+	var box *secretbox.Box
39
+	if cfg.Auth.TOTPKeyB64 != "" {
40
+		if b, err := secretbox.FromBase64(cfg.Auth.TOTPKeyB64); err == nil {
41
+			box = b
42
+		} else if logger != nil {
43
+			logger.Warn("orgs: actions secretbox unavailable",
44
+				"hint", "set Auth.TOTPKeyB64 to a base64 32-byte key",
45
+				"error", err)
46
+		}
47
+	}
3648
 	return orgshandlers.New(orgshandlers.Deps{
3749
 		Logger:      logger,
3850
 		Render:      rr,
@@ -42,6 +54,8 @@ func buildOrgHandlers(
4254
 		SiteName:    cfg.Auth.SiteName,
4355
 		BaseURL:     cfg.Auth.BaseURL,
4456
 		ObjectStore: objectStore,
57
+		SecretBox:   box,
58
+		Audit:       audit.NewRecorder(),
4559
 	})
4660
 }
4761
 
internal/web/server.gomodified
@@ -210,6 +210,12 @@ func Run(ctx context.Context, opts Options) error {
210210
 				repoH.MountSettingsGeneral(r)
211211
 			})
212212
 		}
213
+		deps.RepoSettingsActionsMounter = func(r chi.Router) {
214
+			r.Group(func(r chi.Router) {
215
+				r.Use(middleware.RequireUser)
216
+				repoH.MountSettingsActions(r)
217
+			})
218
+		}
213219
 		deps.RepoWebhooksMounter = func(r chi.Router) {
214220
 			r.Group(func(r chi.Router) {
215221
 				r.Use(middleware.RequireUser)
internal/web/templates/_repo_settings_nav.htmlmodified
@@ -21,6 +21,12 @@
2121
       <li{{ if eq .SettingsActive "webhooks" }} class="active"{{ end }}>
2222
         <a href="/{{ .Owner }}/{{ .Repo.Name }}/settings/webhooks">Webhooks</a>
2323
       </li>
24
+      <li{{ if eq .SettingsActive "actions-secrets" }} class="active"{{ end }}>
25
+        <a href="/{{ .Owner }}/{{ .Repo.Name }}/settings/secrets/actions">Actions secrets</a>
26
+      </li>
27
+      <li{{ if eq .SettingsActive "actions-variables" }} class="active"{{ end }}>
28
+        <a href="/{{ .Owner }}/{{ .Repo.Name }}/settings/variables/actions">Actions variables</a>
29
+      </li>
2430
       <li{{ if eq .SettingsActive "keys" }} class="active"{{ end }}>
2531
         <a href="/{{ .Owner }}/{{ .Repo.Name }}/settings/keys">Deploy keys</a>
2632
       </li>
internal/web/templates/orgs/settings_secrets.htmladded
@@ -0,0 +1,87 @@
1
+{{ define "page" -}}
2
+<section class="shithub-org-settings">
3
+  <header class="shithub-org-profile-head">
4
+    <h1>{{ .Org.DisplayName }} · {{ .Heading }}</h1>
5
+    <p class="shithub-meta">@{{ .Org.Slug }}</p>
6
+    <nav class="shithub-subnav" aria-label="Organization Actions settings">
7
+      <a class="{{ if .IsSecrets }}active{{ end }}" href="/organizations/{{ .Org.Slug }}/settings/secrets/actions">Secrets</a>
8
+      <a class="{{ if not .IsSecrets }}active{{ end }}" href="/organizations/{{ .Org.Slug }}/settings/variables/actions">Variables</a>
9
+    </nav>
10
+  </header>
11
+
12
+  {{ with .Notice }}<p class="shithub-flash shithub-flash-notice" role="status">{{ . }}</p>{{ end }}
13
+  {{ with .Error }}<p class="shithub-flash shithub-flash-error" role="alert">{{ . }}</p>{{ end }}
14
+
15
+  <section class="shithub-settings-section">
16
+    <h2>{{ if .IsSecrets }}Organization secrets{{ else }}Organization variables{{ end }}</h2>
17
+    {{ if and .IsSecrets .SecretDisabled }}
18
+      <p>Actions secret writes require the at-rest secret key. Set Auth.TOTPKeyB64 in config and restart.</p>
19
+    {{ else }}
20
+      <form method="POST" action="{{ .FormAction }}" novalidate>
21
+        <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
22
+        <label>
23
+          <span>Name</span>
24
+          <input type="text" name="name" maxlength="100" pattern="[A-Za-z_][A-Za-z0-9_]*" autocomplete="off" required>
25
+        </label>
26
+        <label>
27
+          <span>{{ if .IsSecrets }}Secret{{ else }}Value{{ end }}</span>
28
+          {{ if .IsSecrets }}
29
+            <input type="password" name="value" autocomplete="off" required>
30
+          {{ else }}
31
+            <textarea name="value" maxlength="4096" rows="3"></textarea>
32
+          {{ end }}
33
+        </label>
34
+        <button type="submit" class="shithub-button shithub-button-primary">Save {{ if .IsSecrets }}secret{{ else }}variable{{ end }}</button>
35
+      </form>
36
+    {{ end }}
37
+  </section>
38
+
39
+  <section class="shithub-settings-section">
40
+    <h2>Existing {{ if .IsSecrets }}secrets{{ else }}variables{{ end }}</h2>
41
+    {{ if .IsSecrets }}
42
+      {{ if .Secrets }}
43
+        <table class="shithub-branches-table">
44
+          <thead><tr><th>Name</th><th></th></tr></thead>
45
+          <tbody>
46
+            {{ range .Secrets }}
47
+            <tr>
48
+              <td><code>{{ .Name }}</code></td>
49
+              <td>
50
+                <form method="POST" action="/organizations/{{ $.Org.Slug }}/settings/secrets/actions/{{ .Name }}/delete" style="display:inline">
51
+                  <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
52
+                  <button type="submit" class="shithub-button shithub-button-danger">Delete</button>
53
+                </form>
54
+              </td>
55
+            </tr>
56
+            {{ end }}
57
+          </tbody>
58
+        </table>
59
+      {{ else }}
60
+        <p>No secrets have been added.</p>
61
+      {{ end }}
62
+    {{ else }}
63
+      {{ if .Variables }}
64
+        <table class="shithub-branches-table">
65
+          <thead><tr><th>Name</th><th>Value</th><th></th></tr></thead>
66
+          <tbody>
67
+            {{ range .Variables }}
68
+            <tr>
69
+              <td><code>{{ .Name }}</code></td>
70
+              <td><code>{{ .Value }}</code></td>
71
+              <td>
72
+                <form method="POST" action="/organizations/{{ $.Org.Slug }}/settings/variables/actions/{{ .Name }}/delete" style="display:inline">
73
+                  <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
74
+                  <button type="submit" class="shithub-button shithub-button-danger">Delete</button>
75
+                </form>
76
+              </td>
77
+            </tr>
78
+            {{ end }}
79
+          </tbody>
80
+        </table>
81
+      {{ else }}
82
+        <p>No variables have been added.</p>
83
+      {{ end }}
84
+    {{ end }}
85
+  </section>
86
+</section>
87
+{{- end }}
internal/web/templates/repo/settings_secrets.htmladded
@@ -0,0 +1,82 @@
1
+{{ define "page" -}}
2
+<div class="shithub-settings-page">
3
+  {{ template "repo-settings-nav" . }}
4
+  <div class="shithub-settings-content">
5
+    <h1>{{ .Heading }}</h1>
6
+    {{ with .Notice }}<p class="shithub-flash shithub-flash-notice" role="status">{{ . }}</p>{{ end }}
7
+    {{ with .Error }}<p class="shithub-flash shithub-flash-error" role="alert">{{ . }}</p>{{ end }}
8
+
9
+    <section class="shithub-settings-section">
10
+      <h2>{{ if .IsSecrets }}Repository secrets{{ else }}Repository variables{{ end }}</h2>
11
+      {{ if and .IsSecrets .SecretDisabled }}
12
+        <p>Actions secret writes require the at-rest secret key. Set Auth.TOTPKeyB64 in config and restart.</p>
13
+      {{ else }}
14
+        <form method="POST" action="{{ .FormAction }}" novalidate>
15
+          <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
16
+          <label>
17
+            <span>Name</span>
18
+            <input type="text" name="name" maxlength="100" pattern="[A-Za-z_][A-Za-z0-9_]*" autocomplete="off" required>
19
+          </label>
20
+          <label>
21
+            <span>{{ if .IsSecrets }}Secret{{ else }}Value{{ end }}</span>
22
+            {{ if .IsSecrets }}
23
+              <input type="password" name="value" autocomplete="off" required>
24
+            {{ else }}
25
+              <textarea name="value" maxlength="4096" rows="3"></textarea>
26
+            {{ end }}
27
+          </label>
28
+          <button type="submit" class="shithub-button shithub-button-primary">Save {{ if .IsSecrets }}secret{{ else }}variable{{ end }}</button>
29
+        </form>
30
+      {{ end }}
31
+    </section>
32
+
33
+    <section class="shithub-settings-section">
34
+      <h2>Existing {{ if .IsSecrets }}secrets{{ else }}variables{{ end }}</h2>
35
+      {{ if .IsSecrets }}
36
+        {{ if .Secrets }}
37
+          <table class="shithub-branches-table">
38
+            <thead><tr><th>Name</th><th></th></tr></thead>
39
+            <tbody>
40
+              {{ range .Secrets }}
41
+              <tr>
42
+                <td><code>{{ .Name }}</code></td>
43
+                <td>
44
+                  <form method="POST" action="/{{ $.Owner }}/{{ $.Repo.Name }}/settings/secrets/actions/{{ .Name }}/delete" style="display:inline">
45
+                    <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
46
+                    <button type="submit" class="shithub-button shithub-button-danger">Delete</button>
47
+                  </form>
48
+                </td>
49
+              </tr>
50
+              {{ end }}
51
+            </tbody>
52
+          </table>
53
+        {{ else }}
54
+          <p>No secrets have been added.</p>
55
+        {{ end }}
56
+      {{ else }}
57
+        {{ if .Variables }}
58
+          <table class="shithub-branches-table">
59
+            <thead><tr><th>Name</th><th>Value</th><th></th></tr></thead>
60
+            <tbody>
61
+              {{ range .Variables }}
62
+              <tr>
63
+                <td><code>{{ .Name }}</code></td>
64
+                <td><code>{{ .Value }}</code></td>
65
+                <td>
66
+                  <form method="POST" action="/{{ $.Owner }}/{{ $.Repo.Name }}/settings/variables/actions/{{ .Name }}/delete" style="display:inline">
67
+                    <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
68
+                    <button type="submit" class="shithub-button shithub-button-danger">Delete</button>
69
+                  </form>
70
+                </td>
71
+              </tr>
72
+              {{ end }}
73
+            </tbody>
74
+          </table>
75
+        {{ else }}
76
+          <p>No variables have been added.</p>
77
+        {{ end }}
78
+      {{ end }}
79
+    </section>
80
+  </div>
81
+</div>
82
+{{- end }}