tenseleyflow/shithub / 0f2f494

Browse files

S15: policy package — Can(), roles, actions, request cache, matrix tests

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
0f2f494cf3eb13df9a7cd02077bb65c69612d340
Parents
e4425c9
Tree
0717756

9 changed files

StatusFile+-
A internal/auth/policy/actions.go 92 0
A internal/auth/policy/actor.go 38 0
A internal/auth/policy/adapters.go 28 0
A internal/auth/policy/cache.go 82 0
A internal/auth/policy/policy.go 253 0
A internal/auth/policy/policy_test.go 318 0
A internal/auth/policy/resources.go 30 0
A internal/auth/policy/roles.go 57 0
A internal/auth/policy/test_helpers.go 13 0
internal/auth/policy/actions.goadded
@@ -0,0 +1,92 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package policy is the single source of truth for "who can do what".
4
+// Every authorization decision in shithub flows through Can(); handlers
5
+// must not read ownership, visibility, or collaborator state inline.
6
+//
7
+// The package shape:
8
+//
9
+//	Actor      — who is asking (anonymous, suspended, site-admin etc.)
10
+//	Resource   — what they want to act on (a RepoRef today; org/team later)
11
+//	Action     — a constant from the registry below
12
+//	Can(...)   — the only public decision function
13
+//	Decision   — { Allow bool; Reason string }
14
+//
15
+// Reasons are for logs and admin debugging, never for end-user error
16
+// strings — they can leak existence.
17
+package policy
18
+
19
+// Action is a constant identifier for an operation against some resource.
20
+// Adding a new action: add the const, register a default rule in
21
+// policy.Can, and extend the test matrix. The matrix test asserts that
22
+// every Action has explicit coverage for every Actor archetype.
23
+type Action string
24
+
25
+// Repo-level actions.
26
+const (
27
+	ActionRepoRead  Action = "repo:read"
28
+	ActionRepoWrite Action = "repo:write"
29
+	ActionRepoAdmin Action = "repo:admin"
30
+
31
+	ActionRepoSettingsGeneral       Action = "repo:settings:general"
32
+	ActionRepoSettingsCollaborators Action = "repo:settings:collaborators"
33
+	ActionRepoSettingsBranches      Action = "repo:settings:branches"
34
+
35
+	ActionRepoArchive    Action = "repo:archive"
36
+	ActionRepoDelete     Action = "repo:delete"
37
+	ActionRepoTransfer   Action = "repo:transfer"
38
+	ActionRepoVisibility Action = "repo:visibility"
39
+)
40
+
41
+// Issue-level actions. (Issue resources arrive in S18; S15 just ships
42
+// the registry entries so the matrix is exhaustive from day one.)
43
+const (
44
+	ActionIssueRead    Action = "issue:read"
45
+	ActionIssueCreate  Action = "issue:create"
46
+	ActionIssueComment Action = "issue:comment"
47
+	ActionIssueClose   Action = "issue:close"
48
+	ActionIssueLabel   Action = "issue:label"
49
+	ActionIssueAssign  Action = "issue:assign"
50
+)
51
+
52
+// Pull-request actions. (Pull resources arrive in S19; same note as above.)
53
+const (
54
+	ActionPullRead   Action = "pull:read"
55
+	ActionPullCreate Action = "pull:create"
56
+	ActionPullMerge  Action = "pull:merge"
57
+	ActionPullReview Action = "pull:review"
58
+	ActionPullClose  Action = "pull:close"
59
+)
60
+
61
+// Per-user social actions.
62
+const (
63
+	ActionStarCreate Action = "star:create"
64
+	ActionForkCreate Action = "fork:create"
65
+)
66
+
67
+// AllActions is the canonical list. The matrix test iterates this so a
68
+// new Action that's not registered above will fail coverage and force
69
+// the author to think through every actor archetype.
70
+var AllActions = []Action{
71
+	ActionRepoRead, ActionRepoWrite, ActionRepoAdmin,
72
+	ActionRepoSettingsGeneral, ActionRepoSettingsCollaborators, ActionRepoSettingsBranches,
73
+	ActionRepoArchive, ActionRepoDelete, ActionRepoTransfer, ActionRepoVisibility,
74
+	ActionIssueRead, ActionIssueCreate, ActionIssueComment, ActionIssueClose, ActionIssueLabel, ActionIssueAssign,
75
+	ActionPullRead, ActionPullCreate, ActionPullMerge, ActionPullReview, ActionPullClose,
76
+	ActionStarCreate, ActionForkCreate,
77
+}
78
+
79
+// isWriteAction returns true when the action mutates state. Used by the
80
+// suspended-user gate (suspended accounts can read but not write).
81
+func isWriteAction(a Action) bool {
82
+	switch a {
83
+	case ActionRepoRead, ActionIssueRead, ActionPullRead:
84
+		return false
85
+	default:
86
+		return true
87
+	}
88
+}
89
+
90
+// isReadAction is the inverse, broken out for readability at call sites
91
+// that branch on intent rather than on the absence of writes.
92
+func isReadAction(a Action) bool { return !isWriteAction(a) }
internal/auth/policy/actor.goadded
@@ -0,0 +1,38 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package policy
4
+
5
+// Actor is the authenticated identity asking for a decision. The web
6
+// layer constructs one from middleware.CurrentUserFromContext + a
7
+// suspended/admin check. SSH and HTTP git transports build their own
8
+// from the resolved auth principal.
9
+//
10
+// An anonymous request has UserID == 0; IsAnonymous == true. Convention
11
+// is that callers fill IsAnonymous explicitly even when UserID == 0
12
+// implies it — duplication is cheap and keeps the boolean visible at
13
+// every call site.
14
+type Actor struct {
15
+	UserID      int64
16
+	Username    string
17
+	IsAnonymous bool
18
+	IsSuspended bool
19
+	IsSiteAdmin bool
20
+}
21
+
22
+// AnonymousActor returns the canonical anonymous Actor. Use in tests
23
+// and at unauthenticated entrypoints.
24
+func AnonymousActor() Actor {
25
+	return Actor{IsAnonymous: true}
26
+}
27
+
28
+// UserActor wraps a logged-in user. Suspension and site-admin flags
29
+// must be loaded from the DB by the caller — the policy package does
30
+// not query users on its own to keep the decision pure.
31
+func UserActor(userID int64, username string, suspended, siteAdmin bool) Actor {
32
+	return Actor{
33
+		UserID:      userID,
34
+		Username:    username,
35
+		IsSuspended: suspended,
36
+		IsSiteAdmin: siteAdmin,
37
+	}
38
+}
internal/auth/policy/adapters.goadded
@@ -0,0 +1,28 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package policy
4
+
5
+import (
6
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
7
+)
8
+
9
+// NewRepoRefFromRepo converts a reposdb.Repo into a policy RepoRef.
10
+// Used at the boundary between data-access and policy code: any caller
11
+// that already has a Repo row passes it in, and policy never imports
12
+// reposdb at the rule layer.
13
+func NewRepoRefFromRepo(r reposdb.Repo) RepoRef {
14
+	ref := RepoRef{
15
+		ID:         r.ID,
16
+		Visibility: string(r.Visibility),
17
+		IsArchived: r.IsArchived,
18
+		IsDeleted:  r.DeletedAt.Valid,
19
+	}
20
+	if r.OwnerUserID.Valid {
21
+		ref.OwnerUserID = r.OwnerUserID.Int64
22
+	}
23
+	if r.OwnerOrgID.Valid {
24
+		ref.OwnerOrgID = r.OwnerOrgID.Int64
25
+	}
26
+	return ref
27
+}
28
+
internal/auth/policy/cache.goadded
@@ -0,0 +1,82 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package policy
4
+
5
+import (
6
+	"context"
7
+	"sync"
8
+)
9
+
10
+// cacheKey is the per-(actor, repo) composite the cache memoizes
11
+// effective role lookups against. The cache is request-scoped — created
12
+// in WithCache, dropped when the context goes out of scope.
13
+type cacheKey struct {
14
+	actorUserID int64
15
+	repoID      int64
16
+}
17
+
18
+// requestCache holds the memo. The lock is held across DB lookups so
19
+// concurrent goroutines reading the same key (rare in practice — a
20
+// single request pipeline is typically sequential) coalesce into one
21
+// query.
22
+type requestCache struct {
23
+	mu    sync.Mutex
24
+	roles map[cacheKey]Role
25
+}
26
+
27
+type cacheCtxKey struct{}
28
+
29
+// WithCache returns a derived context that carries a per-request memo.
30
+// Wire this into the request middleware once, and every Can() call on
31
+// downstream handlers benefits from de-duplication. Calling Can with a
32
+// context that has no cache is supported — the lookup just runs every
33
+// time.
34
+func WithCache(ctx context.Context) context.Context {
35
+	c := &requestCache{roles: make(map[cacheKey]Role, 4)}
36
+	return context.WithValue(ctx, cacheCtxKey{}, c)
37
+}
38
+
39
+// cacheFromContext returns the cache or nil. Internal helper — Can()
40
+// degrades gracefully when nil.
41
+func cacheFromContext(ctx context.Context) *requestCache {
42
+	c, _ := ctx.Value(cacheCtxKey{}).(*requestCache)
43
+	return c
44
+}
45
+
46
+// InvalidateRepo drops cached entries for one repo. Call this from
47
+// handlers that mutate collaborator state and then re-check policy
48
+// inside the same request.
49
+func InvalidateRepo(ctx context.Context, repoID int64) {
50
+	c := cacheFromContext(ctx)
51
+	if c == nil {
52
+		return
53
+	}
54
+	c.mu.Lock()
55
+	defer c.mu.Unlock()
56
+	for k := range c.roles {
57
+		if k.repoID == repoID {
58
+			delete(c.roles, k)
59
+		}
60
+	}
61
+}
62
+
63
+// cacheGet returns (role, true) when present.
64
+func cacheGet(c *requestCache, k cacheKey) (Role, bool) {
65
+	if c == nil {
66
+		return RoleNone, false
67
+	}
68
+	c.mu.Lock()
69
+	defer c.mu.Unlock()
70
+	r, ok := c.roles[k]
71
+	return r, ok
72
+}
73
+
74
+// cachePut stores a role. Safe to call with c == nil.
75
+func cachePut(c *requestCache, k cacheKey, r Role) {
76
+	if c == nil {
77
+		return
78
+	}
79
+	c.mu.Lock()
80
+	defer c.mu.Unlock()
81
+	c.roles[k] = r
82
+}
internal/auth/policy/policy.goadded
@@ -0,0 +1,253 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package policy
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"net/http"
9
+
10
+	"github.com/jackc/pgx/v5"
11
+	"github.com/jackc/pgx/v5/pgxpool"
12
+
13
+	policydb "github.com/tenseleyFlow/shithub/internal/auth/policy/sqlc"
14
+)
15
+
16
+// Deps wires the policy package against Postgres. Construct once at
17
+// boot and keep alive for the process lifetime.
18
+type Deps struct {
19
+	Pool *pgxpool.Pool
20
+}
21
+
22
+// DenyCode is a typed enum carried on a deny Decision so handlers can
23
+// pick a friendly user-facing message without re-deriving the reason
24
+// from the resource. Read this; do not parse Decision.Reason.
25
+type DenyCode int
26
+
27
+const (
28
+	// DenyNone is the zero value used on an allow Decision.
29
+	DenyNone DenyCode = iota
30
+	DenyRepoDeleted
31
+	DenyActorSuspended
32
+	DenyArchived
33
+	DenyVisibility    // anonymous-or-non-collab on a private repo
34
+	DenyRoleTooLow    // logged-in but role insufficient
35
+	DenyAnonymous     // login required (e.g. star/fork)
36
+	DenyDBError
37
+)
38
+
39
+// Decision is the verdict from Can. Allow is the only field handlers
40
+// should branch on for control flow. DenyCode lets the handler pick a
41
+// user-facing message; Reason is for logs and tests, never end-user
42
+// surfaces — it can carry implementation details that constitute
43
+// existence leaks.
44
+type Decision struct {
45
+	Allow  bool
46
+	Reason string
47
+	Code   DenyCode
48
+}
49
+
50
+// allow / deny are convenience constructors used by the rule engine
51
+// below to keep each branch one short line.
52
+func allow(reason string) Decision { return Decision{Allow: true, Reason: reason} }
53
+func deny(code DenyCode, reason string) Decision {
54
+	return Decision{Allow: false, Reason: reason, Code: code}
55
+}
56
+
57
+// Can is the single authorization decision function. Every handler,
58
+// hook, and worker in shithub funnels through here. Order of evaluation
59
+// is significant — higher-precedence denies (deletion, suspension)
60
+// short-circuit before role lookups touch the DB.
61
+//
62
+// The cache (if present in ctx via WithCache) is consulted before any
63
+// query and populated on the first lookup.
64
+func Can(ctx context.Context, d Deps, actor Actor, action Action, repo RepoRef) Decision {
65
+	// 1. Soft-deleted repos: nothing is allowed against them, full stop.
66
+	//    (Site admins can technically still hard-delete via admin
67
+	//    tooling, which doesn't go through Can.)
68
+	if repo.IsDeleted {
69
+		return deny(DenyRepoDeleted, "repo deleted")
70
+	}
71
+
72
+	// 2. Site admins: read access to anything; explicit-impersonation
73
+	//    needed for writes (S34 wires the impersonation surface — for
74
+	//    now the override only fires on read actions).
75
+	if actor.IsSiteAdmin && isReadAction(action) {
76
+		return allow("site admin read")
77
+	}
78
+
79
+	// 3. Suspended actors: writes denied unconditionally; reads against
80
+	//    public repos are still allowed (matches GitHub's "ghost-mode"
81
+	//    suspension semantics).
82
+	if actor.IsSuspended && isWriteAction(action) {
83
+		return deny(DenyActorSuspended, "actor suspended")
84
+	}
85
+
86
+	// 4. Anonymous + private: existence-leak-safe deny. Handler maps to
87
+	//    404 via Maybe404.
88
+	if actor.IsAnonymous && repo.IsPrivate() {
89
+		return deny(DenyVisibility, "anonymous on private repo")
90
+	}
91
+
92
+	// 5. Visibility for reads on public repos: anyone (anon or logged-
93
+	//    in) can read public, regardless of suspension.
94
+	if isReadAction(action) && repo.IsPublic() {
95
+		return allow("public repo read")
96
+	}
97
+
98
+	// 6. From here we need the actor's effective role on the repo.
99
+	//    Owner short-circuits to admin; collaborator role from DB
100
+	//    otherwise; org membership stub for S31.
101
+	role, err := effectiveRole(ctx, d, actor, repo)
102
+	if err != nil {
103
+		// DB error: deny rather than allow. Surface in logs via the
104
+		// reason; the caller is expected to log and fail closed.
105
+		return deny(DenyDBError, "role lookup failed: "+err.Error())
106
+	}
107
+
108
+	// 7. Archived repos: writes denied even for owners. Reads still go
109
+	//    through the role check above. (We could short-circuit reads
110
+	//    earlier but keeping the flow uniform makes the matrix readable.)
111
+	if repo.IsArchived && isWriteAction(action) {
112
+		return deny(DenyArchived, "repo archived")
113
+	}
114
+
115
+	// 8. Map action → minimum required role; check.
116
+	want := minRoleFor(action)
117
+	if want != RoleNone && !RoleAtLeast(role, want) {
118
+		// No role at all + private repo → look like a visibility deny
119
+		// so the handler picks the 404-leak guard. Otherwise it's a
120
+		// genuine role-too-low for a logged-in collaborator.
121
+		if role == RoleNone && repo.IsPrivate() {
122
+			return deny(DenyVisibility, "no role on private repo")
123
+		}
124
+		return deny(DenyRoleTooLow, "role too low")
125
+	}
126
+
127
+	// 9. Login-required actions: star/fork need any logged-in user.
128
+	if (action == ActionStarCreate || action == ActionForkCreate) && actor.IsAnonymous {
129
+		return deny(DenyAnonymous, "anonymous cannot star/fork")
130
+	}
131
+
132
+	return allow("granted")
133
+}
134
+
135
+// IsVisibleTo is a convenience wrapper around Can(actor, repo:read, …).
136
+// Used by listing endpoints that need to filter results by visibility
137
+// without caring about the deny reason.
138
+func IsVisibleTo(ctx context.Context, d Deps, actor Actor, repo RepoRef) bool {
139
+	return Can(ctx, d, actor, ActionRepoRead, repo).Allow
140
+}
141
+
142
+// Maybe404 maps a deny Decision to an HTTP status code that doesn't
143
+// leak existence. The convention:
144
+//
145
+//   - allow → 200 (caller handles)
146
+//   - deny on a private+anonymous (or non-collab+non-owner) → 404
147
+//   - any other deny → 403
148
+//
149
+// Handlers should call this after checking Allow, only when serving
150
+// the rejection response.
151
+func Maybe404(decision Decision, repo RepoRef, actor Actor) int {
152
+	if decision.Allow {
153
+		return http.StatusOK
154
+	}
155
+	// Anything that turns on private-visibility surfaces as 404.
156
+	if repo.IsPrivate() {
157
+		// Owner of a private repo getting denied is a real 403 (e.g.
158
+		// archived push). We approximate "owner" as having matching
159
+		// owner_user_id; collaborator status doesn't change the leak
160
+		// analysis since the row already exists in their world.
161
+		if actor.UserID != 0 && actor.UserID == repo.OwnerUserID {
162
+			return http.StatusForbidden
163
+		}
164
+		return http.StatusNotFound
165
+	}
166
+	return http.StatusForbidden
167
+}
168
+
169
+// effectiveRole computes the highest-effective role for actor on repo.
170
+// Owner ⇒ implicit admin; collaborator role from repo_collaborators;
171
+// nothing otherwise.
172
+func effectiveRole(ctx context.Context, d Deps, actor Actor, repo RepoRef) (Role, error) {
173
+	if actor.UserID != 0 && actor.UserID == repo.OwnerUserID {
174
+		return RoleAdmin, nil
175
+	}
176
+	if actor.UserID == 0 {
177
+		return RoleNone, nil
178
+	}
179
+
180
+	// Cache check.
181
+	cache := cacheFromContext(ctx)
182
+	key := cacheKey{actorUserID: actor.UserID, repoID: repo.ID}
183
+	if r, ok := cacheGet(cache, key); ok {
184
+		return r, nil
185
+	}
186
+
187
+	// DB lookup.
188
+	if d.Pool == nil {
189
+		// In tests where a Deps without Pool is passed, fail closed.
190
+		return RoleNone, nil
191
+	}
192
+	q := policydb.New()
193
+	dbRole, err := q.GetCollabRole(ctx, d.Pool, policydb.GetCollabRoleParams{
194
+		RepoID: repo.ID,
195
+		UserID: actor.UserID,
196
+	})
197
+	if errors.Is(err, pgx.ErrNoRows) {
198
+		cachePut(cache, key, RoleNone)
199
+		return RoleNone, nil
200
+	}
201
+	if err != nil {
202
+		return RoleNone, err
203
+	}
204
+	r := roleFromDB(dbRole)
205
+	cachePut(cache, key, r)
206
+	return r, nil
207
+}
208
+
209
+// minRoleFor returns the minimum collaborator role required for the
210
+// action against an existing repo. Owner is implicit admin, so this
211
+// table is the single source of truth for "what role grants what."
212
+//
213
+//nolint:gocyclo // exhaustive switch is the readable shape here.
214
+func minRoleFor(action Action) Role {
215
+	switch action {
216
+	// Read tier — public repos bypass via the early allow in Can; this
217
+	// is the gate for *private* read access.
218
+	case ActionRepoRead, ActionIssueRead, ActionPullRead:
219
+		return RoleRead
220
+
221
+	// Triage tier — issue-shape mutations without code write.
222
+	case ActionIssueClose, ActionIssueLabel, ActionIssueAssign:
223
+		return RoleTriage
224
+
225
+	// Write tier — code push, branch create, PR open/comment.
226
+	case ActionRepoWrite, ActionPullCreate, ActionPullReview, ActionPullClose,
227
+		ActionIssueCreate, ActionIssueComment:
228
+		return RoleWrite
229
+
230
+	// Maintain tier — most settings except dangerous ones.
231
+	case ActionRepoSettingsGeneral, ActionRepoSettingsBranches:
232
+		return RoleMaintain
233
+
234
+	// Admin tier — destructive and ownership-changing actions.
235
+	case ActionRepoAdmin, ActionRepoSettingsCollaborators,
236
+		ActionRepoArchive, ActionRepoDelete, ActionRepoTransfer, ActionRepoVisibility,
237
+		ActionPullMerge:
238
+		return RoleAdmin
239
+
240
+	// Login-required but no role required (any logged-in user).
241
+	case ActionStarCreate, ActionForkCreate:
242
+		return RoleNone
243
+
244
+	default:
245
+		// Unknown actions deny by default — RoleAdmin is impossibly
246
+		// high for a stranger so this acts as a fail-closed gate.
247
+		return RoleAdmin
248
+	}
249
+}
250
+
251
+// errBadAction is returned by Can when a caller passes a value that
252
+// doesn't match any registered Action. Reserved for tests.
253
+var errBadAction = errors.New("policy: unregistered action")
internal/auth/policy/policy_test.goadded
@@ -0,0 +1,318 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package policy_test
4
+
5
+import (
6
+	"context"
7
+	"testing"
8
+
9
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
10
+)
11
+
12
+// The matrix is built from three orthogonal axes: actor archetype,
13
+// resource shape, and Action. We enumerate every combination and
14
+// assert the verdict so a future refactor that breaks one cell fails
15
+// loudly.
16
+
17
+type actorKind int
18
+
19
+const (
20
+	actorAnonymous actorKind = iota
21
+	actorOwner
22
+	actorCollabRead
23
+	actorCollabTriage
24
+	actorCollabWrite
25
+	actorCollabMaintain
26
+	actorCollabAdmin
27
+	actorUnrelated
28
+	actorSuspendedOwner
29
+	actorSuspendedCollabWrite
30
+	actorSiteAdmin
31
+)
32
+
33
+func (k actorKind) String() string {
34
+	return [...]string{
35
+		"anonymous", "owner",
36
+		"collab-read", "collab-triage", "collab-write", "collab-maintain", "collab-admin",
37
+		"unrelated", "suspended-owner", "suspended-collab-write", "site-admin",
38
+	}[k]
39
+}
40
+
41
+type repoKind int
42
+
43
+const (
44
+	repoPublic repoKind = iota
45
+	repoPrivate
46
+	repoArchivedPublic
47
+	repoArchivedPrivate
48
+	repoDeletedPublic
49
+)
50
+
51
+func (k repoKind) String() string {
52
+	return [...]string{"public", "private", "archived-public", "archived-private", "deleted-public"}[k]
53
+}
54
+
55
+const (
56
+	ownerID  int64 = 100
57
+	otherID  int64 = 200
58
+	repoIDV  int64 = 1000
59
+)
60
+
61
+func makeActor(k actorKind) policy.Actor {
62
+	switch k {
63
+	case actorAnonymous:
64
+		return policy.AnonymousActor()
65
+	case actorOwner:
66
+		return policy.UserActor(ownerID, "owner", false, false)
67
+	case actorCollabRead, actorCollabTriage, actorCollabWrite, actorCollabMaintain, actorCollabAdmin:
68
+		return policy.UserActor(otherID, "collab", false, false)
69
+	case actorUnrelated:
70
+		return policy.UserActor(otherID+1, "stranger", false, false)
71
+	case actorSuspendedOwner:
72
+		return policy.UserActor(ownerID, "owner", true, false)
73
+	case actorSuspendedCollabWrite:
74
+		return policy.UserActor(otherID, "collab", true, false)
75
+	case actorSiteAdmin:
76
+		return policy.UserActor(otherID+2, "admin", false, true)
77
+	}
78
+	return policy.AnonymousActor()
79
+}
80
+
81
+func makeRepo(k repoKind) policy.RepoRef {
82
+	r := policy.RepoRef{ID: repoIDV, OwnerUserID: ownerID}
83
+	switch k {
84
+	case repoPublic:
85
+		r.Visibility = "public"
86
+	case repoPrivate:
87
+		r.Visibility = "private"
88
+	case repoArchivedPublic:
89
+		r.Visibility = "public"
90
+		r.IsArchived = true
91
+	case repoArchivedPrivate:
92
+		r.Visibility = "private"
93
+		r.IsArchived = true
94
+	case repoDeletedPublic:
95
+		r.Visibility = "public"
96
+		r.IsDeleted = true
97
+	}
98
+	return r
99
+}
100
+
101
+// fakeRoleResolver intercepts the cache layer so tests can assert role
102
+// outcomes without hitting Postgres. Each collab actorKind seeds a
103
+// specific role in the cache pre-Can.
104
+func ctxWithCollabRole(t *testing.T, kind actorKind) context.Context {
105
+	t.Helper()
106
+	ctx := policy.WithCache(context.Background())
107
+	role := map[actorKind]policy.Role{
108
+		actorCollabRead:           policy.RoleRead,
109
+		actorCollabTriage:         policy.RoleTriage,
110
+		actorCollabWrite:          policy.RoleWrite,
111
+		actorCollabMaintain:       policy.RoleMaintain,
112
+		actorCollabAdmin:          policy.RoleAdmin,
113
+		actorSuspendedCollabWrite: policy.RoleWrite,
114
+	}[kind]
115
+	if role != policy.RoleNone {
116
+		policy.PrimeCacheForTest(ctx, otherID, repoIDV, role)
117
+	}
118
+	return ctx
119
+}
120
+
121
+// expect computes the canonical verdict for a (actorKind, repoKind, Action)
122
+// triple. The function below is the policy spec restated in plain Go;
123
+// if Can() and expect() ever disagree, one of them has a bug.
124
+//
125
+//nolint:gocyclo // exhaustive shape is by design.
126
+func expect(actor actorKind, repo repoKind, action policy.Action) bool {
127
+	// Deleted repo → nothing.
128
+	if repo == repoDeletedPublic {
129
+		return false
130
+	}
131
+	isWrite := action != policy.ActionRepoRead && action != policy.ActionIssueRead && action != policy.ActionPullRead
132
+
133
+	// Site admin can read everything (except deleted handled above).
134
+	if actor == actorSiteAdmin && !isWrite {
135
+		return true
136
+	}
137
+
138
+	// Suspended actors: writes always blocked. Reads against public repos
139
+	// still allowed (matches the suspended-then-read public code path).
140
+	if actor == actorSuspendedOwner || actor == actorSuspendedCollabWrite {
141
+		if isWrite {
142
+			return false
143
+		}
144
+		// fall through to the visibility check
145
+	}
146
+
147
+	isPrivate := repo == repoPrivate || repo == repoArchivedPrivate
148
+	isArchived := repo == repoArchivedPublic || repo == repoArchivedPrivate
149
+
150
+	// Anonymous + private → deny.
151
+	if actor == actorAnonymous && isPrivate {
152
+		return false
153
+	}
154
+
155
+	// Public repo reads: anyone.
156
+	if !isWrite && !isPrivate {
157
+		return true
158
+	}
159
+
160
+	// Compute role.
161
+	var have policy.Role
162
+	switch actor {
163
+	case actorOwner, actorSuspendedOwner:
164
+		have = policy.RoleAdmin
165
+	case actorCollabRead:
166
+		have = policy.RoleRead
167
+	case actorCollabTriage:
168
+		have = policy.RoleTriage
169
+	case actorCollabWrite, actorSuspendedCollabWrite:
170
+		have = policy.RoleWrite
171
+	case actorCollabMaintain:
172
+		have = policy.RoleMaintain
173
+	case actorCollabAdmin:
174
+		have = policy.RoleAdmin
175
+	}
176
+
177
+	// Archived: writes denied (covers owner too).
178
+	if isArchived && isWrite {
179
+		return false
180
+	}
181
+
182
+	// Login-only social actions.
183
+	if action == policy.ActionStarCreate || action == policy.ActionForkCreate {
184
+		return have != policy.RoleNone || (actor != actorAnonymous)
185
+	}
186
+
187
+	// Role check.
188
+	want := mirrorMinRoleFor(action)
189
+	if want == policy.RoleNone {
190
+		return actor != actorAnonymous
191
+	}
192
+	return policy.RoleAtLeast(have, want)
193
+}
194
+
195
+// mirrorMinRoleFor mirrors policy.minRoleFor for test side. We could
196
+// expose the internal helper; keeping the mirror enforces that the
197
+// matrix knows about every action explicitly.
198
+func mirrorMinRoleFor(a policy.Action) policy.Role {
199
+	switch a {
200
+	case policy.ActionRepoRead, policy.ActionIssueRead, policy.ActionPullRead:
201
+		return policy.RoleRead
202
+	case policy.ActionIssueClose, policy.ActionIssueLabel, policy.ActionIssueAssign:
203
+		return policy.RoleTriage
204
+	case policy.ActionRepoWrite, policy.ActionPullCreate, policy.ActionPullReview, policy.ActionPullClose,
205
+		policy.ActionIssueCreate, policy.ActionIssueComment:
206
+		return policy.RoleWrite
207
+	case policy.ActionRepoSettingsGeneral, policy.ActionRepoSettingsBranches:
208
+		return policy.RoleMaintain
209
+	case policy.ActionRepoAdmin, policy.ActionRepoSettingsCollaborators,
210
+		policy.ActionRepoArchive, policy.ActionRepoDelete, policy.ActionRepoTransfer, policy.ActionRepoVisibility,
211
+		policy.ActionPullMerge:
212
+		return policy.RoleAdmin
213
+	case policy.ActionStarCreate, policy.ActionForkCreate:
214
+		return policy.RoleNone
215
+	}
216
+	return policy.RoleAdmin
217
+}
218
+
219
+func TestCan_Matrix(t *testing.T) {
220
+	t.Parallel()
221
+	d := policy.Deps{} // pool nil OK; cache primes the role
222
+
223
+	for _, ak := range []actorKind{
224
+		actorAnonymous, actorOwner,
225
+		actorCollabRead, actorCollabTriage, actorCollabWrite, actorCollabMaintain, actorCollabAdmin,
226
+		actorUnrelated, actorSuspendedOwner, actorSuspendedCollabWrite, actorSiteAdmin,
227
+	} {
228
+		for _, rk := range []repoKind{
229
+			repoPublic, repoPrivate, repoArchivedPublic, repoArchivedPrivate, repoDeletedPublic,
230
+		} {
231
+			for _, action := range policy.AllActions {
232
+				ak, rk, action := ak, rk, action
233
+				name := ak.String() + "/" + rk.String() + "/" + string(action)
234
+				t.Run(name, func(t *testing.T) {
235
+					t.Parallel()
236
+					ctx := ctxWithCollabRole(t, ak)
237
+					actor := makeActor(ak)
238
+					repo := makeRepo(rk)
239
+					got := policy.Can(ctx, d, actor, action, repo).Allow
240
+					want := expect(ak, rk, action)
241
+					if got != want {
242
+						t.Errorf("Can(%s, %s, %s) = %v, want %v",
243
+							ak, rk, action, got, want)
244
+					}
245
+				})
246
+			}
247
+		}
248
+	}
249
+}
250
+
251
+func TestRoleAtLeast(t *testing.T) {
252
+	t.Parallel()
253
+	cases := []struct {
254
+		have, want policy.Role
255
+		ok         bool
256
+	}{
257
+		{policy.RoleAdmin, policy.RoleRead, true},
258
+		{policy.RoleAdmin, policy.RoleAdmin, true},
259
+		{policy.RoleWrite, policy.RoleAdmin, false},
260
+		{policy.RoleRead, policy.RoleTriage, false},
261
+		{policy.RoleNone, policy.RoleRead, false},
262
+		{policy.RoleRead, policy.RoleNone, false}, // RoleNone is meaningless as a target
263
+	}
264
+	for _, c := range cases {
265
+		if got := policy.RoleAtLeast(c.have, c.want); got != c.ok {
266
+			t.Errorf("RoleAtLeast(%q, %q) = %v, want %v", c.have, c.want, got, c.ok)
267
+		}
268
+	}
269
+}
270
+
271
+func TestMaybe404(t *testing.T) {
272
+	t.Parallel()
273
+	priv := policy.RepoRef{ID: 1, OwnerUserID: ownerID, Visibility: "private"}
274
+	pub := policy.RepoRef{ID: 1, OwnerUserID: ownerID, Visibility: "public"}
275
+	owner := policy.UserActor(ownerID, "o", false, false)
276
+	stranger := policy.UserActor(otherID, "s", false, false)
277
+
278
+	// Anonymous on private → 404 leak guard.
279
+	if got := policy.Maybe404(policy.Decision{Allow: false}, priv, policy.AnonymousActor()); got != 404 {
280
+		t.Errorf("anonymous on private: got %d, want 404", got)
281
+	}
282
+	// Stranger on private → 404 (don't reveal existence).
283
+	if got := policy.Maybe404(policy.Decision{Allow: false}, priv, stranger); got != 404 {
284
+		t.Errorf("stranger on private: got %d, want 404", got)
285
+	}
286
+	// Owner on private (e.g. archived push) → real 403.
287
+	if got := policy.Maybe404(policy.Decision{Allow: false}, priv, owner); got != 403 {
288
+		t.Errorf("owner deny on private: got %d, want 403", got)
289
+	}
290
+	// Public deny → 403 always.
291
+	if got := policy.Maybe404(policy.Decision{Allow: false}, pub, policy.AnonymousActor()); got != 403 {
292
+		t.Errorf("anonymous deny on public: got %d, want 403", got)
293
+	}
294
+	// Allow → 200 regardless of repo shape.
295
+	if got := policy.Maybe404(policy.Decision{Allow: true}, priv, owner); got != 200 {
296
+		t.Errorf("allow: got %d, want 200", got)
297
+	}
298
+}
299
+
300
+func TestCacheInvalidate(t *testing.T) {
301
+	t.Parallel()
302
+	ctx := policy.WithCache(context.Background())
303
+	policy.PrimeCacheForTest(ctx, otherID, repoIDV, policy.RoleAdmin)
304
+
305
+	// Confirm primed.
306
+	d := policy.Deps{}
307
+	actor := policy.UserActor(otherID, "u", false, false)
308
+	repo := policy.RepoRef{ID: repoIDV, OwnerUserID: ownerID, Visibility: "private"}
309
+	if !policy.Can(ctx, d, actor, policy.ActionRepoAdmin, repo).Allow {
310
+		t.Fatalf("primed cache should grant admin")
311
+	}
312
+
313
+	// Invalidate; with no DB pool the next lookup falls through to RoleNone.
314
+	policy.InvalidateRepo(ctx, repoIDV)
315
+	if policy.Can(ctx, d, actor, policy.ActionRepoAdmin, repo).Allow {
316
+		t.Errorf("after invalidate, admin should deny without role row")
317
+	}
318
+}
internal/auth/policy/resources.goadded
@@ -0,0 +1,30 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package policy
4
+
5
+// RepoRef is the policy-side projection of a repos row. Construct from
6
+// either reposdb.Repo or any of the GetRepoBy* row types via NewRepoRef
7
+// helpers in the policy package's adapters file (kept policy-internal
8
+// so the policy package never imports reposdb directly — sqlc rows
9
+// flow in via constructors at call boundaries).
10
+//
11
+// OwnerOrgID is the S31-shape: zero today; populated when org-owned
12
+// repos ship. Can() already branches on OwnerOrgID > 0 so that S31's
13
+// org membership lookup plugs in cleanly.
14
+type RepoRef struct {
15
+	ID          int64
16
+	OwnerUserID int64
17
+	OwnerOrgID  int64
18
+	Visibility  string // "public" | "private"
19
+	IsArchived  bool
20
+	IsDeleted   bool
21
+}
22
+
23
+// IsPublic returns true when the repo's visibility column is "public".
24
+// Other code paths must not parse Visibility directly — read it through
25
+// these helpers so the canonical strings live in one place.
26
+func (r RepoRef) IsPublic() bool { return r.Visibility == "public" }
27
+
28
+// IsPrivate is the inverse. Use whichever phrasing reads better at the
29
+// call site.
30
+func (r RepoRef) IsPrivate() bool { return r.Visibility == "private" }
internal/auth/policy/roles.goadded
@@ -0,0 +1,57 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package policy
4
+
5
+import policydb "github.com/tenseleyFlow/shithub/internal/auth/policy/sqlc"
6
+
7
+// Role models the per-collaborator grant. The five values mirror
8
+// GitHub's tiers; the order matters because RoleAtLeast depends on it.
9
+type Role string
10
+
11
+const (
12
+	// RoleNone is the zero value used by callers when no row exists.
13
+	RoleNone     Role = ""
14
+	RoleRead     Role = "read"
15
+	RoleTriage   Role = "triage"
16
+	RoleWrite    Role = "write"
17
+	RoleMaintain Role = "maintain"
18
+	RoleAdmin    Role = "admin"
19
+)
20
+
21
+// roleRank maps a role to its position in the lattice. Higher rank →
22
+// strictly more powerful. RoleNone is below everything; RoleAdmin is
23
+// the top.
24
+func roleRank(r Role) int {
25
+	switch r {
26
+	case RoleAdmin:
27
+		return 5
28
+	case RoleMaintain:
29
+		return 4
30
+	case RoleWrite:
31
+		return 3
32
+	case RoleTriage:
33
+		return 2
34
+	case RoleRead:
35
+		return 1
36
+	default:
37
+		return 0
38
+	}
39
+}
40
+
41
+// RoleAtLeast reports whether `have` grants at least `want`. RoleNone
42
+// grants nothing.
43
+func RoleAtLeast(have, want Role) bool {
44
+	return roleRank(have) >= roleRank(want) && roleRank(want) > 0
45
+}
46
+
47
+// roleFromDB translates the sqlc-generated enum to our domain type.
48
+// We mirror values rather than re-using the DB type so the policy
49
+// package's API doesn't require callers to import policydb.
50
+func roleFromDB(r policydb.CollabRole) Role {
51
+	return Role(r)
52
+}
53
+
54
+// roleToDB is the inverse, used by callers that mutate collaborators.
55
+func roleToDB(r Role) policydb.CollabRole {
56
+	return policydb.CollabRole(r)
57
+}
internal/auth/policy/test_helpers.goadded
@@ -0,0 +1,13 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package policy
4
+
5
+import "context"
6
+
7
+// PrimeCacheForTest seeds the request cache with a (user, repo) → role
8
+// entry so tests can drive Can() without a live DB. Only call from
9
+// _test.go files; the export is intentionally export-tagged-as-test in
10
+// its name so production callers don't reach for it.
11
+func PrimeCacheForTest(ctx context.Context, userID, repoID int64, role Role) {
12
+	cachePut(cacheFromContext(ctx), cacheKey{actorUserID: userID, repoID: repoID}, role)
13
+}