A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix role permissions

+226 -58
+17 -4
CLAUDE.md
··· 10 10 11 11 ```bash 12 12 # Build all binaries 13 - go build -o atcr-registry ./cmd/registry 14 - go build -o atcr-hold ./cmd/hold 15 - go build -o docker-credential-atcr ./cmd/credential-helper 13 + # create go builds in the bin/ directory 14 + go build -o bin/atcr-registry ./cmd/registry 15 + go build -o bin/atcr-hold ./cmd/hold 16 + go build -o bin/docker-credential-atcr ./cmd/credential-helper 16 17 17 18 # Run tests 18 19 go test ./... ··· 269 270 **Architecture:** 270 271 - Reuses distribution's storage driver factory 271 272 - Supports all distribution drivers: S3, Storj, Minio, Azure, GCS, filesystem 272 - - Authorization based on PDS records (hold.public field, crew records) 273 + - Authorization follows ATProto's public-by-default model 273 274 - Generates presigned URLs (15min expiry) or proxies uploads/downloads 275 + 276 + **Authorization Model:** 277 + 278 + Read access: 279 + - **Public hold** (`HOLD_PUBLIC=true`): Anonymous + all authenticated users 280 + - **Private hold** (`HOLD_PUBLIC=false`): Authenticated users only (any ATCR user) 281 + 282 + Write access: 283 + - Hold owner OR crew members only 284 + - Verified via `io.atcr.hold.crew` records in owner's PDS 285 + 286 + Key insight: "Private" gates anonymous access, not authenticated access. This reflects ATProto's current limitation (no private PDS records yet). 274 287 275 288 **Endpoints:** 276 289 - `POST /get-presigned-url` - Get download URL for blob
+1 -1
SAILOR.md
··· 90 90 91 91 ⏳ In Progress: 92 92 - Need to update /auth/token handler similarly (add defaultHoldEndpoint parameter and profile management) 93 - - Fix compilation error in extractDefaultHoldEndpoint() - should use configuration.Middleware type not interface{} 93 + - Fix compilation error in extractDefaultHoldEndpoint() - should use configuration.Middleware type not any 94 94 95 95 🔜 Remaining: 96 96 - Update findStorageEndpoint() for new priority logic (check profile → own hold → default)
atcr-storage

This is a binary file and will not be displayed.

+1 -1
cmd/credential-helper/token.go
··· 82 82 return "", fmt.Errorf("failed to load token store: %w", err) 83 83 } 84 84 85 - reqBody := map[string]interface{}{ 85 + reqBody := map[string]any{ 86 86 "access_token": atprotoToken, 87 87 "handle": store.Handle, // Required for PDS resolution and token validation 88 88 "scope": []string{"repository:*:pull,push"},
+145 -25
cmd/hold/main.go
··· 121 121 return 122 122 } 123 123 124 - // Validate DID authorization 125 - if !s.isAuthorized(req.DID) { 126 - http.Error(w, "forbidden: DID not authorized", http.StatusForbidden) 124 + // Validate DID authorization for READ 125 + if !s.isAuthorizedRead(req.DID) { 126 + if req.DID == "" { 127 + // Anonymous request to private hold 128 + http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 129 + } else { 130 + // Authenticated but not authorized 131 + http.Error(w, "forbidden: access denied", http.StatusForbidden) 132 + } 127 133 return 128 134 } 129 135 ··· 161 167 return 162 168 } 163 169 164 - // Validate DID authorization 165 - if !s.isAuthorized(req.DID) { 166 - http.Error(w, "forbidden: DID not authorized", http.StatusForbidden) 170 + // Validate DID authorization for WRITE 171 + if !s.isAuthorizedWrite(req.DID) { 172 + if req.DID == "" { 173 + // Anonymous write attempt 174 + http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 175 + } else { 176 + // Authenticated but not crew/owner 177 + http.Error(w, "forbidden: write access denied", http.StatusForbidden) 178 + } 167 179 return 168 180 } 169 181 ··· 206 218 did = r.Header.Get("X-ATCR-DID") 207 219 } 208 220 209 - if !s.isAuthorized(did) { 210 - http.Error(w, "forbidden", http.StatusForbidden) 221 + // Authorize READ access 222 + if !s.isAuthorizedRead(did) { 223 + if did == "" { 224 + http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 225 + } else { 226 + http.Error(w, "forbidden: access denied", http.StatusForbidden) 227 + } 211 228 return 212 229 } 213 230 ··· 243 260 did = r.Header.Get("X-ATCR-DID") 244 261 } 245 262 246 - if !s.isAuthorized(did) { 247 - http.Error(w, "forbidden", http.StatusForbidden) 263 + // Authorize WRITE access 264 + if !s.isAuthorizedWrite(did) { 265 + if did == "" { 266 + http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 267 + } else { 268 + http.Error(w, "forbidden: write access denied", http.StatusForbidden) 269 + } 248 270 return 249 271 } 250 272 ··· 266 288 w.WriteHeader(http.StatusCreated) 267 289 } 268 290 269 - // isAuthorized checks if a DID is authorized to use this hold 270 - // Authorization is now based on: 271 - // - Hold record's "public" field (for reads) 272 - // - Crew records in PDS (for writes) 273 - // TODO: Query PDS to check hold.public and crew membership 274 - func (s *HoldService) isAuthorized(did string) bool { 275 - // For now, allow all requests 276 - // Real implementation should query PDS for hold record and crew records 291 + // isAuthorizedRead checks if a DID can read from this hold 292 + // Authorization: 293 + // - Public hold: allow anonymous (empty DID) or any authenticated user 294 + // - Private hold: require authentication (any user with sailor.profile) 295 + func (s *HoldService) isAuthorizedRead(did string) bool { 296 + // Check hold public flag 297 + isPublic, err := s.isHoldPublic() 298 + if err != nil { 299 + log.Printf("ERROR: Failed to check hold public flag: %v", err) 300 + // Fail secure - deny access on error 301 + return false 302 + } 303 + 304 + if isPublic { 305 + // Public hold - allow anyone (even anonymous) 306 + return true 307 + } 308 + 309 + // Private hold - require authentication 310 + // Any authenticated user with sailor.profile can read 311 + if did == "" { 312 + // Anonymous user trying to access private hold 313 + return false 314 + } 315 + 316 + // For MVP: assume DID presence means they have sailor.profile 317 + // Future: could query PDS to verify sailor.profile exists 277 318 return true 278 319 } 279 320 321 + // isAuthorizedWrite checks if a DID can write to this hold 322 + // Authorization: must be hold owner OR crew member 323 + func (s *HoldService) isAuthorizedWrite(did string) bool { 324 + if did == "" { 325 + // Anonymous writes not allowed 326 + return false 327 + } 328 + 329 + // Check if DID is the hold owner 330 + ownerDID := s.config.Registration.OwnerDID 331 + if ownerDID == "" { 332 + log.Printf("ERROR: Hold owner DID not configured") 333 + return false 334 + } 335 + 336 + if did == ownerDID { 337 + // Owner always has write access 338 + return true 339 + } 340 + 341 + // Check if DID is a crew member 342 + isCrew, err := s.isCrewMember(did) 343 + if err != nil { 344 + log.Printf("ERROR: Failed to check crew membership: %v", err) 345 + return false 346 + } 347 + 348 + return isCrew 349 + } 350 + 351 + // isHoldPublic checks if this hold allows public (anonymous) reads 352 + func (s *HoldService) isHoldPublic() (bool, error) { 353 + // Use cached config value for now 354 + // Future: could query PDS for hold record to get live value 355 + return s.config.Server.Public, nil 356 + } 357 + 358 + // isCrewMember checks if a DID is a crew member of this hold 359 + func (s *HoldService) isCrewMember(did string) (bool, error) { 360 + ownerDID := s.config.Registration.OwnerDID 361 + if ownerDID == "" { 362 + return false, fmt.Errorf("hold owner DID not configured") 363 + } 364 + 365 + ctx := context.Background() 366 + 367 + // Resolve owner's PDS endpoint 368 + resolver := atproto.NewResolver() 369 + pdsEndpoint, err := resolver.ResolvePDS(ctx, ownerDID) 370 + if err != nil { 371 + return false, fmt.Errorf("failed to resolve owner PDS: %w", err) 372 + } 373 + 374 + // Create unauthenticated client to read public records 375 + client := atproto.NewClient(pdsEndpoint, ownerDID, "") 376 + 377 + // List crew records for this hold 378 + // Crew records are public, so we can read them without auth 379 + records, err := client.ListRecords(ctx, atproto.HoldCrewCollection, 100) 380 + if err != nil { 381 + return false, fmt.Errorf("failed to list crew records: %w", err) 382 + } 383 + 384 + // Check if DID is in crew list 385 + for _, record := range records { 386 + var crewRecord atproto.HoldCrewRecord 387 + if err := json.Unmarshal(record.Value, &crewRecord); err != nil { 388 + continue 389 + } 390 + 391 + if crewRecord.Member == did { 392 + // Found crew membership 393 + return true, nil 394 + } 395 + } 396 + 397 + return false, nil 398 + } 399 + 280 400 // getDownloadURL generates a download URL for a blob 281 401 func (s *HoldService) getDownloadURL(ctx context.Context, digest string) (string, error) { 282 402 // Check if blob exists ··· 480 600 481 601 // buildStorageConfig creates storage configuration based on driver type 482 602 func buildStorageConfig(driver string) (StorageConfig, error) { 483 - params := make(map[string]interface{}) 603 + params := make(map[string]any) 484 604 485 605 switch driver { 486 606 case "s3": ··· 634 754 } 635 755 636 756 // Print the OAuth URL for user to visit 637 - log.Printf("\n" + strings.Repeat("=", 80)) 757 + log.Print("\n" + strings.Repeat("=", 80)) 638 758 log.Printf("OAUTH AUTHORIZATION REQUIRED") 639 - log.Printf(strings.Repeat("=", 80)) 759 + log.Print(strings.Repeat("=", 80)) 640 760 log.Printf("\nPlease visit this URL to authorize the hold service:\n") 641 761 log.Printf(" %s\n", authURL) 642 762 log.Printf("Waiting for authorization...") 643 - log.Printf(strings.Repeat("=", 80) + "\n") 763 + log.Print(strings.Repeat("=", 80) + "\n") 644 764 645 765 // Start temporary HTTP server for callback 646 766 codeChan := make(chan string, 1) ··· 746 866 747 867 log.Printf("✓ Created crew record: %s", crewResult.URI) 748 868 749 - log.Printf("\n" + strings.Repeat("=", 80)) 869 + log.Print("\n" + strings.Repeat("=", 80)) 750 870 log.Printf("REGISTRATION COMPLETE") 751 - log.Printf(strings.Repeat("=", 80)) 871 + log.Print(strings.Repeat("=", 80)) 752 872 log.Printf("Hold service is now registered and ready to use!") 753 - log.Printf(strings.Repeat("=", 80) + "\n") 873 + log.Print(strings.Repeat("=", 80) + "\n") 754 874 755 875 return nil 756 876 }
+1 -1
cmd/registry/serve.go
··· 205 205 continue 206 206 } 207 207 208 - // Extract options - options is configuration.Parameters which is map[string]interface{} 208 + // Extract options - options is configuration.Parameters which is map[string]any 209 209 if mw.Options != nil { 210 210 if endpoint, ok := mw.Options["default_storage_endpoint"].(string); ok { 211 211 return endpoint
credential-helper

This is a binary file and will not be displayed.

+50 -15
docs/BYOS.md
··· 133 133 ``` 134 134 135 135 **Authorization:** 136 - - Authorization is now based on PDS records, not local config 137 - - Public reads: controlled by `HOLD_PUBLIC` env var (stored in hold record) 138 - - Writes: controlled by `io.atcr.hold.crew` records in PDS 136 + 137 + ATCR follows ATProto's public-by-default model with gated anonymous access: 138 + 139 + **Read Access:** 140 + - **Public hold** (`HOLD_PUBLIC=true`): Anonymous reads allowed (no authentication) 141 + - **Private hold** (`HOLD_PUBLIC=false`): Requires authentication (any ATCR user with sailor.profile) 142 + 143 + **Write Access:** 144 + - Always requires authentication 145 + - Must be hold owner OR crew member (verified via `io.atcr.hold.crew` records in owner's PDS) 146 + 147 + **Key Points:** 148 + - "Private" just means "no anonymous access" - not "limited user access" 149 + - Any authenticated ATCR user can read from private holds 150 + - Crew membership only controls WRITE access, not READ access 151 + - This aligns with ATProto's public records model (no private PDS records yet) 139 152 140 153 ### Running 141 154 ··· 337 350 338 351 ### Authorization 339 352 340 - Authorization is now based on ATProto PDS records: 353 + Authorization is based on ATProto's public-by-default model: 341 354 342 - - **Public reads**: Controlled by `hold.public` field in hold record (set via `HOLD_PUBLIC` env var) 343 - - **Writes**: Controlled by `io.atcr.hold.crew` records in PDS 344 - - **Owner**: User who created the hold record automatically gets crew owner role 345 - - **No local config**: Authorization state lives in PDS, not hold service config 355 + **Read Authorization:** 356 + - **Public hold** (`public: true` in hold record): 357 + - Anonymous users: ✅ Allowed 358 + - Any authenticated user: ✅ Allowed 359 + 360 + - **Private hold** (`public: false` in hold record): 361 + - Anonymous users: ❌ 401 Unauthorized 362 + - Any authenticated ATCR user: ✅ Allowed (no crew membership required) 346 363 347 - The hold service queries the PDS to check: 348 - 1. Hold record's `public` field for read authorization 349 - 2. Crew records for write authorization 364 + **Write Authorization:** 365 + - Anonymous users: ❌ 401 Unauthorized 366 + - Authenticated non-crew: ❌ 403 Forbidden 367 + - Authenticated crew member: ✅ Allowed 368 + - Hold owner: ✅ Allowed 369 + 370 + **Implementation:** 371 + - Hold service queries owner's PDS for `io.atcr.hold.crew` records 372 + - Crew records are public ATProto records (read without authentication) 373 + - "Private" holds only gate anonymous access, not authenticated user access 374 + - This reflects ATProto's current limitation: no private PDS records 350 375 351 376 ### Presigned URLs 352 377 ··· 356 381 357 382 ### Private Holds 358 383 359 - Users can restrict access by: 360 - 1. Setting `HOLD_PUBLIC=false` (requires authentication for all operations) 361 - 2. Adding crew members via `io.atcr.hold.crew` records in PDS 384 + "Private" holds gate anonymous access while remaining accessible to authenticated users: 385 + 386 + **What "Private" Means:** 387 + - `HOLD_PUBLIC=false` prevents anonymous reads 388 + - Any authenticated ATCR user can still read 389 + - This aligns with ATProto's public records model 390 + 391 + **Write Control:** 392 + - Only hold owner and crew members can write 393 + - Crew membership managed via `io.atcr.hold.crew` records in owner's PDS 394 + - Removing crew member immediately revokes write access 362 395 363 - Only users with crew records can write to the hold. 396 + **Future: True Private Access** 397 + - When ATProto adds private PDS records, ATCR can support truly private repos 398 + - For now, "private" = "authenticated-only access" 364 399 365 400 ## Example: Personal Storage 366 401
+3 -3
lexicons/io/atcr/hold/crew.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", 7 - "description": "Crew membership for a storage hold. Stored in the hold owner's PDS to maintain control over access. Defines who can use a specific hold.", 7 + "description": "Crew membership for a storage hold. Stored in the hold owner's PDS to maintain control over write access. Crew members can push blobs to the hold. Read access is controlled by the hold's public flag, not crew membership.", 8 8 "key": "any", 9 9 "record": { 10 10 "type": "object", ··· 22 22 }, 23 23 "role": { 24 24 "type": "string", 25 - "description": "Member's role/permissions", 26 - "knownValues": ["owner", "write", "read"] 25 + "description": "Member's role/permissions for write access. 'owner' = hold owner, 'write' = can push blobs. Read access is controlled by hold's public flag.", 26 + "knownValues": ["owner", "write"] 27 27 }, 28 28 "expiresAt": { 29 29 "type": "string",
+3 -3
pkg/atproto/client.go
··· 35 35 } 36 36 37 37 // PutRecord stores a record in the ATProto repository 38 - func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record interface{}) (*Record, error) { 38 + func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record any) (*Record, error) { 39 39 // Construct the record URI 40 40 // Format: at://<did>/<collection>/<rkey> 41 41 42 - payload := map[string]interface{}{ 42 + payload := map[string]any{ 43 43 "repo": c.did, 44 44 "collection": collection, 45 45 "rkey": rkey, ··· 116 116 117 117 // DeleteRecord deletes a record from the ATProto repository 118 118 func (c *Client) DeleteRecord(ctx context.Context, collection, rkey string) error { 119 - payload := map[string]interface{}{ 119 + payload := map[string]any{ 120 120 "repo": c.did, 121 121 "collection": collection, 122 122 "rkey": rkey,
+1 -1
pkg/atproto/lexicon.go
··· 133 133 134 134 // ToOCIManifest converts the manifest record back to OCI manifest JSON 135 135 func (m *ManifestRecord) ToOCIManifest() ([]byte, error) { 136 - ociManifest := map[string]interface{}{ 136 + ociManifest := map[string]any{ 137 137 "schemaVersion": m.SchemaVersion, 138 138 "mediaType": m.MediaType, 139 139 "config": m.Config,
+1 -1
pkg/middleware/registry.go
··· 29 29 } 30 30 31 31 // initATProtoResolver initializes the name resolution middleware 32 - func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ driver.StorageDriver, options map[string]interface{}) (distribution.Namespace, error) { 32 + func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ driver.StorageDriver, options map[string]any) (distribution.Namespace, error) { 33 33 resolver := atproto.NewResolver() 34 34 35 35 // Get default storage endpoint from config (optional)
+1 -1
pkg/middleware/repository.go
··· 17 17 } 18 18 19 19 // initATProtoRouter initializes the ATProto routing middleware 20 - func initATProtoRouter(ctx context.Context, repo distribution.Repository, options map[string]interface{}) (distribution.Repository, error) { 20 + func initATProtoRouter(ctx context.Context, repo distribution.Repository, options map[string]any) (distribution.Repository, error) { 21 21 fmt.Printf("DEBUG [repository/middleware]: Initializing atproto-router for repo=%s\n", repo.Named().Name()) 22 22 fmt.Printf("DEBUG [repository/middleware]: Context values: atproto.did=%v, atproto.pds=%v\n", 23 23 ctx.Value("atproto.did"), ctx.Value("atproto.pds"))
+2 -2
pkg/storage/proxy_blob_store.go
··· 197 197 198 198 // getDownloadURL requests a presigned download URL from the storage service 199 199 func (p *ProxyBlobStore) getDownloadURL(ctx context.Context, dgst digest.Digest) (string, error) { 200 - reqBody := map[string]interface{}{ 200 + reqBody := map[string]any{ 201 201 "did": p.did, 202 202 "digest": dgst.String(), 203 203 } ··· 236 236 237 237 // getUploadURL requests a presigned upload URL from the storage service 238 238 func (p *ProxyBlobStore) getUploadURL(ctx context.Context, dgst digest.Digest, size int64) (string, error) { 239 - reqBody := map[string]interface{}{ 239 + reqBody := map[string]any{ 240 240 "did": p.did, 241 241 "digest": dgst.String(), 242 242 "size": size,