tenseleyflow/shithub / a325a33

Browse files

actions: enforce trigger and runner policy gates (S41j-3)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
a325a332a7b049a024b2aaedecf4ff5567193005
Parents
c50d265
Tree
74e073d

12 changed files

StatusFile+-
A internal/actions/policy/policy.go 158 0
A internal/actions/policy/policy_test.go 268 0
M internal/actions/trigger/enqueue.go 17 1
M internal/actions/trigger/handler.go 26 0
M internal/auth/policy/actions.go 4 0
M internal/auth/policy/policy.go 2 2
M internal/auth/policy/policy_test.go 2 2
M internal/pulls/pulls.go 4 8
M internal/web/handlers/api/runners.go 6 3
M internal/web/handlers/api/runners_test.go 97 3
M internal/web/handlers/repo/actions_dispatch.go 12 0
M internal/worker/jobs/pr_jobs.go 30 14
internal/actions/policy/policy.goadded
@@ -0,0 +1,158 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package actionspolicy owns Actions-specific runtime policy: whether a
4
+// matched workflow may be queued, whether it must pause for approval, and the
5
+// abuse caps that keep a shared runner pool from being monopolized.
6
+package actionspolicy
7
+
8
+import (
9
+	"context"
10
+	"errors"
11
+	"fmt"
12
+	"time"
13
+
14
+	"github.com/jackc/pgx/v5/pgtype"
15
+	"github.com/jackc/pgx/v5/pgxpool"
16
+
17
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
18
+	authpolicy "github.com/tenseleyFlow/shithub/internal/auth/policy"
19
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
20
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
21
+)
22
+
23
+var (
24
+	ErrActionsDisabled = errors.New("actions policy: actions disabled")
25
+	ErrRepoQueuedCap   = errors.New("actions policy: repo queued-run cap reached")
26
+	ErrActorRateLimit  = errors.New("actions policy: actor trigger rate limit reached")
27
+	ErrActorRequired   = errors.New("actions policy: actor required")
28
+	ErrActorNotFound   = errors.New("actions policy: actor not found")
29
+	ErrUnauthorized    = errors.New("actions policy: actor cannot run actions")
30
+)
31
+
32
+// Deps wires policy evaluation to storage and auth policy role lookups.
33
+type Deps struct {
34
+	Pool *pgxpool.Pool
35
+}
36
+
37
+// TriggerRequest describes one matched workflow about to become a run.
38
+type TriggerRequest struct {
39
+	Repo        reposdb.Repo
40
+	EventKind   string
41
+	ActorUserID int64
42
+	Now         time.Time
43
+}
44
+
45
+// TriggerDecision is the dispatch posture for one matched workflow.
46
+type TriggerDecision struct {
47
+	Allow          bool
48
+	NeedApproval   bool
49
+	ApprovalReason string
50
+	Reason         string
51
+	Policy         actionsdb.GetEffectiveActionsPolicyForRepoRow
52
+}
53
+
54
+// EvaluateTrigger applies site/org/repo Actions policy, auth policy, and
55
+// enqueue-time abuse caps. It never grants repository privileges itself:
56
+// push/dispatch/same-repo trusted PR execution flows through
57
+// internal/auth/policy.ActionActionsRun. Untrusted PRs can only be queued in
58
+// a paused approval-required state.
59
+func EvaluateTrigger(ctx context.Context, deps Deps, req TriggerRequest) (TriggerDecision, error) {
60
+	if deps.Pool == nil {
61
+		return TriggerDecision{}, errors.New("actions policy: nil Pool")
62
+	}
63
+	q := actionsdb.New()
64
+	pol, err := q.GetEffectiveActionsPolicyForRepo(ctx, deps.Pool, req.Repo.ID)
65
+	if err != nil {
66
+		return TriggerDecision{}, err
67
+	}
68
+	base := TriggerDecision{Policy: pol}
69
+	if !pol.ActionsEnabled {
70
+		base.Reason = "actions disabled by policy"
71
+		return base, ErrActionsDisabled
72
+	}
73
+	if req.Repo.DeletedAt.Valid {
74
+		base.Reason = "repo deleted"
75
+		return base, ErrUnauthorized
76
+	}
77
+	if req.Repo.IsArchived {
78
+		base.Reason = "repo archived"
79
+		return base, ErrUnauthorized
80
+	}
81
+	if err := checkEnqueueCaps(ctx, q, deps.Pool, req, pol); err != nil {
82
+		base.Reason = err.Error()
83
+		return base, err
84
+	}
85
+
86
+	if req.EventKind == "schedule" {
87
+		base.Allow = true
88
+		base.Reason = "schedule trigger"
89
+		return base, nil
90
+	}
91
+	if req.ActorUserID == 0 {
92
+		base.Reason = "actor required"
93
+		return base, ErrActorRequired
94
+	}
95
+	user, err := usersdb.New().GetUserByID(ctx, deps.Pool, req.ActorUserID)
96
+	if err != nil {
97
+		base.Reason = "actor not found"
98
+		return base, fmt.Errorf("%w: %w", ErrActorNotFound, err)
99
+	}
100
+	actor := authpolicy.UserActor(user.ID, user.Username, user.SuspendedAt.Valid, user.IsSiteAdmin)
101
+	authz := authpolicy.Can(ctx, authpolicy.Deps{Pool: deps.Pool}, actor, authpolicy.ActionActionsRun, authpolicy.NewRepoRefFromRepo(req.Repo))
102
+	if authz.Allow {
103
+		base.Allow = true
104
+		base.Reason = "actor can run actions"
105
+		return base, nil
106
+	}
107
+	if user.SuspendedAt.Valid {
108
+		base.Reason = "actor suspended"
109
+		return base, ErrUnauthorized
110
+	}
111
+	if req.EventKind == "pull_request" {
112
+		base.Allow = true
113
+		if pol.RequirePrApproval {
114
+			base.NeedApproval = true
115
+			base.ApprovalReason = "Pull request workflow requires maintainer approval before runner dispatch."
116
+			base.Reason = "pull request requires approval"
117
+		} else {
118
+			base.Reason = "pull request approval disabled by policy"
119
+		}
120
+		return base, nil
121
+	}
122
+	base.Reason = authz.Reason
123
+	return base, ErrUnauthorized
124
+}
125
+
126
+func checkEnqueueCaps(
127
+	ctx context.Context,
128
+	q *actionsdb.Queries,
129
+	db actionsdb.DBTX,
130
+	req TriggerRequest,
131
+	pol actionsdb.GetEffectiveActionsPolicyForRepoRow,
132
+) error {
133
+	queued, err := q.CountQueuedWorkflowRunsForRepo(ctx, db, req.Repo.ID)
134
+	if err != nil {
135
+		return err
136
+	}
137
+	if queued >= int64(pol.MaxRepoQueuedRuns) {
138
+		return ErrRepoQueuedCap
139
+	}
140
+	if req.ActorUserID == 0 {
141
+		return nil
142
+	}
143
+	now := req.Now
144
+	if now.IsZero() {
145
+		now = time.Now()
146
+	}
147
+	recent, err := q.CountRecentWorkflowRunsForActor(ctx, db, actionsdb.CountRecentWorkflowRunsForActorParams{
148
+		ActorUserID: pgtype.Int8{Int64: req.ActorUserID, Valid: true},
149
+		Since:       pgtype.Timestamptz{Time: now.Add(-time.Hour), Valid: true},
150
+	})
151
+	if err != nil {
152
+		return err
153
+	}
154
+	if recent >= int64(pol.ActorTriggerLimitPerHour) {
155
+		return ErrActorRateLimit
156
+	}
157
+	return nil
158
+}
internal/actions/policy/policy_test.goadded
@@ -0,0 +1,268 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package actionspolicy_test
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"strings"
9
+	"testing"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+	"github.com/jackc/pgx/v5/pgxpool"
13
+
14
+	actionspolicy "github.com/tenseleyFlow/shithub/internal/actions/policy"
15
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
16
+	"github.com/tenseleyFlow/shithub/internal/actions/trigger"
17
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
18
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
19
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
20
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
21
+)
22
+
23
+const policyFixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
24
+	"AAAAAAAAAAAAAAAA$" +
25
+	"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
26
+
27
+type policyFx struct {
28
+	ctx       context.Context
29
+	pool      *pgxpool.Pool
30
+	owner     usersdb.User
31
+	writeUser usersdb.User
32
+	readUser  usersdb.User
33
+	outsider  usersdb.User
34
+	suspended usersdb.User
35
+	siteAdmin usersdb.User
36
+	orgOwner  usersdb.User
37
+	repo      reposdb.Repo
38
+	orgRepo   reposdb.Repo
39
+}
40
+
41
+func setupPolicyFx(t *testing.T) policyFx {
42
+	t.Helper()
43
+	pool := dbtest.NewTestDB(t)
44
+	ctx := context.Background()
45
+	uq := usersdb.New()
46
+	mkUser := func(name string) usersdb.User {
47
+		t.Helper()
48
+		u, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
49
+			Username: name, DisplayName: name, PasswordHash: policyFixtureHash,
50
+		})
51
+		if err != nil {
52
+			t.Fatalf("CreateUser %s: %v", name, err)
53
+		}
54
+		return u
55
+	}
56
+	owner := mkUser("owner")
57
+	writeUser := mkUser("writer")
58
+	readUser := mkUser("reader")
59
+	outsider := mkUser("outsider")
60
+	suspended := mkUser("suspended")
61
+	siteAdmin := mkUser("siteadmin")
62
+	orgOwner := mkUser("orgowner")
63
+	if _, err := pool.Exec(ctx, `UPDATE users SET suspended_at = now() WHERE id = $1`, suspended.ID); err != nil {
64
+		t.Fatalf("suspend user: %v", err)
65
+	}
66
+	if _, err := pool.Exec(ctx, `UPDATE users SET is_site_admin = true WHERE id = $1`, siteAdmin.ID); err != nil {
67
+		t.Fatalf("site admin user: %v", err)
68
+	}
69
+	rq := reposdb.New()
70
+	repo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
71
+		OwnerUserID:   pgtype.Int8{Int64: owner.ID, Valid: true},
72
+		Name:          "demo",
73
+		DefaultBranch: "trunk",
74
+		Visibility:    reposdb.RepoVisibilityPublic,
75
+	})
76
+	if err != nil {
77
+		t.Fatalf("CreateRepo: %v", err)
78
+	}
79
+	if _, err := pool.Exec(ctx, `INSERT INTO repo_collaborators (repo_id, user_id, role, added_by_user_id) VALUES ($1, $2, 'write', $3), ($1, $4, 'read', $3)`,
80
+		repo.ID, writeUser.ID, owner.ID, readUser.ID); err != nil {
81
+		t.Fatalf("insert collaborators: %v", err)
82
+	}
83
+	org, err := orgsdb.New().CreateOrg(ctx, pool, orgsdb.CreateOrgParams{
84
+		Slug:            "acme",
85
+		DisplayName:     "Acme",
86
+		CreatedByUserID: pgtype.Int8{Int64: orgOwner.ID, Valid: true},
87
+	})
88
+	if err != nil {
89
+		t.Fatalf("CreateOrg: %v", err)
90
+	}
91
+	if _, err := pool.Exec(ctx, `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'owner')`, org.ID, orgOwner.ID); err != nil {
92
+		t.Fatalf("insert org owner: %v", err)
93
+	}
94
+	orgRepo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
95
+		OwnerOrgID:    pgtype.Int8{Int64: org.ID, Valid: true},
96
+		Name:          "org-demo",
97
+		DefaultBranch: "trunk",
98
+		Visibility:    reposdb.RepoVisibilityPublic,
99
+	})
100
+	if err != nil {
101
+		t.Fatalf("CreateRepo org: %v", err)
102
+	}
103
+	return policyFx{
104
+		ctx:       ctx,
105
+		pool:      pool,
106
+		owner:     owner,
107
+		writeUser: writeUser,
108
+		readUser:  readUser,
109
+		outsider:  outsider,
110
+		suspended: suspended,
111
+		siteAdmin: siteAdmin,
112
+		orgOwner:  orgOwner,
113
+		repo:      repo,
114
+		orgRepo:   orgRepo,
115
+	}
116
+}
117
+
118
+func TestEvaluateTrigger_TrustMatrix(t *testing.T) {
119
+	t.Parallel()
120
+	f := setupPolicyFx(t)
121
+	deps := actionspolicy.Deps{Pool: f.pool}
122
+	tests := []struct {
123
+		name         string
124
+		repo         reposdb.Repo
125
+		actorID      int64
126
+		event        trigger.EventKind
127
+		wantAllow    bool
128
+		wantApproval bool
129
+		wantErr      error
130
+	}{
131
+		{name: "owner push runs", repo: f.repo, actorID: f.owner.ID, event: trigger.EventPush, wantAllow: true},
132
+		{name: "write collaborator push runs", repo: f.repo, actorID: f.writeUser.ID, event: trigger.EventPush, wantAllow: true},
133
+		{name: "org owner push runs", repo: f.orgRepo, actorID: f.orgOwner.ID, event: trigger.EventPush, wantAllow: true},
134
+		{name: "read collaborator pr pauses for approval", repo: f.repo, actorID: f.readUser.ID, event: trigger.EventPullRequest, wantAllow: true, wantApproval: true},
135
+		{name: "outsider pr pauses for approval", repo: f.repo, actorID: f.outsider.ID, event: trigger.EventPullRequest, wantAllow: true, wantApproval: true},
136
+		{name: "outsider push denied", repo: f.repo, actorID: f.outsider.ID, event: trigger.EventPush, wantErr: actionspolicy.ErrUnauthorized},
137
+		{name: "suspended actor denied", repo: f.repo, actorID: f.suspended.ID, event: trigger.EventPush, wantErr: actionspolicy.ErrUnauthorized},
138
+		{name: "site admin write does not bypass repo role", repo: f.repo, actorID: f.siteAdmin.ID, event: trigger.EventPush, wantErr: actionspolicy.ErrUnauthorized},
139
+	}
140
+	for _, tt := range tests {
141
+		tt := tt
142
+		t.Run(tt.name, func(t *testing.T) {
143
+			dec, err := actionspolicy.EvaluateTrigger(f.ctx, deps, actionspolicy.TriggerRequest{
144
+				Repo:        tt.repo,
145
+				EventKind:   string(tt.event),
146
+				ActorUserID: tt.actorID,
147
+			})
148
+			if tt.wantErr != nil {
149
+				if !errors.Is(err, tt.wantErr) {
150
+					t.Fatalf("err=%v, want %v (decision=%+v)", err, tt.wantErr, dec)
151
+				}
152
+				return
153
+			}
154
+			if err != nil {
155
+				t.Fatalf("EvaluateTrigger: %v", err)
156
+			}
157
+			if dec.Allow != tt.wantAllow || dec.NeedApproval != tt.wantApproval {
158
+				t.Fatalf("decision=%+v, want allow=%t approval=%t", dec, tt.wantAllow, tt.wantApproval)
159
+			}
160
+		})
161
+	}
162
+}
163
+
164
+func TestEvaluateTrigger_PullRequestApprovalPolicyCanAllowImmediateRun(t *testing.T) {
165
+	t.Parallel()
166
+	f := setupPolicyFx(t)
167
+	if _, err := actionsdb.New().UpsertActionsRepoPolicy(f.ctx, f.pool, actionsdb.UpsertActionsRepoPolicyParams{
168
+		RepoID:            f.repo.ID,
169
+		ActionsEnabled:    actionsdb.ActionsPolicyStateInherit,
170
+		RequirePrApproval: pgtype.Bool{Bool: false, Valid: true},
171
+	}); err != nil {
172
+		t.Fatalf("UpsertActionsRepoPolicy: %v", err)
173
+	}
174
+
175
+	dec, err := actionspolicy.EvaluateTrigger(f.ctx, actionspolicy.Deps{Pool: f.pool}, actionspolicy.TriggerRequest{
176
+		Repo:        f.repo,
177
+		EventKind:   string(trigger.EventPullRequest),
178
+		ActorUserID: f.outsider.ID,
179
+	})
180
+	if err != nil {
181
+		t.Fatalf("EvaluateTrigger: %v", err)
182
+	}
183
+	if !dec.Allow || dec.NeedApproval {
184
+		t.Fatalf("decision=%+v, want immediate PR run", dec)
185
+	}
186
+}
187
+
188
+func TestEvaluateTrigger_DeniesArchivedAndDisabledRepos(t *testing.T) {
189
+	t.Parallel()
190
+	f := setupPolicyFx(t)
191
+	archived := f.repo
192
+	archived.IsArchived = true
193
+	if dec, err := actionspolicy.EvaluateTrigger(f.ctx, actionspolicy.Deps{Pool: f.pool}, actionspolicy.TriggerRequest{
194
+		Repo:        archived,
195
+		EventKind:   string(trigger.EventPush),
196
+		ActorUserID: f.owner.ID,
197
+	}); !errors.Is(err, actionspolicy.ErrUnauthorized) || dec.Allow {
198
+		t.Fatalf("archived decision=%+v err=%v", dec, err)
199
+	}
200
+
201
+	if _, err := actionsdb.New().UpsertActionsRepoPolicy(f.ctx, f.pool, actionsdb.UpsertActionsRepoPolicyParams{
202
+		RepoID:         f.repo.ID,
203
+		ActionsEnabled: actionsdb.ActionsPolicyStateDisabled,
204
+	}); err != nil {
205
+		t.Fatalf("UpsertActionsRepoPolicy: %v", err)
206
+	}
207
+	if dec, err := actionspolicy.EvaluateTrigger(f.ctx, actionspolicy.Deps{Pool: f.pool}, actionspolicy.TriggerRequest{
208
+		Repo:        f.repo,
209
+		EventKind:   string(trigger.EventPush),
210
+		ActorUserID: f.owner.ID,
211
+	}); !errors.Is(err, actionspolicy.ErrActionsDisabled) || dec.Allow {
212
+		t.Fatalf("disabled decision=%+v err=%v", dec, err)
213
+	}
214
+}
215
+
216
+func TestEvaluateTrigger_EnforcesQueueAndActorCaps(t *testing.T) {
217
+	t.Parallel()
218
+	f := setupPolicyFx(t)
219
+	q := actionsdb.New()
220
+	if _, err := q.UpsertActionsRepoPolicy(f.ctx, f.pool, actionsdb.UpsertActionsRepoPolicyParams{
221
+		RepoID:                   f.repo.ID,
222
+		ActionsEnabled:           actionsdb.ActionsPolicyStateInherit,
223
+		MaxRepoQueuedRuns:        pgtype.Int4{Int32: 1, Valid: true},
224
+		ActorTriggerLimitPerHour: pgtype.Int4{Int32: 1, Valid: true},
225
+	}); err != nil {
226
+		t.Fatalf("UpsertActionsRepoPolicy: %v", err)
227
+	}
228
+	if _, err := q.InsertWorkflowRun(f.ctx, f.pool, actionsdb.InsertWorkflowRunParams{
229
+		RepoID:       f.repo.ID,
230
+		RunIndex:     1,
231
+		WorkflowFile: ".shithub/workflows/ci.yml",
232
+		WorkflowName: "CI",
233
+		HeadSha:      strings.Repeat("a", 40),
234
+		HeadRef:      "refs/heads/trunk",
235
+		Event:        actionsdb.WorkflowRunEventPush,
236
+		EventPayload: []byte("{}"),
237
+		ActorUserID:  pgtype.Int8{Int64: f.owner.ID, Valid: true},
238
+		NeedApproval: false,
239
+	}); err != nil {
240
+		t.Fatalf("InsertWorkflowRun: %v", err)
241
+	}
242
+	dec, err := actionspolicy.EvaluateTrigger(f.ctx, actionspolicy.Deps{Pool: f.pool}, actionspolicy.TriggerRequest{
243
+		Repo:        f.repo,
244
+		EventKind:   string(trigger.EventPush),
245
+		ActorUserID: f.owner.ID,
246
+	})
247
+	if !errors.Is(err, actionspolicy.ErrRepoQueuedCap) || dec.Allow {
248
+		t.Fatalf("queue cap decision=%+v err=%v", dec, err)
249
+	}
250
+	if _, err := f.pool.Exec(f.ctx, `
251
+		UPDATE workflow_runs
252
+		   SET status = 'completed',
253
+		       conclusion = 'success',
254
+		       started_at = now(),
255
+		       completed_at = now()
256
+		 WHERE repo_id = $1`,
257
+		f.repo.ID); err != nil {
258
+		t.Fatalf("complete queued run: %v", err)
259
+	}
260
+	dec, err = actionspolicy.EvaluateTrigger(f.ctx, actionspolicy.Deps{Pool: f.pool}, actionspolicy.TriggerRequest{
261
+		Repo:        f.repo,
262
+		EventKind:   string(trigger.EventPush),
263
+		ActorUserID: f.owner.ID,
264
+	})
265
+	if !errors.Is(err, actionspolicy.ErrActorRateLimit) || dec.Allow {
266
+		t.Fatalf("actor cap decision=%+v err=%v", dec, err)
267
+	}
268
+}
internal/actions/trigger/enqueue.gomodified
@@ -78,6 +78,14 @@ type EnqueueParams struct {
78
 	// persisted; concurrency.group is resolved against the trigger
78
 	// persisted; concurrency.group is resolved against the trigger
79
 	// context and enforced before runners can claim younger jobs.
79
 	// context and enforced before runners can claim younger jobs.
80
 	Workflow *workflow.Workflow
80
 	Workflow *workflow.Workflow
81
+
82
+	// NeedApproval pauses runner dispatch until a maintainer approves the
83
+	// run. The row still becomes visible in the Actions UI and check list.
84
+	NeedApproval bool
85
+
86
+	// ApprovalReason is stored with the approval request. It must stay
87
+	// structural; never include event payloads, env, logs, or secrets.
88
+	ApprovalReason string
81
 }
89
 }
82
 
90
 
83
 // Result reports the outcome of an Enqueue call.
91
 // Result reports the outcome of an Enqueue call.
@@ -180,7 +188,7 @@ func Enqueue(ctx context.Context, deps Deps, p EnqueueParams) (Result, error) {
180
 		ActorUserID:      pgInt8(p.ActorUserID),
188
 		ActorUserID:      pgInt8(p.ActorUserID),
181
 		ParentRunID:      pgInt8(p.ParentRunID),
189
 		ParentRunID:      pgInt8(p.ParentRunID),
182
 		ConcurrencyGroup: concurrencyResolution.Group,
190
 		ConcurrencyGroup: concurrencyResolution.Group,
183
-		NeedApproval:     false,
191
+		NeedApproval:     p.NeedApproval,
184
 		TriggerEventID:   p.TriggerEventID,
192
 		TriggerEventID:   p.TriggerEventID,
185
 	})
193
 	})
186
 	if err != nil {
194
 	if err != nil {
@@ -207,6 +215,14 @@ func Enqueue(ctx context.Context, deps Deps, p EnqueueParams) (Result, error) {
207
 	if err := actionsevents.EmitRunTx(ctx, tx, run, actionsevents.ActionQueued); err != nil {
215
 	if err := actionsevents.EmitRunTx(ctx, tx, run, actionsevents.ActionQueued); err != nil {
208
 		return Result{}, fmt.Errorf("trigger: emit run queued event: %w", err)
216
 		return Result{}, fmt.Errorf("trigger: emit run queued event: %w", err)
209
 	}
217
 	}
218
+	if p.NeedApproval {
219
+		if _, err := q.InsertWorkflowRunApproval(ctx, tx, actionsdb.InsertWorkflowRunApprovalParams{
220
+			RunID:           run.ID,
221
+			RequestedReason: p.ApprovalReason,
222
+		}); err != nil {
223
+			return Result{}, fmt.Errorf("trigger: insert approval request: %w", err)
224
+		}
225
+	}
210
 
226
 
211
 	// Persist child jobs + their steps. Order in Workflow.Jobs is YAML
227
 	// Persist child jobs + their steps. Order in Workflow.Jobs is YAML
212
 	// document order, which we preserve via job_index.
228
 	// document order, which we preserve via job_index.
internal/actions/trigger/handler.gomodified
@@ -12,6 +12,7 @@ import (
12
 	"github.com/jackc/pgx/v5"
12
 	"github.com/jackc/pgx/v5"
13
 	"github.com/jackc/pgx/v5/pgxpool"
13
 	"github.com/jackc/pgx/v5/pgxpool"
14
 
14
 
15
+	actionspolicy "github.com/tenseleyFlow/shithub/internal/actions/policy"
15
 	"github.com/tenseleyFlow/shithub/internal/actions/workflow"
16
 	"github.com/tenseleyFlow/shithub/internal/actions/workflow"
16
 	"github.com/tenseleyFlow/shithub/internal/infra/metrics"
17
 	"github.com/tenseleyFlow/shithub/internal/infra/metrics"
17
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
18
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
@@ -140,6 +141,20 @@ func Handler(deps JobDeps) worker.Handler {
140
 			if !Match(w, ev) {
141
 			if !Match(w, ev) {
141
 				continue
142
 				continue
142
 			}
143
 			}
144
+			decision, err := actionspolicy.EvaluateTrigger(ctx, actionspolicy.Deps{Pool: deps.Pool}, actionspolicy.TriggerRequest{
145
+				Repo:        repo,
146
+				EventKind:   string(p.EventKind),
147
+				ActorUserID: p.ActorUserID,
148
+				Now:         time.Now(),
149
+			})
150
+			if err != nil || !decision.Allow {
151
+				if isExpectedPolicySkip(err) || !decision.Allow {
152
+					deps.Logger.InfoContext(ctx, "trigger: workflow skipped by actions policy",
153
+						"repo_id", p.RepoID, "path", f.Path, "event", p.EventKind, "reason", decision.Reason, "error", err)
154
+					continue
155
+				}
156
+				return fmt.Errorf("trigger: evaluate actions policy: %w", err)
157
+			}
143
 			if _, err := Enqueue(ctx, deps.Deps, EnqueueParams{
158
 			if _, err := Enqueue(ctx, deps.Deps, EnqueueParams{
144
 				RepoID:         p.RepoID,
159
 				RepoID:         p.RepoID,
145
 				WorkflowFile:   f.Path,
160
 				WorkflowFile:   f.Path,
@@ -150,6 +165,8 @@ func Handler(deps JobDeps) worker.Handler {
150
 				ActorUserID:    p.ActorUserID,
165
 				ActorUserID:    p.ActorUserID,
151
 				TriggerEventID: p.TriggerEventID,
166
 				TriggerEventID: p.TriggerEventID,
152
 				Workflow:       w,
167
 				Workflow:       w,
168
+				NeedApproval:   decision.NeedApproval,
169
+				ApprovalReason: decision.ApprovalReason,
153
 			}); err != nil {
170
 			}); err != nil {
154
 				deps.Logger.WarnContext(ctx, "trigger: enqueue failed",
171
 				deps.Logger.WarnContext(ctx, "trigger: enqueue failed",
155
 					"repo_id", p.RepoID, "path", f.Path, "trigger_event_id", p.TriggerEventID, "error", err)
172
 					"repo_id", p.RepoID, "path", f.Path, "trigger_event_id", p.TriggerEventID, "error", err)
@@ -163,6 +180,15 @@ func Handler(deps JobDeps) worker.Handler {
163
 	}
180
 	}
164
 }
181
 }
165
 
182
 
183
+func isExpectedPolicySkip(err error) bool {
184
+	return errors.Is(err, actionspolicy.ErrActionsDisabled) ||
185
+		errors.Is(err, actionspolicy.ErrRepoQueuedCap) ||
186
+		errors.Is(err, actionspolicy.ErrActorRateLimit) ||
187
+		errors.Is(err, actionspolicy.ErrActorRequired) ||
188
+		errors.Is(err, actionspolicy.ErrActorNotFound) ||
189
+		errors.Is(err, actionspolicy.ErrUnauthorized)
190
+}
191
+
166
 // eventFromPayload assembles the typed Event consumed by Match from
192
 // eventFromPayload assembles the typed Event consumed by Match from
167
 // the JSON payload's filter hints.
193
 // the JSON payload's filter hints.
168
 func eventFromPayload(p JobPayload) Event {
194
 func eventFromPayload(p JobPayload) Event {
internal/auth/policy/actions.gomodified
@@ -37,6 +37,9 @@ const (
37
 	ActionRepoDelete     Action = "repo:delete"
37
 	ActionRepoDelete     Action = "repo:delete"
38
 	ActionRepoTransfer   Action = "repo:transfer"
38
 	ActionRepoTransfer   Action = "repo:transfer"
39
 	ActionRepoVisibility Action = "repo:visibility"
39
 	ActionRepoVisibility Action = "repo:visibility"
40
+
41
+	ActionActionsRun     Action = "actions:run"
42
+	ActionActionsApprove Action = "actions:approve"
40
 )
43
 )
41
 
44
 
42
 // Issue-level actions. (Issue resources arrive in S18; S15 just ships
45
 // Issue-level actions. (Issue resources arrive in S18; S15 just ships
@@ -79,6 +82,7 @@ var AllActions = []Action{
79
 	ActionRepoRead, ActionRepoWrite, ActionRepoAdmin,
82
 	ActionRepoRead, ActionRepoWrite, ActionRepoAdmin,
80
 	ActionRepoSettingsGeneral, ActionRepoSettingsCollaborators, ActionRepoSettingsBranches, ActionRepoSettingsActions,
83
 	ActionRepoSettingsGeneral, ActionRepoSettingsCollaborators, ActionRepoSettingsBranches, ActionRepoSettingsActions,
81
 	ActionRepoArchive, ActionRepoDelete, ActionRepoTransfer, ActionRepoVisibility,
84
 	ActionRepoArchive, ActionRepoDelete, ActionRepoTransfer, ActionRepoVisibility,
85
+	ActionActionsRun, ActionActionsApprove,
82
 	ActionIssueRead, ActionIssueCreate, ActionIssueComment, ActionIssueClose, ActionIssueLabel, ActionIssueAssign,
86
 	ActionIssueRead, ActionIssueCreate, ActionIssueComment, ActionIssueClose, ActionIssueLabel, ActionIssueAssign,
83
 	ActionPullRead, ActionPullCreate, ActionPullMerge, ActionPullReview, ActionPullClose,
87
 	ActionPullRead, ActionPullCreate, ActionPullMerge, ActionPullReview, ActionPullClose,
84
 	ActionStarCreate, ActionForkCreate,
88
 	ActionStarCreate, ActionForkCreate,
internal/auth/policy/policy.gomodified
@@ -424,7 +424,7 @@ func minRoleFor(action Action) Role {
424
 		return RoleTriage
424
 		return RoleTriage
425
 
425
 
426
 	// Write tier — code push, branch create, PR open/comment.
426
 	// Write tier — code push, branch create, PR open/comment.
427
-	case ActionRepoWrite, ActionPullCreate, ActionPullReview, ActionPullClose:
427
+	case ActionRepoWrite, ActionActionsRun, ActionPullCreate, ActionPullReview, ActionPullClose:
428
 		return RoleWrite
428
 		return RoleWrite
429
 
429
 
430
 	// Issue participation on private repos requires read access. Public
430
 	// Issue participation on private repos requires read access. Public
@@ -433,7 +433,7 @@ func minRoleFor(action Action) Role {
433
 		return RoleRead
433
 		return RoleRead
434
 
434
 
435
 	// Maintain tier — most settings except dangerous ones.
435
 	// Maintain tier — most settings except dangerous ones.
436
-	case ActionRepoSettingsGeneral, ActionRepoSettingsBranches:
436
+	case ActionRepoSettingsGeneral, ActionRepoSettingsBranches, ActionActionsApprove:
437
 		return RoleMaintain
437
 		return RoleMaintain
438
 
438
 
439
 	// Admin tier — destructive and ownership-changing actions.
439
 	// Admin tier — destructive and ownership-changing actions.
internal/auth/policy/policy_test.gomodified
@@ -216,9 +216,9 @@ func mirrorMinRoleFor(a policy.Action) policy.Role {
216
 		return policy.RoleTriage
216
 		return policy.RoleTriage
217
 	case policy.ActionIssueCreate, policy.ActionIssueComment:
217
 	case policy.ActionIssueCreate, policy.ActionIssueComment:
218
 		return policy.RoleRead
218
 		return policy.RoleRead
219
-	case policy.ActionRepoWrite, policy.ActionPullCreate, policy.ActionPullReview, policy.ActionPullClose:
219
+	case policy.ActionRepoWrite, policy.ActionActionsRun, policy.ActionPullCreate, policy.ActionPullReview, policy.ActionPullClose:
220
 		return policy.RoleWrite
220
 		return policy.RoleWrite
221
-	case policy.ActionRepoSettingsGeneral, policy.ActionRepoSettingsBranches:
221
+	case policy.ActionRepoSettingsGeneral, policy.ActionRepoSettingsBranches, policy.ActionActionsApprove:
222
 		return policy.RoleMaintain
222
 		return policy.RoleMaintain
223
 	case policy.ActionRepoAdmin, policy.ActionRepoSettingsCollaborators, policy.ActionRepoSettingsActions,
223
 	case policy.ActionRepoAdmin, policy.ActionRepoSettingsCollaborators, policy.ActionRepoSettingsActions,
224
 		policy.ActionRepoArchive, policy.ActionRepoDelete, policy.ActionRepoTransfer, policy.ActionRepoVisibility,
224
 		policy.ActionRepoArchive, policy.ActionRepoDelete, policy.ActionRepoTransfer, policy.ActionRepoVisibility,
internal/pulls/pulls.gomodified
@@ -171,18 +171,14 @@ func Create(ctx context.Context, deps Deps, p CreateParams) (CreateResult, error
171
 // jobs package) would need to round-trip through the queue just to
171
 // jobs package) would need to round-trip through the queue just to
172
 // observe the open.
172
 // observe the open.
173
 //
173
 //
174
-// Collaborator gate: actor must be the repo's owning user. Same v1
174
+// Trust gate: the trigger handler evaluates the author through
175
-// posture as the synchronize path.
175
+// internal/auth/policy and either queues the run as trusted or marks it
176
+// approval-required. This helper only supplies the canonical PR event.
176
 func enqueueOpenedActionsTrigger(ctx context.Context, deps Deps, p CreateParams, prRow pullsdb.PullRequest, prNumber int64, baseOID, headOID string) error {
177
 func enqueueOpenedActionsTrigger(ctx context.Context, deps Deps, p CreateParams, prRow pullsdb.PullRequest, prNumber int64, baseOID, headOID string) error {
177
 	repo, err := reposdb.New().GetRepoByID(ctx, deps.Pool, p.RepoID)
178
 	repo, err := reposdb.New().GetRepoByID(ctx, deps.Pool, p.RepoID)
178
 	if err != nil {
179
 	if err != nil {
179
 		return fmt.Errorf("load repo: %w", err)
180
 		return fmt.Errorf("load repo: %w", err)
180
 	}
181
 	}
181
-	if !repo.OwnerUserID.Valid || repo.OwnerUserID.Int64 != p.AuthorUserID {
182
-		// Conservative collaborator gate — non-owner authors don't
183
-		// trigger. External-PR + org-member triggers parked for v2.
184
-		return nil
185
-	}
186
 	changed, err := repogit.ChangedPaths(ctx, p.GitDir, baseOID, headOID)
182
 	changed, err := repogit.ChangedPaths(ctx, p.GitDir, baseOID, headOID)
187
 	if err != nil {
183
 	if err != nil {
188
 		changed = nil // best-effort; path-filtered workflows skip
184
 		changed = nil // best-effort; path-filtered workflows skip
@@ -198,7 +194,7 @@ func enqueueOpenedActionsTrigger(ctx context.Context, deps Deps, p CreateParams,
198
 		authorLogin,
194
 		authorLogin,
199
 	)
195
 	)
200
 	job := trigger.JobPayload{
196
 	job := trigger.JobPayload{
201
-		RepoID:         p.RepoID,
197
+		RepoID:         repo.ID,
202
 		HeadSHA:        prRow.HeadOid,
198
 		HeadSHA:        prRow.HeadOid,
203
 		HeadRef:        "refs/heads/" + prRow.HeadRef,
199
 		HeadRef:        "refs/heads/" + prRow.HeadRef,
204
 		EventKind:      trigger.EventPullRequest,
200
 		EventKind:      trigger.EventPullRequest,
internal/web/handlers/api/runners.gomodified
@@ -257,7 +257,7 @@ func (h *Handlers) claimRunnerJob(
257
 	if err != nil {
257
 	if err != nil {
258
 		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
258
 		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
259
 	}
259
 	}
260
-	resolvedSecrets, err := h.resolveVisibleSecretsFromDB(ctx, tx, job.RepoID)
260
+	resolvedSecrets, err := h.resolveVisibleSecretsFromDB(ctx, tx, job.RepoID, job.Event)
261
 	if err != nil {
261
 	if err != nil {
262
 		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
262
 		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
263
 	}
263
 	}
@@ -1016,13 +1016,16 @@ type secretResolutionDB interface {
1016
 }
1016
 }
1017
 
1017
 
1018
 func (h *Handlers) resolveVisibleSecrets(ctx context.Context, repoID int64) (map[string]string, error) {
1018
 func (h *Handlers) resolveVisibleSecrets(ctx context.Context, repoID int64) (map[string]string, error) {
1019
-	return h.resolveVisibleSecretsFromDB(ctx, h.d.Pool, repoID)
1019
+	return h.resolveVisibleSecretsFromDB(ctx, h.d.Pool, repoID, "")
1020
 }
1020
 }
1021
 
1021
 
1022
-func (h *Handlers) resolveVisibleSecretsFromDB(ctx context.Context, db secretResolutionDB, repoID int64) (map[string]string, error) {
1022
+func (h *Handlers) resolveVisibleSecretsFromDB(ctx context.Context, db secretResolutionDB, repoID int64, event actionsdb.WorkflowRunEvent) (map[string]string, error) {
1023
 	if h.d.SecretBox == nil {
1023
 	if h.d.SecretBox == nil {
1024
 		return nil, nil
1024
 		return nil, nil
1025
 	}
1025
 	}
1026
+	if event == actionsdb.WorkflowRunEventPullRequest {
1027
+		return nil, nil
1028
+	}
1026
 	repo, err := reposdb.New().GetRepoByID(ctx, db, repoID)
1029
 	repo, err := reposdb.New().GetRepoByID(ctx, db, repoID)
1027
 	if err != nil {
1030
 	if err != nil {
1028
 		return nil, err
1031
 		return nil, err
internal/web/handlers/api/runners_test.gomodified
@@ -22,6 +22,7 @@ import (
22
 	dto "github.com/prometheus/client_model/go"
22
 	dto "github.com/prometheus/client_model/go"
23
 
23
 
24
 	"github.com/tenseleyFlow/shithub/internal/actions/finalize"
24
 	"github.com/tenseleyFlow/shithub/internal/actions/finalize"
25
+	actionslifecycle "github.com/tenseleyFlow/shithub/internal/actions/lifecycle"
25
 	"github.com/tenseleyFlow/shithub/internal/actions/runnertoken"
26
 	"github.com/tenseleyFlow/shithub/internal/actions/runnertoken"
26
 	actionsecrets "github.com/tenseleyFlow/shithub/internal/actions/secrets"
27
 	actionsecrets "github.com/tenseleyFlow/shithub/internal/actions/secrets"
27
 	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
28
 	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
@@ -310,6 +311,94 @@ func TestRunnerSecretsAreClaimedAndServerScrubsLogs(t *testing.T) {
310
 	}
311
 	}
311
 }
312
 }
312
 
313
 
314
+func TestRunnerHeartbeatDoesNotClaimApprovalPendingRun(t *testing.T) {
315
+	ctx := context.Background()
316
+	pool := dbtest.NewTestDB(t)
317
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
318
+	repoID, userID := setupRunnerAPIRepo(t, pool)
319
+	runID := enqueueRunnerAPIRun(t, pool, logger, repoID, userID)
320
+	q := actionsdb.New()
321
+	if _, err := pool.Exec(ctx, `UPDATE workflow_runs SET need_approval = true WHERE id = $1`, runID); err != nil {
322
+		t.Fatalf("mark run approval pending: %v", err)
323
+	}
324
+	if _, err := q.InsertWorkflowRunApproval(ctx, pool, actionsdb.InsertWorkflowRunApprovalParams{
325
+		RunID:           runID,
326
+		RequestedReason: "test approval",
327
+	}); err != nil {
328
+		t.Fatalf("InsertWorkflowRunApproval: %v", err)
329
+	}
330
+
331
+	token, _ := registerRunnerForTest(t, pool, []string{"ubuntu-latest", "linux"}, 1)
332
+	router := newRunnerAPIRouter(t, pool, logger, runnerAPISigner(t, time.Now()))
333
+
334
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/heartbeat",
335
+		strings.NewReader(`{"labels":["ubuntu-latest","linux"],"capacity":1}`))
336
+	req.Header.Set("Authorization", "Bearer "+token)
337
+	rr := httptest.NewRecorder()
338
+	router.ServeHTTP(rr, req)
339
+	if rr.Code != http.StatusNoContent {
340
+		t.Fatalf("pending heartbeat status: got %d, want 204; body=%s", rr.Code, rr.Body.String())
341
+	}
342
+	jobs, err := q.ListJobsForRun(ctx, pool, runID)
343
+	if err != nil {
344
+		t.Fatalf("ListJobsForRun: %v", err)
345
+	}
346
+	if len(jobs) != 1 || jobs[0].Status != actionsdb.WorkflowJobStatusQueued {
347
+		t.Fatalf("approval-pending job changed: %+v", jobs)
348
+	}
349
+
350
+	if _, err := actionslifecycle.ApproveRun(ctx, actionslifecycle.Deps{Pool: pool, Logger: logger}, runID, userID); err != nil {
351
+		t.Fatalf("ApproveRun: %v", err)
352
+	}
353
+	req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/heartbeat",
354
+		strings.NewReader(`{"labels":["ubuntu-latest","linux"],"capacity":1}`))
355
+	req.Header.Set("Authorization", "Bearer "+token)
356
+	rr = httptest.NewRecorder()
357
+	router.ServeHTTP(rr, req)
358
+	if rr.Code != http.StatusOK {
359
+		t.Fatalf("approved heartbeat status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
360
+	}
361
+}
362
+
363
+func TestRunnerDoesNotInjectSecretsIntoPullRequestRuns(t *testing.T) {
364
+	ctx := context.Background()
365
+	pool := dbtest.NewTestDB(t)
366
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
367
+	repoID, userID := setupRunnerAPIRepo(t, pool)
368
+	runID := enqueueRunnerAPIEventRun(t, pool, logger, repoID, userID, trigger.EventPullRequest, map[string]any{"action": "opened"})
369
+	box := testRunnerAPISecretBox(t)
370
+	if err := (actionsecrets.Deps{Pool: pool, Box: box}).Set(ctx, actionsecrets.RepoScope(repoID), "TOKEN", []byte("hunter2"), userID); err != nil {
371
+		t.Fatalf("Set secret: %v", err)
372
+	}
373
+
374
+	token, _ := registerRunnerForTest(t, pool, []string{"ubuntu-latest", "linux"}, 1)
375
+	router := newRunnerAPIRouterWithSecretBox(t, pool, logger, runnerAPISigner(t, time.Now()), box)
376
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/heartbeat",
377
+		strings.NewReader(`{"labels":["ubuntu-latest","linux"],"capacity":1}`))
378
+	req.Header.Set("Authorization", "Bearer "+token)
379
+	rr := httptest.NewRecorder()
380
+	router.ServeHTTP(rr, req)
381
+	if rr.Code != http.StatusOK {
382
+		t.Fatalf("heartbeat status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
383
+	}
384
+	var claim struct {
385
+		Job struct {
386
+			RunID      int64             `json:"run_id"`
387
+			Secrets    map[string]string `json:"secrets"`
388
+			MaskValues []string          `json:"mask_values"`
389
+		} `json:"job"`
390
+	}
391
+	if err := json.Unmarshal(rr.Body.Bytes(), &claim); err != nil {
392
+		t.Fatalf("decode claim: %v", err)
393
+	}
394
+	if claim.Job.RunID != runID {
395
+		t.Fatalf("claimed wrong run: %+v", claim.Job)
396
+	}
397
+	if len(claim.Job.Secrets) != 0 || len(claim.Job.MaskValues) != 0 {
398
+		t.Fatalf("pull_request run received secrets: %+v", claim.Job)
399
+	}
400
+}
401
+
313
 func TestRunnerServerScrubsSecretSplitAcrossLogPosts(t *testing.T) {
402
 func TestRunnerServerScrubsSecretSplitAcrossLogPosts(t *testing.T) {
314
 	ctx := context.Background()
403
 	ctx := context.Background()
315
 	pool := dbtest.NewTestDB(t)
404
 	pool := dbtest.NewTestDB(t)
@@ -907,6 +996,11 @@ func setupRunnerAPIRepo(t *testing.T, pool *pgxpool.Pool) (repoID, userID int64)
907
 }
996
 }
908
 
997
 
909
 func enqueueRunnerAPIRun(t *testing.T, pool *pgxpool.Pool, logger *slog.Logger, repoID, userID int64) int64 {
998
 func enqueueRunnerAPIRun(t *testing.T, pool *pgxpool.Pool, logger *slog.Logger, repoID, userID int64) int64 {
999
+	t.Helper()
1000
+	return enqueueRunnerAPIEventRun(t, pool, logger, repoID, userID, trigger.EventPush, map[string]any{"ref": "refs/heads/trunk"})
1001
+}
1002
+
1003
+func enqueueRunnerAPIEventRun(t *testing.T, pool *pgxpool.Pool, logger *slog.Logger, repoID, userID int64, event trigger.EventKind, payload map[string]any) int64 {
910
 	t.Helper()
1004
 	t.Helper()
911
 	wf, diags, err := workflow.Parse([]byte(`name: ci
1005
 	wf, diags, err := workflow.Parse([]byte(`name: ci
912
 on: push
1006
 on: push
@@ -930,10 +1024,10 @@ jobs:
930
 		WorkflowFile:   ".shithub/workflows/ci.yml",
1024
 		WorkflowFile:   ".shithub/workflows/ci.yml",
931
 		HeadSHA:        strings.Repeat("a", 40),
1025
 		HeadSHA:        strings.Repeat("a", 40),
932
 		HeadRef:        "refs/heads/trunk",
1026
 		HeadRef:        "refs/heads/trunk",
933
-		EventKind:      trigger.EventPush,
1027
+		EventKind:      event,
934
-		EventPayload:   map[string]any{"ref": "refs/heads/trunk"},
1028
+		EventPayload:   payload,
935
 		ActorUserID:    userID,
1029
 		ActorUserID:    userID,
936
-		TriggerEventID: "push:test",
1030
+		TriggerEventID: "runner-api-test:" + string(event),
937
 		Workflow:       wf,
1031
 		Workflow:       wf,
938
 	})
1032
 	})
939
 	if err != nil {
1033
 	if err != nil {
internal/web/handlers/repo/actions_dispatch.gomodified
@@ -18,6 +18,7 @@ import (
18
 
18
 
19
 	"github.com/tenseleyFlow/shithub/internal/actions/dispatch"
19
 	"github.com/tenseleyFlow/shithub/internal/actions/dispatch"
20
 	actionsevent "github.com/tenseleyFlow/shithub/internal/actions/event"
20
 	actionsevent "github.com/tenseleyFlow/shithub/internal/actions/event"
21
+	actionspolicy "github.com/tenseleyFlow/shithub/internal/actions/policy"
21
 	"github.com/tenseleyFlow/shithub/internal/actions/trigger"
22
 	"github.com/tenseleyFlow/shithub/internal/actions/trigger"
22
 	"github.com/tenseleyFlow/shithub/internal/actions/workflow"
23
 	"github.com/tenseleyFlow/shithub/internal/actions/workflow"
23
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
24
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
@@ -153,6 +154,17 @@ func (h *Handlers) repoActionsDispatch(w http.ResponseWriter, r *http.Request) {
153
 	actorID := viewer.ID // 0 if anonymous, but RequireUser is in front of this route
154
 	actorID := viewer.ID // 0 if anonymous, but RequireUser is in front of this route
154
 
155
 
155
 	payload := actionsevent.WorkflowDispatch(inputs)
156
 	payload := actionsevent.WorkflowDispatch(inputs)
157
+	decision, err := actionspolicy.EvaluateTrigger(r.Context(), actionspolicy.Deps{Pool: h.d.Pool}, actionspolicy.TriggerRequest{
158
+		Repo:        row,
159
+		EventKind:   string(trigger.EventWorkflowDispatch),
160
+		ActorUserID: actorID,
161
+	})
162
+	if err != nil || !decision.Allow {
163
+		h.d.Logger.WarnContext(r.Context(), "actions dispatch: blocked by actions policy",
164
+			"repo_id", row.ID, "workflow_file", file, "reason", decision.Reason, "error", err)
165
+		h.d.Render.HTTPError(w, r, http.StatusForbidden, "Actions are not allowed to run for this repository.")
166
+		return
167
+	}
156
 	if _, err := trigger.Enqueue(r.Context(), trigger.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, trigger.EnqueueParams{
168
 	if _, err := trigger.Enqueue(r.Context(), trigger.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, trigger.EnqueueParams{
157
 		RepoID:         row.ID,
169
 		RepoID:         row.ID,
158
 		WorkflowFile:   file,
170
 		WorkflowFile:   file,
internal/worker/jobs/pr_jobs.gomodified
@@ -16,6 +16,7 @@ import (
16
 	"github.com/tenseleyFlow/shithub/internal/actions/trigger"
16
 	"github.com/tenseleyFlow/shithub/internal/actions/trigger"
17
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
17
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
18
 	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
18
 	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
19
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
19
 	"github.com/tenseleyFlow/shithub/internal/pulls"
20
 	"github.com/tenseleyFlow/shithub/internal/pulls"
20
 	pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
21
 	pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
21
 	gitops "github.com/tenseleyFlow/shithub/internal/repos/git"
22
 	gitops "github.com/tenseleyFlow/shithub/internal/repos/git"
@@ -128,12 +129,9 @@ func resolveGitDirForPR(ctx context.Context, pool *pgxpool.Pool, rfs *storage.Re
128
 // enqueuePRActionsTrigger fans out a workflow:trigger job for a PR
129
 // enqueuePRActionsTrigger fans out a workflow:trigger job for a PR
129
 // state transition (action ∈ {"opened", "synchronize"} for v1).
130
 // state transition (action ∈ {"opened", "synchronize"} for v1).
130
 //
131
 //
131
-// Collaborator gate (S41b spec §"Pitfalls"): "default for v1: trigger
132
+// Trust gate: the trigger handler evaluates the PR author through
132
-// on PR only when the PR is from a collaborator." We take a
133
+// internal/auth/policy. Trusted same-repo collaborators run immediately;
133
-// conservative approach — actor must be the repo's owning user.
134
+// untrusted PRs are queued in an approval-required state when policy allows.
134
-// Org-member triggers and explicit-collaborator triggers are parked
135
-// behind a TODO; expanding requires a richer policy lookup that the
136
-// worker context doesn't easily reach today.
137
 func enqueuePRActionsTrigger(ctx context.Context, deps PRJobsDeps, prID int64, action string) error {
135
 func enqueuePRActionsTrigger(ctx context.Context, deps PRJobsDeps, prID int64, action string) error {
138
 	pr, err := pullsdb.New().GetPullRequestByIssueID(ctx, deps.Pool, prID)
136
 	pr, err := pullsdb.New().GetPullRequestByIssueID(ctx, deps.Pool, prID)
139
 	if err != nil {
137
 	if err != nil {
@@ -148,20 +146,17 @@ func enqueuePRActionsTrigger(ctx context.Context, deps PRJobsDeps, prID int64, a
148
 		return fmt.Errorf("load repo: %w", err)
146
 		return fmt.Errorf("load repo: %w", err)
149
 	}
147
 	}
150
 
148
 
151
-	// Collaborator gate. v1: actor must be the repo's owner-user.
149
+	if !issue.AuthorUserID.Valid {
152
-	// External-PR + org-member triggers parked.
150
+		deps.Logger.InfoContext(ctx, "pr: skipping workflow:trigger (missing author)",
153
-	if !repo.OwnerUserID.Valid || !issue.AuthorUserID.Valid ||
154
-		repo.OwnerUserID.Int64 != issue.AuthorUserID.Int64 {
155
-		deps.Logger.InfoContext(ctx, "pr: skipping workflow:trigger (non-collaborator PR)",
156
 			"pr_id", prID, "action", action)
151
 			"pr_id", prID, "action", action)
157
 		return nil
152
 		return nil
158
 	}
153
 	}
159
 
154
 
160
-	owner, err := usersdb.New().GetUserByID(ctx, deps.Pool, repo.OwnerUserID.Int64)
155
+	ownerLogin, err := resolvePRActionsOwnerLogin(ctx, deps.Pool, repo)
161
 	if err != nil {
156
 	if err != nil {
162
 		return fmt.Errorf("load owner: %w", err)
157
 		return fmt.Errorf("load owner: %w", err)
163
 	}
158
 	}
164
-	gitDir, err := deps.RepoFS.RepoPath(owner.Username, repo.Name)
159
+	gitDir, err := deps.RepoFS.RepoPath(ownerLogin, repo.Name)
165
 	if err != nil {
160
 	if err != nil {
166
 		return fmt.Errorf("repo path: %w", err)
161
 		return fmt.Errorf("repo path: %w", err)
167
 	}
162
 	}
@@ -177,7 +172,10 @@ func enqueuePRActionsTrigger(ctx context.Context, deps PRJobsDeps, prID int64, a
177
 		changed = nil
172
 		changed = nil
178
 	}
173
 	}
179
 
174
 
180
-	authorLogin := owner.Username
175
+	authorLogin := ""
176
+	if u, err := usersdb.New().GetUserByID(ctx, deps.Pool, issue.AuthorUserID.Int64); err == nil {
177
+		authorLogin = u.Username
178
+	}
181
 	payload := actionsevent.PullRequest(
179
 	payload := actionsevent.PullRequest(
182
 		action, issue.Number, issue.Title,
180
 		action, issue.Number, issue.Title,
183
 		actionsevent.PRRef{Ref: pr.HeadRef, SHA: pr.HeadOid},
181
 		actionsevent.PRRef{Ref: pr.HeadRef, SHA: pr.HeadOid},
@@ -203,3 +201,21 @@ func enqueuePRActionsTrigger(ctx context.Context, deps PRJobsDeps, prID int64, a
203
 	}
201
 	}
204
 	return nil
202
 	return nil
205
 }
203
 }
204
+
205
+func resolvePRActionsOwnerLogin(ctx context.Context, pool *pgxpool.Pool, repo reposdb.Repo) (string, error) {
206
+	if repo.OwnerUserID.Valid {
207
+		u, err := usersdb.New().GetUserByID(ctx, pool, repo.OwnerUserID.Int64)
208
+		if err != nil {
209
+			return "", err
210
+		}
211
+		return u.Username, nil
212
+	}
213
+	if repo.OwnerOrgID.Valid {
214
+		o, err := orgsdb.New().GetOrgByID(ctx, pool, repo.OwnerOrgID.Int64)
215
+		if err != nil {
216
+			return "", err
217
+		}
218
+		return o.Slug, nil
219
+	}
220
+	return "", errors.New("repo has neither owner_user_id nor owner_org_id")
221
+}