tenseleyflow/shithub / d933bdc

Browse files

api: propagate PAT policy actors

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
d933bdc35515ec40286b16af9c924d30c0979003
Parents
b4c53a7
Tree
ea9999e

9 changed files

StatusFile+-
M docs/internal/permissions.md 4 4
A docs/public/api/actions.md 52 0
M docs/public/api/overview.md 2 1
M internal/web/handlers/api/actions_cancel.go 2 3
M internal/web/handlers/api/actions_rerun.go 2 3
M internal/web/handlers/api/checks.go 1 2
M internal/web/handlers/api/stars.go 1 5
M internal/web/middleware/middleware_test.go 17 0
M internal/web/middleware/pat.go 21 6
docs/internal/permissions.mdmodified
@@ -147,10 +147,10 @@ that constructs an actor must source it correctly:
147147
   suspending an account takes effect on the user's next click.
148148
 * **Web (PAT)** — `middleware.PATAuthMiddleware` rejects requests
149149
   whose owning user has `suspended_at IS NOT NULL` with a 401 before
150
-  the handler runs. Code paths under PAT auth construct
151
-  `policy.UserActor(..., IsSuspended: false, ...)` because the gate
152
-  is upstream; the field is still passed for honesty and is correct
153
-  by construction.
150
+  the handler runs. It still binds username, suspension, and site-admin
151
+  fields into `middleware.PATAuth`; API policy gates must construct
152
+  actors through `PATAuth.PolicyActor()` so the request actor stays
153
+  honest even as the middleware evolves.
154154
 * **git over HTTPS (`internal/web/handlers/githttp`)** — the basic-
155155
   auth resolver (`auth.go::resolveViaPAT`/`resolveViaPassword`)
156156
   rejects suspended owners with `errBadCredentials` *before* the
docs/public/api/actions.mdadded
@@ -0,0 +1,52 @@
1
+# Actions workflow API
2
+
3
+Actions workflow lifecycle endpoints are PAT-authenticated and require
4
+`repo:write`. The token's user must also have write permission on the
5
+repository that owns the target run or job.
6
+
7
+## Cancel job
8
+
9
+```text
10
+POST /api/v1/jobs/{id}/cancel
11
+```
12
+
13
+Requests cancellation for a workflow job. Queued jobs become terminal
14
+immediately. Running jobs set `cancel_requested=true`; the runner observes
15
+that flag through its cancel-check endpoint, stops the active container, and
16
+reports the terminal status.
17
+
18
+Response: `202 Accepted`.
19
+
20
+```json
21
+{
22
+  "job_id": 10,
23
+  "run_id": 4,
24
+  "repo_id": 2,
25
+  "changed_jobs": 1,
26
+  "run_completed": false,
27
+  "run_conclusion": ""
28
+}
29
+```
30
+
31
+## Re-run workflow run
32
+
33
+```text
34
+POST /api/v1/runs/{id}/rerun
35
+```
36
+
37
+Creates a new workflow run from the original run's commit and workflow file.
38
+Only terminal runs are rerunnable. The new run records `parent_run_id` so the
39
+history remains linked.
40
+
41
+Response: `201 Created`.
42
+
43
+```json
44
+{
45
+  "run_id": 12,
46
+  "run_index": 8,
47
+  "parent_run_id": 4,
48
+  "repo_id": 2,
49
+  "workflow_file": ".shithub/workflows/ci.yml",
50
+  "head_sha": "0123456789abcdef0123456789abcdef01234567"
51
+}
52
+```
docs/public/api/overview.mdmodified
@@ -8,7 +8,8 @@ instead of PATs.
88
 > **Status.** The API is intentionally narrow today. Endpoints
99
 > currently shipped: `GET /api/v1/user`, the
1010
 > `/api/v1/repos/{owner}/{repo}/check-runs` family, and the
11
-> `/api/v1/user/starred*` stars endpoints. Other sections of this
11
+> `/api/v1/user/starred*` stars endpoints, plus the Actions lifecycle
12
+> routes in [Actions workflow API](actions.md). Other sections of this
1213
 > reference (Issues, Pull requests, Webhooks, etc.) describe the
1314
 > **planned** shape and will land in subsequent sprints. Pages
1415
 > that document planned-only endpoints carry a banner.
internal/web/handlers/api/actions_cancel.gomodified
@@ -39,7 +39,7 @@ func (h *Handlers) workflowJobCancel(w http.ResponseWriter, r *http.Request) {
3939
 		writeAPIError(w, http.StatusNotFound, "job not found")
4040
 		return
4141
 	}
42
-	job, run, repo, ok := h.resolveCancellableJob(w, r, auth.UserID, jobID)
42
+	job, run, repo, ok := h.resolveCancellableJob(w, r, auth.PolicyActor(), jobID)
4343
 	if !ok {
4444
 		return
4545
 	}
@@ -65,7 +65,7 @@ func (h *Handlers) workflowJobCancel(w http.ResponseWriter, r *http.Request) {
6565
 func (h *Handlers) resolveCancellableJob(
6666
 	w http.ResponseWriter,
6767
 	r *http.Request,
68
-	userID int64,
68
+	actor policy.Actor,
6969
 	jobID int64,
7070
 ) (actionsdb.WorkflowJob, actionsdb.WorkflowRun, reposdb.Repo, bool) {
7171
 	q := actionsdb.New()
@@ -92,7 +92,6 @@ func (h *Handlers) resolveCancellableJob(
9292
 		}
9393
 		return actionsdb.WorkflowJob{}, actionsdb.WorkflowRun{}, reposdb.Repo{}, false
9494
 	}
95
-	actor := policy.UserActor(userID, "", false, false)
9695
 	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionRepoWrite, policy.NewRepoRefFromRepo(repo)).Allow {
9796
 		writeAPIError(w, http.StatusNotFound, "job not found")
9897
 		return actionsdb.WorkflowJob{}, actionsdb.WorkflowRun{}, reposdb.Repo{}, false
internal/web/handlers/api/actions_rerun.gomodified
@@ -28,7 +28,7 @@ func (h *Handlers) workflowRunRerun(w http.ResponseWriter, r *http.Request) {
2828
 		writeAPIError(w, http.StatusNotFound, "run not found")
2929
 		return
3030
 	}
31
-	run, repo, ok := h.resolveLifecycleRun(w, r, auth.UserID, runID)
31
+	run, repo, ok := h.resolveLifecycleRun(w, r, auth.PolicyActor(), runID)
3232
 	if !ok {
3333
 		return
3434
 	}
@@ -54,7 +54,7 @@ func (h *Handlers) workflowRunRerun(w http.ResponseWriter, r *http.Request) {
5454
 func (h *Handlers) resolveLifecycleRun(
5555
 	w http.ResponseWriter,
5656
 	r *http.Request,
57
-	userID int64,
57
+	actor policy.Actor,
5858
 	runID int64,
5959
 ) (actionsdb.WorkflowRun, reposdb.Repo, bool) {
6060
 	q := actionsdb.New()
@@ -72,7 +72,6 @@ func (h *Handlers) resolveLifecycleRun(
7272
 		}
7373
 		return actionsdb.WorkflowRun{}, reposdb.Repo{}, false
7474
 	}
75
-	actor := policy.UserActor(userID, "", false, false)
7675
 	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionRepoWrite, policy.NewRepoRefFromRepo(repo)).Allow {
7776
 		writeAPIError(w, http.StatusNotFound, "run not found")
7877
 		return actionsdb.WorkflowRun{}, reposdb.Repo{}, false
internal/web/handlers/api/checks.gomodified
@@ -60,8 +60,7 @@ func (h *Handlers) resolveAPIRepo(w http.ResponseWriter, r *http.Request, action
6060
 		writeAPIError(w, http.StatusNotFound, "repo not found")
6161
 		return nil, false
6262
 	}
63
-	actor := policy.UserActor(auth.UserID, "", false, false)
64
-	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, action, policy.NewRepoRefFromRepo(repo)).Allow {
63
+	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), action, policy.NewRepoRefFromRepo(repo)).Allow {
6564
 		// Existence-leak: 404 instead of 403 when the actor can't see
6665
 		// the repo. The PAT-scope check above is the public 403; this
6766
 		// is the visibility gate.
internal/web/handlers/api/stars.gomodified
@@ -122,11 +122,7 @@ func (h *Handlers) resolveStarTargetRepo(w http.ResponseWriter, r *http.Request)
122122
 		writeAPIError(w, http.StatusNotFound, "repo not found")
123123
 		return reposdb.Repo{}, false
124124
 	}
125
-	// PAT-auth path: the middleware already rejected suspended
126
-	// accounts; passing IsSuspended=false here is correct by
127
-	// construction (documented in docs/internal/permissions.md).
128
-	actor := policy.UserActor(auth.UserID, "", false, false)
129
-	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionStarCreate, policy.NewRepoRefFromRepo(repo)).Allow {
125
+	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), policy.ActionStarCreate, policy.NewRepoRefFromRepo(repo)).Allow {
130126
 		writeAPIError(w, http.StatusNotFound, "repo not found")
131127
 		return reposdb.Repo{}, false
132128
 	}
internal/web/middleware/middleware_test.gomodified
@@ -61,6 +61,23 @@ func TestOptionalUser_PopulatesIsSuspended(t *testing.T) {
6161
 	}
6262
 }
6363
 
64
+func TestPATAuthPolicyActorPropagatesResolvedUserFlags(t *testing.T) {
65
+	t.Parallel()
66
+
67
+	actor := PATAuth{
68
+		UserID:      42,
69
+		Username:    "alice",
70
+		IsSuspended: true,
71
+		IsSiteAdmin: true,
72
+	}.PolicyActor()
73
+	if actor.UserID != 42 || actor.Username != "alice" || !actor.IsSuspended || !actor.IsSiteAdmin {
74
+		t.Fatalf("PolicyActor did not propagate PAT user flags: %+v", actor)
75
+	}
76
+	if anon := (PATAuth{}).PolicyActor(); !anon.IsAnonymous {
77
+		t.Fatalf("zero PATAuth should produce anonymous actor: %+v", anon)
78
+	}
79
+}
80
+
6481
 // TestOptionalUser_StaleEpochSkipsBind is the corollary: when the
6582
 // recorded session epoch doesn't match the current users.session_epoch
6683
 // (because the user logged out everywhere), the binding is skipped so
internal/web/middleware/pat.gomodified
@@ -15,6 +15,7 @@ import (
1515
 	"github.com/jackc/pgx/v5/pgxpool"
1616
 
1717
 	"github.com/tenseleyFlow/shithub/internal/auth/pat"
18
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
1819
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
1920
 )
2021
 
@@ -24,9 +25,12 @@ var patAuthKey = ctxKey{name: "pat_auth"}
2425
 // the auth check passed via PAT, `Token != nil` and Scopes is the parsed
2526
 // scope list. Pure session callers see the zero value.
2627
 type PATAuth struct {
27
-	UserID  int64
28
-	TokenID int64
29
-	Scopes  []string
28
+	UserID      int64
29
+	Username    string
30
+	TokenID     int64
31
+	Scopes      []string
32
+	IsSuspended bool
33
+	IsSiteAdmin bool
3034
 }
3135
 
3236
 // PATAuthFromContext returns the resolved PAT auth state, or the zero
@@ -38,6 +42,14 @@ func PATAuthFromContext(ctx context.Context) PATAuth {
3842
 	return PATAuth{}
3943
 }
4044
 
45
+// PolicyActor returns the canonical policy actor for a resolved PAT request.
46
+func (p PATAuth) PolicyActor() policy.Actor {
47
+	if p.UserID == 0 {
48
+		return policy.AnonymousActor()
49
+	}
50
+	return policy.UserActor(p.UserID, p.Username, p.IsSuspended, p.IsSiteAdmin)
51
+}
52
+
4153
 // PATConfig configures the PAT auth middleware.
4254
 type PATConfig struct {
4355
 	Pool      *pgxpool.Pool
@@ -134,9 +146,12 @@ func PATAuthMiddleware(cfg PATConfig) func(http.Handler) http.Handler {
134146
 			}
135147
 
136148
 			ctx := context.WithValue(r.Context(), patAuthKey, PATAuth{
137
-				UserID:  row.UserID,
138
-				TokenID: row.ID,
139
-				Scopes:  row.Scopes,
149
+				UserID:      row.UserID,
150
+				Username:    user.Username,
151
+				TokenID:     row.ID,
152
+				Scopes:      row.Scopes,
153
+				IsSuspended: user.SuspendedAt.Valid,
154
+				IsSiteAdmin: user.IsSiteAdmin,
140155
 			})
141156
 			next.ServeHTTP(w, r.WithContext(ctx))
142157
 		})