From 1f72d907263d7f49201a83008fc44fcac2178a8e Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Tue, 21 Oct 2025 10:49:06 -0500 Subject: [PATCH] fix issue with mismatched scopes locally --- Dockerfile.appview | 3 +- cmd/appview/serve.go | 22 +++--- docs/appview.md | 104 +++++++++++++++++++++++++++++ go.mod | 4 +- pkg/appview/readme/fetcher.go | 2 +- pkg/auth/oauth/client.go | 27 ++++++-- pkg/auth/oauth/interactive.go | 4 +- test-e2e.sh => scripts/test-e2e.sh | 0 8 files changed, 145 insertions(+), 21 deletions(-) create mode 100644 docs/appview.md rename test-e2e.sh => scripts/test-e2e.sh (100%) diff --git a/Dockerfile.appview b/Dockerfile.appview index 26dd855..466e370 100644 --- a/Dockerfile.appview +++ b/Dockerfile.appview @@ -39,7 +39,8 @@ LABEL org.opencontainers.image.title="ATCR AppView" \ org.opencontainers.image.documentation="https://tangled.org/@evan.jarrett.net/at-container-registry" \ org.opencontainers.image.licenses="MIT" \ org.opencontainers.image.version="0.1.0" \ - io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTNrRelfloN2emuWZDrWmPT0o93bAjEnozjD6UPgoVV9m4" + io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTNrRelfloN2emuWZDrWmPT0o93bAjEnozjD6UPgoVV9m4" \ + io.atcr.readme="https://tangled.org/@evan.jarrett.net/at-container-registry/raw/main/docs/appview.md" ENTRYPOINT ["/atcr-appview"] CMD ["serve"] diff --git a/cmd/appview/serve.go b/cmd/appview/serve.go index 3fdbb1a..e8edd00 100644 --- a/cmd/appview/serve.go +++ b/cmd/appview/serve.go @@ -154,16 +154,26 @@ func serveRegistry(cmd *cobra.Command, args []string) error { // The extraction function normalizes URLs to DIDs for consistency defaultHoldDID := appview.ExtractDefaultHoldDID(config) + // Extract test mode from config (needed for OAuth scope configuration) + testMode := appview.ExtractTestMode(config) + if testMode { + fmt.Println("TEST_MODE enabled - will use HTTP for local DID resolution and transition:generic scope") + } + // Create OAuth app (indigo client) - oauthApp, err := oauth.NewApp(baseURL, oauthStore, defaultHoldDID) + oauthApp, err := oauth.NewApp(baseURL, oauthStore, defaultHoldDID, testMode) if err != nil { return fmt.Errorf("failed to create OAuth app: %w", err) } - fmt.Println("Using full OAuth scopes (including blob: scope)") + if testMode { + fmt.Println("Using OAuth scopes with transition:generic (test mode)") + } else { + fmt.Println("Using OAuth scopes with RPC scope (production mode)") + } // Invalidate sessions with mismatched scopes on startup // This ensures all users have the latest required scopes after deployment - desiredScopes := oauth.GetDefaultScopes(defaultHoldDID) + desiredScopes := oauth.GetDefaultScopes(defaultHoldDID, testMode) invalidatedCount, err := oauthStore.InvalidateSessionsWithMismatchedScopes(context.Background(), desiredScopes) if err != nil { fmt.Printf("Warning: Failed to invalidate sessions with mismatched scopes: %v\n", err) @@ -186,12 +196,6 @@ func serveRegistry(cmd *cobra.Command, args []string) error { metricsDB := db.NewMetricsDB(uiDatabase) middleware.SetGlobalDatabase(metricsDB) - // Extract test mode from config - testMode := appview.ExtractTestMode(config) - if testMode { - fmt.Println("TEST_MODE enabled - will use HTTP for local DID resolution") - } - // Create RemoteHoldAuthorizer for hold authorization with caching holdAuthorizer := auth.NewRemoteHoldAuthorizer(uiDatabase, testMode) middleware.SetGlobalAuthorizer(holdAuthorizer) diff --git a/docs/appview.md b/docs/appview.md new file mode 100644 index 0000000..2d179e6 --- /dev/null +++ b/docs/appview.md @@ -0,0 +1,104 @@ +# ATCR AppView + +The **AppView** is the OCI-compliant registry server for ATCR (ATProto Container Registry). It provides the Docker Registry HTTP API V2 and a web interface for browsing container images. + +## What is AppView? + +AppView serves as the central registry server that: + +- **Serves OCI Distribution API** - Compatible with Docker, containerd, podman, and other OCI clients +- **Resolves ATProto identities** - Converts handles and DIDs to PDS endpoints +- **Routes manifests** - Stores container manifests as ATProto records in users' Personal Data Servers +- **Routes blobs** - Proxies blob operations to hold services (S3-compatible storage) +- **Provides web UI** - Browse, search, and star repositories + +## Image Format + +Container images use ATProto identities: + +``` +atcr.io/alice.bsky.social/myapp:latest +atcr.io/did:plc:xyz123/myapp:latest +``` + +## Using ATCR + +### Push Images + +```bash +# Install credential helper +curl -fsSL https://atcr.io/install.sh | bash + +# Configure Docker (add to ~/.docker/config.json) +{ + "credHelpers": { + "atcr.io": "atcr" + } +} + +# Push images (authenticates automatically) +docker tag myapp:latest atcr.io/yourhandle/myapp:latest +docker push atcr.io/yourhandle/myapp:latest +``` + +### Pull Images + +```bash +# Public images (no auth required) +docker pull atcr.io/alice.bsky.social/myapp:latest + +# Private images (automatic OAuth authentication) +docker pull atcr.io/yourhandle/private-app:latest +``` + +## Running Your Own AppView + +Deploy your own registry instance with Docker Compose: + +```bash +# Create configuration +cp .env.appview.example .env.appview +# Edit .env.appview with your settings + +# Start services +docker-compose up -d +``` + +### Configuration + +Key environment variables: + +- `ATCR_HTTP_ADDR` - HTTP listen address (default: `:5000`) +- `ATCR_BASE_URL` - Public URL for OAuth/JWT realm +- `ATCR_DEFAULT_HOLD_DID` - Default hold service DID for blob storage (required) +- `ATCR_UI_ENABLED` - Enable web interface (default: `true`) +- `JETSTREAM_URL` - ATProto event stream URL for real-time updates + +See [deployment documentation](https://tangled.org/@evan.jarrett.net/at-container-registry/blob/main/deploy/README.md) for production setup. + +## Features + +- ✅ **OCI-compliant** - Full Docker Registry API V2 support +- ✅ **ATProto OAuth** - Secure authentication with DPoP +- ✅ **Decentralized storage** - Manifests stored in users' PDS +- ✅ **Web UI** - Browse repositories, view tags, search images +- ✅ **Real-time updates** - Jetstream integration for live indexing +- ✅ **Multi-arch support** - ARM64, AMD64, and other platforms +- ✅ **BYOS** - Bring Your Own Storage via hold services + +## Storage Architecture + +**Hybrid model:** +- **Manifests** → ATProto records in user's PDS (small JSON metadata) +- **Blobs** → Hold services with S3-compatible backends (large binary layers) + +This design keeps metadata portable and federated while leveraging cheap blob storage for layers. + +## License + +MIT + +--- + +**Documentation:** https://tangled.org/@evan.jarrett.net/at-container-registry +**Source Code:** https://tangled.org/@evan.jarrett.net/at-container-registry diff --git a/go.mod b/go.mod index d118537..c38c790 100644 --- a/go.mod +++ b/go.mod @@ -20,10 +20,12 @@ require ( github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 github.com/klauspost/compress v1.18.0 github.com/mattn/go-sqlite3 v1.14.32 + github.com/microcosm-cc/bluemonday v1.0.27 github.com/multiformats/go-multihash v0.2.3 github.com/opencontainers/go-digest v1.0.0 github.com/spf13/cobra v1.8.0 github.com/whyrusleeping/cbor-gen v0.3.1 + github.com/yuin/goldmark v1.7.13 go.opentelemetry.io/otel v1.32.0 go.yaml.in/yaml/v4 v4.0.0-rc.2 golang.org/x/crypto v0.39.0 @@ -87,7 +89,6 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect @@ -108,7 +109,6 @@ require ( github.com/sirupsen/logrus v1.9.3 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/yuin/goldmark v1.7.13 // indirect gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect diff --git a/pkg/appview/readme/fetcher.go b/pkg/appview/readme/fetcher.go index a0b48de..112e572 100644 --- a/pkg/appview/readme/fetcher.go +++ b/pkg/appview/readme/fetcher.go @@ -29,7 +29,7 @@ func NewFetcher() *Fetcher { // Configure markdown renderer with GitHub-flavored markdown md := goldmark.New( goldmark.WithExtensions( - extension.GFM, // GitHub Flavored Markdown + extension.GFM, // GitHub Flavored Markdown extension.Typographer, // Smart quotes, dashes, etc. ), goldmark.WithParserOptions( diff --git a/pkg/auth/oauth/client.go b/pkg/auth/oauth/client.go index 49b50a6..eae58bb 100644 --- a/pkg/auth/oauth/client.go +++ b/pkg/auth/oauth/client.go @@ -20,8 +20,8 @@ type App struct { } // NewApp creates a new OAuth app for ATCR with default scopes -func NewApp(baseURL string, store oauth.ClientAuthStore, holdDid string) (*App, error) { - return NewAppWithScopes(baseURL, store, GetDefaultScopes(holdDid)) +func NewApp(baseURL string, store oauth.ClientAuthStore, holdDid string, testMode bool) (*App, error) { + return NewAppWithScopes(baseURL, store, GetDefaultScopes(holdDid, testMode)) } // NewAppWithScopes creates a new OAuth app for ATCR with custom scopes @@ -120,10 +120,10 @@ func RedirectURI(baseURL string) string { } // GetDefaultScopes returns the default OAuth scopes for ATCR registry operations -func GetDefaultScopes(did string) []string { - return []string{ +// testMode determines whether to use transition:generic (test) or rpc scopes (production) +func GetDefaultScopes(did string, testMode bool) []string { + scopes := []string{ "atproto", - "transition:generic", // Image manifest types (single-arch) "blob:application/vnd.oci.image.manifest.v1+json", "blob:application/vnd.docker.distribution.manifest.v2+json", @@ -132,12 +132,25 @@ func GetDefaultScopes(did string) []string { "blob:application/vnd.docker.distribution.manifest.list.v2+json", // OCI artifact manifests (for cosign signatures, SBOMs, attestations) "blob:application/vnd.cncf.oras.artifact.manifest.v1+json", - fmt.Sprintf("rpc:com.atproto.repo.getRecord?aud=%s#atcr_hold", did), + } + + // In test mode: use transition:generic (local dev with test PDS) + // In production: use rpc scope for service auth + if testMode { + scopes = append(scopes, "transition:generic") + } else { + scopes = append(scopes, fmt.Sprintf("rpc:com.atproto.repo.getRecord?aud=%s#atcr_hold", did)) + } + + // Add repo scopes + scopes = append(scopes, fmt.Sprintf("repo:%s", atproto.ManifestCollection), fmt.Sprintf("repo:%s", atproto.TagCollection), fmt.Sprintf("repo:%s", atproto.StarCollection), fmt.Sprintf("repo:%s", atproto.SailorProfileCollection), - } + ) + + return scopes } // ScopesMatch checks if two scope lists are equivalent (order-independent) diff --git a/pkg/auth/oauth/interactive.go b/pkg/auth/oauth/interactive.go index 3699154..4d6af75 100644 --- a/pkg/auth/oauth/interactive.go +++ b/pkg/auth/oauth/interactive.go @@ -33,11 +33,13 @@ func InteractiveFlowWithCallback( } // Create OAuth app with custom scopes (or defaults if nil) + // Interactive flows are typically for production use (credential helper, etc.) + // so we default to testMode=false var app *App if scopes != nil { app, err = NewAppWithScopes(baseURL, store, scopes) } else { - app, err = NewApp(baseURL, store, "*") + app, err = NewApp(baseURL, store, "*", false) } if err != nil { return nil, fmt.Errorf("failed to create OAuth app: %w", err) diff --git a/test-e2e.sh b/scripts/test-e2e.sh similarity index 100% rename from test-e2e.sh rename to scripts/test-e2e.sh