Documentation
¶
Overview ¶
Package policy is the single source of truth for "who can do what". Every authorization decision in shithub flows through Can(); handlers must not read ownership, visibility, or collaborator state inline.
The package shape:
Actor — who is asking (anonymous, suspended, site-admin etc.)
Resource — what they want to act on (a RepoRef today; org/team later)
Action — a constant from the registry below
Can(...) — the only public decision function
Decision — { Allow bool; Reason string }
Reasons are for logs and admin debugging, never for end-user error strings — they can leak existence.
Index ¶
- Variables
- func HasRoleAtLeast(ctx context.Context, d Deps, actor Actor, repo RepoRef, want Role) bool
- func InvalidateRepo(ctx context.Context, repoID int64)
- func IsVisibleTo(ctx context.Context, d Deps, actor Actor, repo RepoRef) bool
- func Maybe404(decision Decision, repo RepoRef, actor Actor) int
- func PrimeCacheForTest(ctx context.Context, userID, repoID int64, role Role)
- func RoleAtLeast(have, want Role) bool
- func VisibilityPredicate(actor Actor, tableAlias string, startPlaceholder int) (clause string, args []any)
- func WithCache(ctx context.Context) context.Context
- type Action
- type Actor
- type Decision
- type DenyCode
- type Deps
- type RepoRef
- type Role
Constants ¶
This section is empty.
Variables ¶
var AllActions = []Action{ ActionRepoRead, ActionRepoWrite, ActionRepoAdmin, ActionRepoSettingsGeneral, ActionRepoSettingsCollaborators, ActionRepoSettingsBranches, ActionRepoArchive, ActionRepoDelete, ActionRepoTransfer, ActionRepoVisibility, ActionIssueRead, ActionIssueCreate, ActionIssueComment, ActionIssueClose, ActionIssueLabel, ActionIssueAssign, ActionPullRead, ActionPullCreate, ActionPullMerge, ActionPullReview, ActionPullClose, ActionStarCreate, ActionForkCreate, ActionWatchSet, }
AllActions is the canonical list. The matrix test iterates this so a new Action that's not registered above will fail coverage and force the author to think through every actor archetype.
Functions ¶
func HasRoleAtLeast ¶
HasRoleAtLeast is the convenience shorthand for the common case: "does this actor hold at least `want` on this repo?" Wraps EffectiveRole + RoleAtLeast.
func InvalidateRepo ¶
InvalidateRepo drops cached entries for one repo. Call this from handlers that mutate collaborator state and then re-check policy inside the same request.
func IsVisibleTo ¶
IsVisibleTo is a convenience wrapper around Can(actor, repo:read, …). Used by listing endpoints that need to filter results by visibility without caring about the deny reason.
func Maybe404 ¶
Maybe404 maps a deny Decision to an HTTP status code that doesn't leak existence. The convention:
- allow → 200 (caller handles)
- deny on a private+anonymous (or non-collab+non-owner) → 404
- any other deny → 403
Handlers should call this after checking Allow, only when serving the rejection response.
func PrimeCacheForTest ¶
PrimeCacheForTest seeds the request cache with a (user, repo) → role entry so tests can drive Can() without a live DB. Only call from _test.go files; the export is intentionally export-tagged-as-test in its name so production callers don't reach for it.
func RoleAtLeast ¶
RoleAtLeast reports whether `have` grants at least `want`. RoleNone grants nothing.
func VisibilityPredicate ¶
func VisibilityPredicate(actor Actor, tableAlias string, startPlaceholder int) (clause string, args []any)
VisibilityPredicate returns a SQL WHERE-clause fragment plus its bind args that filters rows from a `repos` table reference (or a table that joins to repos via a column named like `repo_id`/`r.id`) to those visible to the actor.
The fragment is parameterised against the supplied placeholder offset so callers can splice it into queries that already bind other parameters. Returns:
clause — SQL fragment ready to drop after `WHERE` or `AND`.
args — the values for the placeholders inside `clause`, in
order from `$<startPlaceholder>` upward.
`tableAlias` is the alias the caller used for the `repos` table (e.g. "r" for `FROM repos r`). The fragment references `<tableAlias>.visibility`, `<tableAlias>.owner_user_id`, `<tableAlias>.deleted_at`, and `<tableAlias>.id` as needed.
Visibility rules (mirror policy.Can step 4–6):
- Soft-deleted repos are always excluded.
- Public repos visible to anyone.
- Private repos visible only to: owner, or any user with a row in `repo_collaborators` (any role).
Site-admin special-cased: when actor.IsSiteAdmin, only the soft-delete filter applies (admins can read everything).
This is the single source of truth for "what repos can this viewer see in a list query". S28 search composes it; future listing endpoints (trending, activity feed) reuse it.
Types ¶
type Action ¶
type Action string
Action is a constant identifier for an operation against some resource. Adding a new action: add the const, register a default rule in policy.Can, and extend the test matrix. The matrix test asserts that every Action has explicit coverage for every Actor archetype.
const ( ActionRepoRead Action = "repo:read" ActionRepoWrite Action = "repo:write" ActionRepoAdmin Action = "repo:admin" ActionRepoSettingsGeneral Action = "repo:settings:general" ActionRepoSettingsCollaborators Action = "repo:settings:collaborators" ActionRepoSettingsBranches Action = "repo:settings:branches" ActionRepoArchive Action = "repo:archive" ActionRepoDelete Action = "repo:delete" ActionRepoTransfer Action = "repo:transfer" ActionRepoVisibility Action = "repo:visibility" )
Repo-level actions.
const ( ActionIssueRead Action = "issue:read" ActionIssueCreate Action = "issue:create" ActionIssueComment Action = "issue:comment" ActionIssueClose Action = "issue:close" ActionIssueLabel Action = "issue:label" ActionIssueAssign Action = "issue:assign" )
Issue-level actions. (Issue resources arrive in S18; S15 just ships the registry entries so the matrix is exhaustive from day one.)
const ( ActionPullRead Action = "pull:read" ActionPullCreate Action = "pull:create" ActionPullMerge Action = "pull:merge" ActionPullReview Action = "pull:review" ActionPullClose Action = "pull:close" )
Pull-request actions. (Pull resources arrive in S19; same note as above.)
const ( ActionStarCreate Action = "star:create" ActionForkCreate Action = "fork:create" // S26 social actions. WatchSet covers both setting an explicit // level and unsetting (deleting the row). Both require a logged-in // user with read access to the repo — the policy.Can engine // enforces visibility before reaching the role check, so a // non-collab on a private repo deny-leaks as 404. ActionWatchSet Action = "watch:set" )
Per-user social actions.
type Actor ¶
type Actor struct {
UserID int64
Username string
IsAnonymous bool
IsSuspended bool
IsSiteAdmin bool
// Impersonating reports whether this actor was constructed from
// an admin viewing-as another user. ImpersonateWriteOK enables
// the admin's writes during the impersonation; without it, every
// write action gets denied (the canonical foot-gun guard).
Impersonating bool
ImpersonateWriteOK bool
}
Actor is the authenticated identity asking for a decision. The web layer constructs one from middleware.CurrentUserFromContext + a suspended/admin check. SSH and HTTP git transports build their own from the resolved auth principal.
An anonymous request has UserID == 0; IsAnonymous == true. Convention is that callers fill IsAnonymous explicitly even when UserID == 0 implies it — duplication is cheap and keeps the boolean visible at every call site.
func AnonymousActor ¶
func AnonymousActor() Actor
AnonymousActor returns the canonical anonymous Actor. Use in tests and at unauthenticated entrypoints.
type Decision ¶
Decision is the verdict from Can. Allow is the only field handlers should branch on for control flow. DenyCode lets the handler pick a user-facing message; Reason is for logs and tests, never end-user surfaces — it can carry implementation details that constitute existence leaks.
func Can ¶
Can is the single authorization decision function. Every handler, hook, and worker in shithub funnels through here. Order of evaluation is significant — higher-precedence denies (deletion, suspension) short-circuit before role lookups touch the DB.
The cache (if present in ctx via WithCache) is consulted before any query and populated on the first lookup.
type DenyCode ¶
type DenyCode int
DenyCode is a typed enum carried on a deny Decision so handlers can pick a friendly user-facing message without re-deriving the reason from the resource. Read this; do not parse Decision.Reason.
const ( // DenyNone is the zero value used on an allow Decision. DenyNone DenyCode = iota DenyRepoDeleted DenyActorSuspended DenyArchived DenyVisibility // anonymous-or-non-collab on a private repo DenyRoleTooLow // logged-in but role insufficient DenyAnonymous // login required (e.g. star/fork) DenyDBError // DenyOrgSuspended is returned for write actions on a repo whose // owning org is currently suspended. Reads stay allowed (the spec // preserves visibility into suspended-org content); writes flip // off uniformly. DenyOrgSuspended // DenyImpersonationReadOnly is returned when an admin in // impersonation mode attempts a write without first opting into // write-mode (the typed-name confirm step in /admin/impersonate). // The default is read-only on the canonical foot-gun grounds. DenyImpersonationReadOnly )
type Deps ¶
Deps wires the policy package against Postgres. Construct once at boot and keep alive for the process lifetime.
type RepoRef ¶
type RepoRef struct {
ID int64
OwnerUserID int64
OwnerOrgID int64
Visibility string // "public" | "private"
IsArchived bool
IsDeleted bool
// AuthorUserID is the author of the *issue or PR* being acted on,
// when the action is one that grants author-self privileges
// (`ActionIssueClose`, `ActionPullClose`). Zero means "no author
// context" — e.g. a repo-level read or write — and is the default.
// Handlers populate this only on the close paths; everywhere else
// it stays zero.
//
// This is a pragmatic v1 shape: instead of introducing an
// IssueRef/PullRef parallel to RepoRef, we widen RepoRef with the
// one fact the close gate needs. When/if more issue-level rules
// land (assignee privileges, labeler privileges) we'll graduate
// to a proper IssueRef. The audit captured the design tradeoff;
// don't add new fields here without re-reading that note.
AuthorUserID int64
}
RepoRef is the policy-side projection of a repos row. Construct from either reposdb.Repo or any of the GetRepoBy* row types via NewRepoRef helpers in the policy package's adapters file (kept policy-internal so the policy package never imports reposdb directly — sqlc rows flow in via constructors at call boundaries).
OwnerOrgID is the S31-shape: zero today; populated when org-owned repos ship. Can() already branches on OwnerOrgID > 0 so that S31's org membership lookup plugs in cleanly.
func NewRepoRefFromRepo ¶
NewRepoRefFromRepo converts a reposdb.Repo into a policy RepoRef. Used at the boundary between data-access and policy code: any caller that already has a Repo row passes it in, and policy never imports reposdb at the rule layer.
type Role ¶
type Role string
Role models the per-collaborator grant. The five values mirror GitHub's tiers; the order matters because RoleAtLeast depends on it.
func EffectiveRole ¶
EffectiveRole returns the actor's resolved role on repo, taking owner-equals-admin into account and consulting the per-request cache.
Use this when a handler needs to ask "is this actor at least X" for a rule that doesn't map cleanly to an Action — for example, the locked-issue gate which lets triage+ comment despite the lock without granting them any other write permission.
Returns RoleNone for anonymous actors and on any DB error; callers should treat RoleNone as "no permissions."