Go · 4891 bytes Raw Blame History
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 }
159