mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 12:17:00 +00:00
259 lines
9.3 KiB
Go
259 lines
9.3 KiB
Go
// Package authgate implements the auth-phase gate for ATCR's registry JWT
|
|
// issuance. The gate runs once per token request (push or pull): it always
|
|
// reconciles crew membership so first-time CLI users on a private hold
|
|
// can pass the hold-side read check, and additionally enforces hold
|
|
// membership (captain or crew with blob:write) plus storage quota for
|
|
// non-wildcard push scopes. Once the JWT is signed, the registry hot path
|
|
// trusts it for its short lifetime — no per-/v2/-request re-authorization.
|
|
package authgate
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"slices"
|
|
|
|
"atcr.io/pkg/appview/storage"
|
|
"atcr.io/pkg/atproto"
|
|
"atcr.io/pkg/auth"
|
|
"atcr.io/pkg/auth/oauth"
|
|
"atcr.io/pkg/auth/token"
|
|
)
|
|
|
|
// Authorizer enforces hold membership and quota at /auth/token.
|
|
//
|
|
// Captain/crew checks are served from local Jetstream-fed tables
|
|
// (hold_captain_records, hold_crew_members) so they're sub-millisecond.
|
|
// Quota is a live GET to hold's public io.atcr.hold.getQuota endpoint,
|
|
// which fails open on network errors so a brief hold outage doesn't lock
|
|
// users out of pushing entirely.
|
|
type Authorizer struct {
|
|
holdResolver
|
|
holdAuthorizer auth.HoldAuthorizer
|
|
refresher *oauth.Refresher
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// Option configures an Authorizer. Use New(..., WithX(...)) to set them.
|
|
type Option func(*Authorizer)
|
|
|
|
// WithHTTPClient overrides the HTTP client used for the live quota call.
|
|
// Production wiring leaves this at http.DefaultClient; tests pass an
|
|
// httptest.Server-backed client to drive checkQuota deterministically.
|
|
func WithHTTPClient(c *http.Client) Option {
|
|
return func(a *Authorizer) { a.httpClient = c }
|
|
}
|
|
|
|
// New constructs an Authorizer. defaultHoldDID is the AppView's fallback
|
|
// hold (used when a user's sailor profile hasn't recorded one yet). The
|
|
// refresher is required for OAuth-flow service-token minting during
|
|
// EnsureCrewMembership reconciliation; pass nil if OAuth isn't configured.
|
|
func New(db *sql.DB, holdAuthorizer auth.HoldAuthorizer, refresher *oauth.Refresher, defaultHoldDID string, opts ...Option) *Authorizer {
|
|
a := &Authorizer{
|
|
holdResolver: holdResolver{db: db, defaultHoldDID: defaultHoldDID},
|
|
holdAuthorizer: holdAuthorizer,
|
|
refresher: refresher,
|
|
httpClient: http.DefaultClient,
|
|
}
|
|
for _, opt := range opts {
|
|
opt(a)
|
|
}
|
|
return a
|
|
}
|
|
|
|
// Authorize satisfies token.Authorizer.
|
|
//
|
|
// authMethod is one of token.AuthMethodOAuth or token.AuthMethodAppPassword
|
|
// and selects which service-token fetcher EnsureCrewMembership uses for
|
|
// reconciliation. We can't infer it from `did` alone because either flow
|
|
// can produce a valid session for the same identity.
|
|
//
|
|
// Crew reconciliation runs for both push and pull token requests so a
|
|
// first-time CLI user (especially OAuth/credential-helper) doesn't get
|
|
// 403'd on their first pull from a private hold. The remaining gates
|
|
// (membership requirement, quota) only apply to non-wildcard push.
|
|
func (a *Authorizer) Authorize(ctx context.Context, did, authMethod string, access []auth.AccessEntry) error {
|
|
holdDID, err := a.resolveHoldDID(ctx, did)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if holdDID == "" {
|
|
// No hold configured anywhere. Registry can't function without one,
|
|
// but that's a config problem — not the gate's job to surface.
|
|
return nil
|
|
}
|
|
|
|
// Captain check first — they own the hold, so they have all permissions
|
|
// (no reconciliation needed, no membership requirement to check). This
|
|
// is the common case (most users push to their own hold) and lets us
|
|
// skip both the EnsureCrewMembership reconciliation and the
|
|
// hold_crew_members lookup below.
|
|
captain, err := a.isCaptain(ctx, did, holdDID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !captain {
|
|
// Reconcile crew membership for first-time CLI users (push or pull).
|
|
// Idempotent and short-circuits via approval cache when the record is
|
|
// already known. Best-effort: errors are logged inside the helper.
|
|
if fetcher := a.serviceTokenFetcher(ctx, authMethod, did); fetcher != nil {
|
|
storage.EnsureCrewMembership(ctx, did, holdDID, a.holdAuthorizer, fetcher)
|
|
}
|
|
}
|
|
|
|
if !hasNonWildcardPushScope(access) {
|
|
// Pull-only or wildcard scope — reconciliation done above is enough.
|
|
// Membership requirement and quota only apply to push.
|
|
return nil
|
|
}
|
|
|
|
if !captain {
|
|
if err := a.checkCrewBlobWrite(ctx, did, holdDID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return a.checkQuota(ctx, did, holdDID)
|
|
}
|
|
|
|
// isCaptain returns true if userDID owns holdDID per the local Jetstream-
|
|
// fed hold_captain_records table. Returns (false, nil) if no captain record
|
|
// exists yet (hold not yet ingested, or doesn't exist).
|
|
func (a *Authorizer) isCaptain(ctx context.Context, userDID, holdDID string) (bool, error) {
|
|
var ownerDID string
|
|
err := a.db.QueryRowContext(ctx,
|
|
"SELECT owner_did FROM hold_captain_records WHERE hold_did = ?", holdDID,
|
|
).Scan(&ownerDID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("look up hold captain: %w", err)
|
|
}
|
|
return ownerDID == userDID, nil
|
|
}
|
|
|
|
func hasNonWildcardPushScope(access []auth.AccessEntry) bool {
|
|
for _, entry := range access {
|
|
if entry.Type != "repository" || entry.Name == "*" {
|
|
continue
|
|
}
|
|
if slices.Contains(entry.Actions, "push") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// checkCrewBlobWrite returns nil if userDID is a crew member of holdDID with
|
|
// blob:write permission. Caller must have already determined that userDID is
|
|
// not the captain (captains have all permissions implicitly).
|
|
func (a *Authorizer) checkCrewBlobWrite(ctx context.Context, userDID, holdDID string) error {
|
|
var permsJSON sql.NullString
|
|
err := a.db.QueryRowContext(ctx,
|
|
"SELECT permissions FROM hold_crew_members WHERE hold_did = ? AND member_did = ?",
|
|
holdDID, userDID,
|
|
).Scan(&permsJSON)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return fmt.Errorf("crew membership required: %s is not a member of hold %s", userDID, holdDID)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("look up crew membership: %w", err)
|
|
}
|
|
|
|
if !permissionsAllowBlobWrite(permsJSON.String) {
|
|
return fmt.Errorf("crew membership lacks blob:write on hold %s", holdDID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func permissionsAllowBlobWrite(permsJSON string) bool {
|
|
if permsJSON == "" {
|
|
return false
|
|
}
|
|
var perms []string
|
|
if err := json.Unmarshal([]byte(permsJSON), &perms); err != nil {
|
|
return false
|
|
}
|
|
return slices.Contains(perms, "blob:write")
|
|
}
|
|
|
|
// checkQuota fails open on network errors. The hold's quota endpoint is
|
|
// public (no auth), but if the hold is unreachable we don't want every
|
|
// push everywhere to error out — Docker users would see denied:quota for
|
|
// reasons unrelated to their actual usage.
|
|
func (a *Authorizer) checkQuota(ctx context.Context, userDID, holdDID string) error {
|
|
stats, err := atproto.FetchQuotaStats(ctx, a.httpClient, holdDID, userDID)
|
|
if err != nil {
|
|
slog.Warn("push gate: quota call failed; allowing push", "did", userDID, "hold_did", holdDID, "error", err)
|
|
return nil
|
|
}
|
|
|
|
if stats.Limit != nil && stats.TotalSize >= *stats.Limit {
|
|
return fmt.Errorf("quota exceeded: %s / %s used by %s. Delete images to free space",
|
|
formatGB(stats.TotalSize), formatGB(*stats.Limit), a.identityLabel(ctx, userDID))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// formatGB renders bytes as gigabytes with two decimals — matches the
|
|
// percentage display in the appview UI so users see consistent units.
|
|
func formatGB(b int64) string {
|
|
return fmt.Sprintf("%.2f GB", float64(b)/(1024*1024*1024))
|
|
}
|
|
|
|
// identityLabel returns "handle (did)" when the local users table knows the
|
|
// handle, falling back to the bare DID. The users table is Jetstream-fed, so
|
|
// for first-seen identities (rare on a push, since they've already OAuth'd)
|
|
// the handle may be missing — the DID alone still uniquely identifies them.
|
|
func (a *Authorizer) identityLabel(ctx context.Context, did string) string {
|
|
var handle string
|
|
err := a.db.QueryRowContext(ctx, "SELECT handle FROM users WHERE did = ?", did).Scan(&handle)
|
|
if err != nil || handle == "" || handle == did {
|
|
return did
|
|
}
|
|
return fmt.Sprintf("%s (%s)", handle, did)
|
|
}
|
|
|
|
// serviceTokenFetcher returns the appropriate fetcher for the auth method,
|
|
// or nil if none is available (e.g. OAuth flow with no refresher configured).
|
|
// ctx is used for the cold-cache identity lookup inside resolvePDS.
|
|
func (a *Authorizer) serviceTokenFetcher(ctx context.Context, authMethod, userDID string) storage.ServiceTokenFetcher {
|
|
pdsEndpoint, err := a.resolvePDS(ctx, userDID)
|
|
if err != nil || pdsEndpoint == "" {
|
|
return nil
|
|
}
|
|
|
|
switch authMethod {
|
|
case token.AuthMethodAppPassword:
|
|
return func(ctx context.Context, holdDID string) (string, error) {
|
|
return auth.GetOrFetchServiceTokenWithAppPassword(ctx, userDID, holdDID, pdsEndpoint)
|
|
}
|
|
case token.AuthMethodOAuth:
|
|
if a.refresher == nil {
|
|
return nil
|
|
}
|
|
return func(ctx context.Context, holdDID string) (string, error) {
|
|
return auth.GetOrFetchServiceToken(ctx, a.refresher, userDID, holdDID, pdsEndpoint)
|
|
}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (a *Authorizer) resolvePDS(ctx context.Context, userDID string) (string, error) {
|
|
// Cached identity directory lookup (24h TTL). Per-call cost <1ms after
|
|
// the first resolution per user; on a cold miss this issues live DNS/
|
|
// HTTPS lookups, so we honor ctx so a client disconnect cancels them.
|
|
_, _, pdsEndpoint, err := atproto.ResolveIdentity(ctx, userDID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return pdsEndpoint, nil
|
|
}
|