policy

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 10, 2026 License: AGPL-3.0 Imports: 10 Imported by: 0

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

Constants

This section is empty.

Variables

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

func HasRoleAtLeast(ctx context.Context, d Deps, actor Actor, repo RepoRef, want Role) bool

HasRoleAtLeast is the convenience shorthand for the common case: "does this actor hold at least `want` on this repo?" Wraps EffectiveRole + RoleAtLeast.

func InvalidateRepo

func InvalidateRepo(ctx context.Context, repoID int64)

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

func IsVisibleTo(ctx context.Context, d Deps, actor Actor, repo RepoRef) bool

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

func Maybe404(decision Decision, repo RepoRef, actor Actor) int

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

func PrimeCacheForTest(ctx context.Context, userID, repoID int64, role Role)

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

func RoleAtLeast(have, want Role) bool

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.

func WithCache

func WithCache(ctx context.Context) context.Context

WithCache returns a derived context that carries a per-request memo. Wire this into the request middleware once, and every Can() call on downstream handlers benefits from de-duplication. Calling Can with a context that has no cache is supported — the lookup just runs every time.

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.

func UserActor

func UserActor(userID int64, username string, suspended, siteAdmin bool) Actor

UserActor wraps a logged-in user. Suspension and site-admin flags must be loaded from the DB by the caller — the policy package does not query users on its own to keep the decision pure.

type Decision

type Decision struct {
	Allow  bool
	Reason string
	Code   DenyCode
}

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

func Can(ctx context.Context, d Deps, actor Actor, action Action, repo RepoRef) Decision

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

type Deps struct {
	Pool *pgxpool.Pool
}

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

func NewRepoRefFromRepo(r reposdb.Repo) RepoRef

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.

func (RepoRef) IsPrivate

func (r RepoRef) IsPrivate() bool

IsPrivate is the inverse. Use whichever phrasing reads better at the call site.

func (RepoRef) IsPublic

func (r RepoRef) IsPublic() bool

IsPublic returns true when the repo's visibility column is "public". Other code paths must not parse Visibility directly — read it through these helpers so the canonical strings live in one place.

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.

const (
	// RoleNone is the zero value used by callers when no row exists.
	RoleNone     Role = ""
	RoleRead     Role = "read"
	RoleTriage   Role = "triage"
	RoleWrite    Role = "write"
	RoleMaintain Role = "maintain"
	RoleAdmin    Role = "admin"
)

func EffectiveRole

func EffectiveRole(ctx context.Context, d Deps, actor Actor, repo RepoRef) Role

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."

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL