A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
80
fork

Configure Feed

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

implement writes for everyone

+1720 -77
+10 -7
cmd/hold/main.go
··· 90 90 redirectURI := cfg.Server.PublicURL + "/auth/oauth/callback" 91 91 clientID := cfg.Server.PublicURL + "/client-metadata.json" 92 92 93 - // Define scopes needed for hold registration 93 + // Define scopes needed for hold registration and crew management 94 + // Omit action parameter to allow all actions (create, update, delete) 94 95 scopes := []string{ 95 96 "atproto", 96 - fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection), 97 - fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection), 98 - fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection), 99 - fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection), 100 - fmt.Sprintf("repo:%s?action=create", atproto.SailorProfileCollection), 101 - fmt.Sprintf("repo:%s?action=update", atproto.SailorProfileCollection), 97 + fmt.Sprintf("repo:%s", atproto.HoldCollection), 98 + fmt.Sprintf("repo:%s", atproto.HoldCrewCollection), 99 + fmt.Sprintf("repo:%s", atproto.SailorProfileCollection), 102 100 } 103 101 104 102 config := indigooauth.NewPublicConfig(clientID, redirectURI, scopes) ··· 147 145 log.Printf("You can register manually later using the /register endpoint") 148 146 } else { 149 147 log.Printf("Successfully registered hold service in PDS") 148 + } 149 + 150 + // Reconcile allow-all crew state 151 + if err := service.ReconcileAllowAllCrew(&oauthCallbackHandler); err != nil { 152 + log.Printf("WARNING: Failed to reconcile allow-all crew state: %v", err) 150 153 } 151 154 } 152 155
+28
deploy/.env.prod.template
··· 35 35 # Default: false (private) 36 36 HOLD_PUBLIC=false 37 37 38 + # Allow all authenticated users to write to this hold 39 + # This setting controls write permissions for authenticated ATCR users 40 + # 41 + # - true: Any authenticated ATCR user can push images (treat all as crew) 42 + # Useful for shared/community holds where you want to allow 43 + # multiple users to push without explicit crew membership. 44 + # Users must still authenticate via ATProto OAuth. 45 + # 46 + # - false: Only hold owner and explicit crew members can push (default) 47 + # Write access requires io.atcr.hold.crew record in owner's PDS. 48 + # Most secure option for production holds. 49 + # 50 + # Read permissions are controlled by HOLD_PUBLIC (above). 51 + # 52 + # Security model: 53 + # Read: HOLD_PUBLIC=true → anonymous + authenticated users 54 + # HOLD_PUBLIC=false → authenticated users only 55 + # Write: HOLD_ALLOW_ALL_CREW=true → all authenticated users 56 + # HOLD_ALLOW_ALL_CREW=false → owner + crew only (verified via PDS) 57 + # 58 + # Use cases: 59 + # - Public registry: HOLD_PUBLIC=true, HOLD_ALLOW_ALL_CREW=true 60 + # - ATProto users only: HOLD_PUBLIC=false, HOLD_ALLOW_ALL_CREW=true 61 + # - Private hold (default): HOLD_PUBLIC=false, HOLD_ALLOW_ALL_CREW=false 62 + # 63 + # Default: false 64 + HOLD_ALLOW_ALL_CREW=false 65 + 38 66 # ============================================================================== 39 67 # S3/UpCloud Object Storage Configuration 40 68 # ==============================================================================
+1
deploy/docker-compose.prod.yml
··· 94 94 # Hold service configuration 95 95 HOLD_PUBLIC_URL: https://${HOLD_DOMAIN:-hold01.atcr.io} 96 96 HOLD_SERVER_ADDR: :8080 97 + HOLD_ALLOW_ALL_CREW: ${HOLD_ALLOW_ALL_CREW:-false} 97 98 HOLD_PUBLIC: ${HOLD_PUBLIC:-false} 98 99 HOLD_OWNER: ${HOLD_OWNER} 99 100
+1231
docs/CREW_ACCESS_CONTROL.md
··· 1 + # Hold Crew Access Control 2 + 3 + ## Overview 4 + 5 + ATCR uses a crew-based access control system for hold (storage) services. Hold owners can grant write access to other users by creating crew records in their PDS. This document describes the scalable access control system that supports: 6 + 7 + - **Individual access** - Explicit DID-based crew membership 8 + - **Wildcard access** - Allow all authenticated users 9 + - **Pattern-based access** - Match users by handle patterns (e.g., `*.example.com`) 10 + - **Access revocation** - Bar (ban) specific users or patterns 11 + 12 + ## Problem Statement 13 + 14 + The original crew system required one `io.atcr.hold.crew` record per user. This doesn't scale for: 15 + 16 + 1. **Public/shared holds** - Thousands of users would need individual crew records 17 + 2. **Community holds** - PDS operators want to allow all their users 18 + 3. **Default registries** - AppView operators want to allow all authenticated users 19 + 4. **Access revocation** - No way to selectively remove access from wildcard/pattern grants 20 + 21 + ## Design Goals 22 + 23 + 1. **Preserve ATProto semantics** - Keep `member` as DID type for backlinks 24 + 2. **Scalable** - Support thousands of users with minimal records 25 + 3. **Flexible patterns** - Support wildcards, handle globs, future regex 26 + 4. **Clear semantics** - Separate allow/deny (crew vs barred) 27 + 5. **Backward compatible** - Existing crew records work unchanged 28 + 6. **Performance** - Minimize PDS queries, enable caching 29 + 30 + ## Record Schemas 31 + 32 + ### io.atcr.hold.crew (Updated) 33 + 34 + Crew membership grants write access to a hold. Stored in the **hold owner's PDS**. 35 + 36 + ```json 37 + { 38 + "$type": "io.atcr.hold.crew", 39 + "hold": "at://did:plc:owner/io.atcr.hold/shared", 40 + "member": "did:plc:alice123", // Optional: Explicit DID (for backlinks) 41 + "memberPattern": "*.bsky.social", // Optional: Pattern matching 42 + "role": "write", 43 + "createdAt": "2025-10-13T12:00:00Z" 44 + } 45 + ``` 46 + 47 + **Fields:** 48 + 49 + - `hold` (string, at-uri, required) - AT-URI of the hold record 50 + - `member` (string, did, optional) - Explicit DID for individual access (enables backlinks) 51 + - `memberPattern` (string, optional) - Pattern for matching multiple users 52 + - `role` (string, required) - Role: `"owner"` or `"write"` 53 + - `expiresAt` (string, datetime, optional) - Optional expiration 54 + - `createdAt` (string, datetime, required) - Creation timestamp 55 + 56 + **Validation:** Exactly one of `member` or `memberPattern` must be set. 57 + 58 + **Pattern syntax:** 59 + 60 + - `"*"` - Matches all authenticated users 61 + - `"*.domain.com"` - Matches handles ending with `.domain.com` 62 + - `"subdomain.*"` - Matches handles starting with `subdomain.` 63 + - `"*.bsky.*"` - Matches handles containing `.bsky.` 64 + 65 + **Examples:** 66 + 67 + ```json 68 + // Explicit DID (current behavior, preserved) 69 + { 70 + "$type": "io.atcr.hold.crew", 71 + "hold": "at://did:plc:owner/io.atcr.hold/team", 72 + "member": "did:plc:alice123", 73 + "role": "write", 74 + "createdAt": "2025-10-13T12:00:00Z" 75 + } 76 + 77 + // Allow all authenticated users (public hold) 78 + { 79 + "$type": "io.atcr.hold.crew", 80 + "hold": "at://did:plc:owner/io.atcr.hold/shared", 81 + "memberPattern": "*", 82 + "role": "write", 83 + "createdAt": "2025-10-13T12:00:00Z" 84 + } 85 + 86 + // Allow all users from a community 87 + { 88 + "$type": "io.atcr.hold.crew", 89 + "hold": "at://did:plc:owner/io.atcr.hold/community", 90 + "memberPattern": "*.my-community.social", 91 + "role": "write", 92 + "createdAt": "2025-10-13T12:00:00Z" 93 + } 94 + 95 + // Allow specific subdomain 96 + { 97 + "$type": "io.atcr.hold.crew", 98 + "hold": "at://did:plc:owner/io.atcr.hold/corp", 99 + "memberPattern": "*.eng.company.com", 100 + "role": "write", 101 + "createdAt": "2025-10-13T12:00:00Z" 102 + } 103 + ``` 104 + 105 + ### io.atcr.hold.crew.barred (New) 106 + 107 + Barred list revokes access for specific users or patterns. Overrides crew membership. Stored in the **hold owner's PDS**. 108 + 109 + ```json 110 + { 111 + "$type": "io.atcr.hold.crew.barred", 112 + "hold": "at://did:plc:owner/io.atcr.hold/shared", 113 + "member": "did:plc:spammer", // Optional: Explicit DID 114 + "memberPattern": "*.spam-instance.com", // Optional: Pattern matching 115 + "reason": "spam/abuse/policy violation", 116 + "barredAt": "2025-10-13T12:00:00Z" 117 + } 118 + ``` 119 + 120 + **Fields:** 121 + 122 + - `hold` (string, at-uri, required) - AT-URI of the hold record 123 + - `member` (string, did, optional) - Explicit DID to bar 124 + - `memberPattern` (string, optional) - Pattern for barring multiple users 125 + - `reason` (string, optional) - Human-readable reason for access revocation 126 + - `barredAt` (string, datetime, required) - When user was barred 127 + 128 + **Validation:** Exactly one of `member` or `memberPattern` must be set. 129 + 130 + **Pattern syntax:** Same as crew patterns (wildcards, handle globs). 131 + 132 + **Limitations:** Handle-based barring can be circumvented by users changing their handle or acquiring a new domain. However, this requires significant effort (purchasing domains, changing identity), making it an acceptable deterrent for most abuse cases. DID-based barring is permanent (until user creates new DID). 133 + 134 + **Examples:** 135 + 136 + ```json 137 + // Bar specific user 138 + { 139 + "$type": "io.atcr.hold.crew.barred", 140 + "hold": "at://did:plc:owner/io.atcr.hold/shared", 141 + "member": "did:plc:badactor", 142 + "reason": "Terms of service violation", 143 + "barredAt": "2025-10-13T12:00:00Z" 144 + } 145 + 146 + // Bar all users from a spam PDS 147 + { 148 + "$type": "io.atcr.hold.crew.barred", 149 + "hold": "at://did:plc:owner/io.atcr.hold/shared", 150 + "memberPattern": "*.spam-pds.com", 151 + "reason": "Spam instance", 152 + "barredAt": "2025-10-13T14:30:00Z" 153 + } 154 + 155 + // Bar pattern of suspicious accounts 156 + { 157 + "$type": "io.atcr.hold.crew.barred", 158 + "hold": "at://did:plc:owner/io.atcr.hold/shared", 159 + "memberPattern": "bot*", 160 + "reason": "Automated account abuse", 161 + "barredAt": "2025-10-13T15:00:00Z" 162 + } 163 + ``` 164 + 165 + ## Authorization Logic 166 + 167 + Write authorization follows this priority order: 168 + 169 + ``` 170 + isAuthorizedWrite(did, handle): 171 + 1. If DID is hold owner → ALLOW 172 + 2. If DID or handle matches barred list → DENY 173 + 3. If DID explicitly in crew list → ALLOW 174 + 4. If handle matches crew pattern → ALLOW 175 + 5. Default → DENY 176 + ``` 177 + 178 + **Detailed algorithm:** 179 + 180 + ```go 181 + func (s *HoldService) isAuthorizedWrite(did string) bool { 182 + // 1. Check if owner 183 + if did == s.config.Registration.OwnerDID { 184 + return true // Owner always has access 185 + } 186 + 187 + // 2. Resolve handle from DID 188 + handle, err := resolveHandle(did) 189 + if err != nil { 190 + log.Printf("Failed to resolve handle for DID %s: %v", did, err) 191 + handle = "" // Continue without handle matching 192 + } 193 + 194 + // 3. Check barred list (explicit deny overrides everything) 195 + barred, err := s.isBarred(did, handle) 196 + if err != nil { 197 + log.Printf("Error checking barred status: %v", err) 198 + return false // Fail secure 199 + } 200 + if barred { 201 + return false // Explicitly barred 202 + } 203 + 204 + // 4. Check crew list (explicit allow) 205 + crew, err := s.isCrewMember(did, handle) 206 + if err != nil { 207 + log.Printf("Error checking crew status: %v", err) 208 + return false // Fail secure 209 + } 210 + 211 + return crew // Allow if crew member, deny otherwise 212 + } 213 + 214 + func (s *HoldService) isBarred(did, handle string) (bool, error) { 215 + records := listBarredRecords() 216 + 217 + for _, record := range records { 218 + // Check explicit DID match 219 + if record.Member != "" && record.Member == did { 220 + return true, nil 221 + } 222 + 223 + // Check pattern match (if handle available) 224 + if record.MemberPattern != "" && handle != "" { 225 + if matchPattern(record.MemberPattern, handle) { 226 + return true, nil 227 + } 228 + } 229 + } 230 + 231 + return false, nil 232 + } 233 + 234 + func (s *HoldService) isCrewMember(did, handle string) (bool, error) { 235 + records := listCrewRecords() 236 + 237 + for _, record := range records { 238 + // Check explicit DID match 239 + if record.Member != "" && record.Member == did { 240 + return true, nil 241 + } 242 + 243 + // Check pattern match (if handle available) 244 + if record.MemberPattern != "" && handle != "" { 245 + if matchPattern(record.MemberPattern, handle) { 246 + return true, nil 247 + } 248 + } 249 + } 250 + 251 + return false, nil 252 + } 253 + ``` 254 + 255 + **Pattern matching:** 256 + 257 + ```go 258 + func matchPattern(pattern, handle string) bool { 259 + if pattern == "*" { 260 + return true // Wildcard matches all 261 + } 262 + 263 + // Convert glob pattern to regex 264 + // *.example.com → ^.*\.example\.com$ 265 + // subdomain.* → ^subdomain\..*$ 266 + // *.bsky.* → ^.*\.bsky\..*$ 267 + 268 + regex := globToRegex(pattern) 269 + matched, _ := regexp.MatchString(regex, handle) 270 + return matched 271 + } 272 + ``` 273 + 274 + ## Use Cases 275 + 276 + ### 1. Public Hold (Allow All Users) 277 + 278 + **Goal:** Shared storage for any authenticated ATCR user. 279 + 280 + **Setup:** 281 + ```bash 282 + # Create crew record with wildcard 283 + atproto put-record \ 284 + --collection io.atcr.hold.crew \ 285 + --rkey "all-users" \ 286 + --value '{ 287 + "$type": "io.atcr.hold.crew", 288 + "hold": "at://did:plc:owner/io.atcr.hold/public", 289 + "memberPattern": "*", 290 + "role": "write" 291 + }' 292 + ``` 293 + 294 + **Result:** All authenticated users can push. Owner can selectively bar bad actors. 295 + 296 + ### 2. Community Hold (PDS-Specific) 297 + 298 + **Goal:** Storage for all users from a specific community/PDS. 299 + 300 + **Setup:** 301 + ```bash 302 + # Allow all community members 303 + atproto put-record \ 304 + --collection io.atcr.hold.crew \ 305 + --rkey "community-hold" \ 306 + --value '{ 307 + "$type": "io.atcr.hold.crew", 308 + "hold": "at://did:plc:owner/io.atcr.hold/community", 309 + "memberPattern": "*.my-community.social", 310 + "role": "write" 311 + }' 312 + ``` 313 + 314 + **Result:** Anyone with a `@someone.my-community.social` handle can push. 315 + 316 + ### 3. Team Hold with Selective Banning 317 + 318 + **Goal:** Shared team storage, but remove access from former employees. 319 + 320 + **Setup:** 321 + ```bash 322 + # Allow team domain 323 + atproto put-record \ 324 + --collection io.atcr.hold.crew \ 325 + --rkey "team-hold" \ 326 + --value '{ 327 + "$type": "io.atcr.hold.crew", 328 + "hold": "at://did:plc:owner/io.atcr.hold/team", 329 + "memberPattern": "*.company.com", 330 + "role": "write" 331 + }' 332 + 333 + # Bar former employee 334 + atproto put-record \ 335 + --collection io.atcr.hold.crew.barred \ 336 + --rkey "bar-former-employee" \ 337 + --value '{ 338 + "$type": "io.atcr.hold.crew.barred", 339 + "hold": "at://did:plc:owner/io.atcr.hold/team", 340 + "member": "did:plc:former-employee", 341 + "reason": "No longer with company" 342 + }' 343 + ``` 344 + 345 + **Result:** All `@*.company.com` users can push, except the explicitly barred DID. 346 + 347 + ### 4. Anti-Spam with Barred Patterns 348 + 349 + **Goal:** Public hold with protection against known spam instances. 350 + 351 + **Setup:** 352 + ```bash 353 + # Allow all users 354 + atproto put-record \ 355 + --collection io.atcr.hold.crew \ 356 + --rkey "public-hold" \ 357 + --value '{ 358 + "$type": "io.atcr.hold.crew", 359 + "hold": "at://did:plc:owner/io.atcr.hold/public", 360 + "memberPattern": "*", 361 + "role": "write" 362 + }' 363 + 364 + # Bar spam instance 365 + atproto put-record \ 366 + --collection io.atcr.hold.crew.barred \ 367 + --rkey "bar-spam-pds" \ 368 + --value '{ 369 + "$type": "io.atcr.hold.crew.barred", 370 + "hold": "at://did:plc:owner/io.atcr.hold/public", 371 + "memberPattern": "*.known-spam.com", 372 + "reason": "Spam source" 373 + }' 374 + ``` 375 + 376 + **Result:** Everyone can push except users from `*.known-spam.com`. 377 + 378 + ### 5. Mixed Access (Explicit + Patterns) 379 + 380 + **Goal:** Team pattern plus individual guests. 381 + 382 + **Setup:** 383 + ```bash 384 + # Team pattern 385 + atproto put-record \ 386 + --collection io.atcr.hold.crew \ 387 + --rkey "team-pattern" \ 388 + --value '{ 389 + "$type": "io.atcr.hold.crew", 390 + "hold": "at://did:plc:owner/io.atcr.hold/team", 391 + "memberPattern": "*.company.com", 392 + "role": "write" 393 + }' 394 + 395 + # Individual contractor 396 + atproto put-record \ 397 + --collection io.atcr.hold.crew \ 398 + --rkey "contractor-alice" \ 399 + --value '{ 400 + "$type": "io.atcr.hold.crew", 401 + "hold": "at://did:plc:owner/io.atcr.hold/team", 402 + "member": "did:plc:alice-contractor", 403 + "role": "write" 404 + }' 405 + ``` 406 + 407 + **Result:** Team members + specific contractor all have access. 408 + 409 + ## Implementation Details 410 + 411 + ### Code Changes Required 412 + 413 + **Files to modify:** 414 + 415 + 1. **`lexicons/io/atcr/hold/crew.json`** 416 + - Make `member` optional (remove from `required`) 417 + - Add `memberPattern` field (string, optional) 418 + - Update description 419 + 420 + 2. **`lexicons/io/atcr/hold/crew/barred.json`** (new file) 421 + - Define new lexicon for barred records 422 + - Same structure as crew (member + memberPattern) 423 + - Add `reason` field 424 + 425 + 3. **`pkg/atproto/lexicon.go`** 426 + - Update `HoldCrewRecord` struct (add `MemberPattern` field, make `Member` pointer for optional) 427 + - Add `BarredRecord` struct 428 + - Add `NewBarredRecord()` constructor 429 + - Add `BarredCollection` constant 430 + 431 + 4. **`pkg/hold/authorization.go`** 432 + - Update `isCrewMember()` to check patterns 433 + - Add `isBarred()` function 434 + - Add `resolveHandle()` helper (DID → handle lookup) 435 + - Add `matchPattern()` helper (glob matching) 436 + - Update `isAuthorizedWrite()` to check barred first 437 + 438 + 5. **`pkg/hold/registration.go`** 439 + - Add `HOLD_ALLOW_ALL_CREW` env var handling 440 + - Check env var on every startup (not just first registration) 441 + - Reconcile desired state (env) vs actual state (PDS) 442 + - Create/delete wildcard crew record as needed 443 + 444 + ### Pattern Matching Implementation 445 + 446 + ```go 447 + // pkg/hold/patterns.go (new file) 448 + 449 + package hold 450 + 451 + import ( 452 + "regexp" 453 + "strings" 454 + ) 455 + 456 + // matchPattern checks if a handle matches a pattern 457 + func matchPattern(pattern, handle string) bool { 458 + if pattern == "*" { 459 + return true 460 + } 461 + 462 + // Convert glob to regex 463 + regex := globToRegex(pattern) 464 + matched, err := regexp.MatchString(regex, handle) 465 + if err != nil { 466 + return false 467 + } 468 + return matched 469 + } 470 + 471 + // globToRegex converts a glob pattern to a regex 472 + // *.example.com → ^.*\.example\.com$ 473 + // subdomain.* → ^subdomain\..*$ 474 + // *.bsky.* → ^.*\.bsky\..*$ 475 + func globToRegex(pattern string) string { 476 + // Escape special regex characters except * 477 + escaped := regexp.QuoteMeta(pattern) 478 + 479 + // Replace escaped \* with .* 480 + regex := strings.ReplaceAll(escaped, "\\*", ".*") 481 + 482 + // Anchor to start and end 483 + return "^" + regex + "$" 484 + } 485 + ``` 486 + 487 + ### Handle Resolution 488 + 489 + ```go 490 + // pkg/hold/resolve.go 491 + 492 + package hold 493 + 494 + import ( 495 + "context" 496 + "github.com/bluesky-social/indigo/atproto/identity" 497 + "github.com/bluesky-social/indigo/atproto/syntax" 498 + ) 499 + 500 + // resolveHandle resolves a DID to its current handle 501 + func resolveHandle(did string) (string, error) { 502 + ctx := context.Background() 503 + directory := identity.DefaultDirectory() 504 + 505 + didParsed, err := syntax.ParseDID(did) 506 + if err != nil { 507 + return "", err 508 + } 509 + 510 + ident, err := directory.LookupDID(ctx, didParsed) 511 + if err != nil { 512 + return "", err 513 + } 514 + 515 + return ident.Handle.String(), nil 516 + } 517 + ``` 518 + 519 + ### Caching Considerations 520 + 521 + **Problem:** Pattern matching requires handle resolution, which adds latency. 522 + 523 + **Solution:** Cache handle lookups with TTL. 524 + 525 + ```go 526 + type handleCache struct { 527 + mu sync.RWMutex 528 + cache map[string]cacheEntry // did → handle 529 + } 530 + 531 + type cacheEntry struct { 532 + handle string 533 + expiresAt time.Time 534 + } 535 + 536 + const handleCacheTTL = 10 * time.Minute 537 + 538 + func (c *handleCache) get(did string) (string, bool) { 539 + c.mu.RLock() 540 + defer c.mu.RUnlock() 541 + 542 + entry, ok := c.cache[did] 543 + if !ok || time.Now().After(entry.expiresAt) { 544 + return "", false 545 + } 546 + return entry.handle, true 547 + } 548 + 549 + func (c *handleCache) set(did, handle string) { 550 + c.mu.Lock() 551 + defer c.mu.Unlock() 552 + 553 + c.cache[did] = cacheEntry{ 554 + handle: handle, 555 + expiresAt: time.Now().Add(handleCacheTTL), 556 + } 557 + } 558 + ``` 559 + 560 + **Trade-offs:** 561 + - **Cache hit:** Authorization instant 562 + - **Cache miss:** One additional PDS lookup (acceptable for writes) 563 + - **TTL:** 10 minutes balances freshness vs performance 564 + 565 + ### HOLD_ALLOW_ALL_CREW Environment Variable 566 + 567 + **Purpose:** Automatically manage wildcard crew access via environment variable. 568 + 569 + **Behavior:** Checked on **every startup** (not just first registration): 570 + 571 + 1. **Read env var:** `HOLD_ALLOW_ALL_CREW` (true/false) 572 + 2. **Query PDS:** Check for crew record with rkey `"allow-all"` and `memberPattern: "*"` 573 + 3. **Reconcile state:** 574 + - If env=`true` and record missing → **Create wildcard crew record** (requires OAuth) 575 + - If env=`false` (or unset) and record exists → **Delete wildcard crew record** (requires OAuth) 576 + - Otherwise → No action needed 577 + 578 + **Well-known record key:** `"allow-all"` (used exclusively for the managed wildcard record) 579 + 580 + **Implementation:** 581 + 582 + ```go 583 + // pkg/hold/config.go 584 + type Config struct { 585 + Registration struct { 586 + OwnerDID string 587 + AllowAllCrew bool // HOLD_ALLOW_ALL_CREW 588 + } 589 + // ... 590 + } 591 + 592 + // pkg/hold/registration.go 593 + func (s *HoldService) ReconcileAllowAllCrew(callbackHandler *http.HandlerFunc) error { 594 + desiredState := s.config.Registration.AllowAllCrew 595 + 596 + // Query PDS for "allow-all" crew record 597 + actualState, err := s.hasAllowAllCrewRecord() 598 + if err != nil { 599 + return fmt.Errorf("failed to check allow-all crew record: %w", err) 600 + } 601 + 602 + // States match - nothing to do 603 + if desiredState == actualState { 604 + log.Printf("Allow-all crew state matches desired state: %v", desiredState) 605 + return nil 606 + } 607 + 608 + // State mismatch - need to reconcile 609 + if desiredState && !actualState { 610 + // Need to create wildcard crew record 611 + log.Printf("Creating allow-all crew record (HOLD_ALLOW_ALL_CREW=true)") 612 + return s.createAllowAllCrewRecord(callbackHandler) 613 + } 614 + 615 + if !desiredState && actualState { 616 + // Need to delete wildcard crew record 617 + log.Printf("Deleting allow-all crew record (HOLD_ALLOW_ALL_CREW removed/false)") 618 + return s.deleteAllowAllCrewRecord(callbackHandler) 619 + } 620 + 621 + return nil 622 + } 623 + 624 + func (s *HoldService) hasAllowAllCrewRecord() (bool, error) { 625 + ownerDID := s.config.Registration.OwnerDID 626 + if ownerDID == "" { 627 + return false, fmt.Errorf("hold owner DID not configured") 628 + } 629 + 630 + ctx := context.Background() 631 + 632 + // Resolve owner's PDS 633 + pdsEndpoint, err := s.resolveOwnerPDS(ownerDID) 634 + if err != nil { 635 + return false, err 636 + } 637 + 638 + // Query for specific rkey 639 + client := atproto.NewClient(pdsEndpoint, ownerDID, "") 640 + record, err := client.GetRecord(ctx, atproto.HoldCrewCollection, "allow-all") 641 + 642 + if err != nil { 643 + // Record doesn't exist 644 + return false, nil 645 + } 646 + 647 + // Verify it's the wildcard record (memberPattern: "*") 648 + var crewRecord atproto.HoldCrewRecord 649 + if err := json.Unmarshal(record.Value, &crewRecord); err != nil { 650 + return false, err 651 + } 652 + 653 + // Check if it's the exact wildcard pattern 654 + return crewRecord.MemberPattern == "*", nil 655 + } 656 + 657 + func (s *HoldService) createAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error { 658 + // This requires OAuth - reuse registration OAuth flow 659 + // Need authenticated client to create record 660 + 661 + ownerDID := s.config.Registration.OwnerDID 662 + pdsEndpoint, err := s.resolveOwnerPDS(ownerDID) 663 + if err != nil { 664 + return err 665 + } 666 + 667 + // Get handle for OAuth 668 + handle, err := resolveHandleFromDID(ownerDID) 669 + if err != nil { 670 + return err 671 + } 672 + 673 + // Run OAuth flow (similar to registration) 674 + ctx := context.Background() 675 + result, err := oauth.InteractiveFlowWithCallback( 676 + ctx, 677 + s.config.Server.PublicURL, 678 + handle, 679 + s.getCrewManagementScopes(), 680 + func(handler http.HandlerFunc) error { 681 + *callbackHandler = handler 682 + return nil 683 + }, 684 + func(authURL string) error { 685 + log.Printf("\n%s", strings.Repeat("=", 80)) 686 + log.Printf("OAUTH REQUIRED: Creating allow-all crew record") 687 + log.Printf("%s", strings.Repeat("=", 80)) 688 + log.Printf("\nVisit: %s\n", authURL) 689 + log.Printf("Waiting for authorization...") 690 + log.Printf("%s\n", strings.Repeat("=", 80)) 691 + return nil 692 + }, 693 + ) 694 + if err != nil { 695 + return err 696 + } 697 + 698 + // Create authenticated client 699 + apiClient := result.Session.APIClient() 700 + client := atproto.NewClientWithIndigoClient(pdsEndpoint, ownerDID, apiClient) 701 + 702 + // Get hold URI (need to know which hold to grant access to) 703 + holdURI, err := s.getHoldURI() 704 + if err != nil { 705 + return err 706 + } 707 + 708 + // Create wildcard crew record 709 + crewRecord := atproto.HoldCrewRecord{ 710 + Type: atproto.HoldCrewCollection, 711 + Hold: holdURI, 712 + MemberPattern: ptr("*"), // Wildcard - allow all 713 + Role: "write", 714 + CreatedAt: time.Now(), 715 + } 716 + 717 + _, err = client.PutRecord(ctx, atproto.HoldCrewCollection, "allow-all", &crewRecord) 718 + if err != nil { 719 + return fmt.Errorf("failed to create allow-all crew record: %w", err) 720 + } 721 + 722 + log.Printf("✓ Created allow-all crew record (allows all authenticated users)") 723 + return nil 724 + } 725 + 726 + func (s *HoldService) deleteAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error { 727 + // Similar OAuth flow for deletion 728 + // Only delete if it's the exact wildcard pattern (safety check) 729 + 730 + isWildcard, err := s.hasAllowAllCrewRecord() 731 + if err != nil { 732 + return err 733 + } 734 + 735 + if !isWildcard { 736 + log.Printf("Warning: 'allow-all' crew record exists but is not wildcard - skipping deletion") 737 + return nil 738 + } 739 + 740 + // OAuth flow (same as create) 741 + ownerDID := s.config.Registration.OwnerDID 742 + pdsEndpoint, err := s.resolveOwnerPDS(ownerDID) 743 + if err != nil { 744 + return err 745 + } 746 + 747 + handle, err := resolveHandleFromDID(ownerDID) 748 + if err != nil { 749 + return err 750 + } 751 + 752 + ctx := context.Background() 753 + result, err := oauth.InteractiveFlowWithCallback( 754 + ctx, 755 + s.config.Server.PublicURL, 756 + handle, 757 + s.getCrewManagementScopes(), 758 + func(handler http.HandlerFunc) error { 759 + *callbackHandler = handler 760 + return nil 761 + }, 762 + func(authURL string) error { 763 + log.Printf("\n%s", strings.Repeat("=", 80)) 764 + log.Printf("OAUTH REQUIRED: Deleting allow-all crew record") 765 + log.Printf("%s", strings.Repeat("=", 80)) 766 + log.Printf("\nVisit: %s\n", authURL) 767 + log.Printf("Waiting for authorization...") 768 + log.Printf("%s\n", strings.Repeat("=", 80)) 769 + return nil 770 + }, 771 + ) 772 + if err != nil { 773 + return err 774 + } 775 + 776 + // Create authenticated client 777 + apiClient := result.Session.APIClient() 778 + client := atproto.NewClientWithIndigoClient(pdsEndpoint, ownerDID, apiClient) 779 + 780 + // Delete the record 781 + err = client.DeleteRecord(ctx, atproto.HoldCrewCollection, "allow-all") 782 + if err != nil { 783 + return fmt.Errorf("failed to delete allow-all crew record: %w", err) 784 + } 785 + 786 + log.Printf("✓ Deleted allow-all crew record") 787 + return nil 788 + } 789 + 790 + func (s *HoldService) getCrewManagementScopes() []string { 791 + return []string{ 792 + "atproto", 793 + fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection), 794 + fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection), 795 + fmt.Sprintf("repo:%s?action=delete", atproto.HoldCrewCollection), 796 + } 797 + } 798 + 799 + // Helper for pointer 800 + func ptr(s string) *string { 801 + return &s 802 + } 803 + ``` 804 + 805 + **Startup sequence:** 806 + 807 + ```go 808 + // cmd/hold/main.go 809 + func main() { 810 + // ... load config ... 811 + 812 + holdService := hold.NewHoldService(config) 813 + 814 + // Register HTTP routes 815 + var oauthCallbackHandler http.HandlerFunc 816 + http.HandleFunc("/auth/oauth/callback", func(w http.ResponseWriter, r *http.Request) { 817 + if oauthCallbackHandler != nil { 818 + oauthCallbackHandler(w, r) 819 + } else { 820 + http.Error(w, "OAuth callback not initialized", http.StatusInternalServerError) 821 + } 822 + }) 823 + 824 + // Auto-register hold (if HOLD_OWNER set) 825 + if config.Registration.OwnerDID != "" { 826 + err := holdService.AutoRegister(&oauthCallbackHandler) 827 + if err != nil { 828 + log.Fatalf("Failed to register hold: %v", err) 829 + } 830 + 831 + // Reconcile allow-all crew record 832 + err = holdService.ReconcileAllowAllCrew(&oauthCallbackHandler) 833 + if err != nil { 834 + log.Fatalf("Failed to reconcile allow-all crew: %v", err) 835 + } 836 + } 837 + 838 + // Start server... 839 + } 840 + ``` 841 + 842 + **Key properties:** 843 + 844 + 1. **Idempotent:** Safe to run on every startup 845 + 2. **Well-known rkey:** Uses `"allow-all"` exclusively for managed record 846 + 3. **Safety:** Only deletes if `memberPattern` is exactly `"*"` (won't touch custom patterns like `*.example.com`) 847 + 4. **OAuth required:** Both create and delete operations need authentication 848 + 5. **Reuses infrastructure:** Same OAuth flow as registration 849 + 850 + **Example configurations:** 851 + 852 + ```bash 853 + # Public hold - allow all users 854 + HOLD_ALLOW_ALL_CREW=true 855 + 856 + # Private hold - explicit crew only 857 + HOLD_ALLOW_ALL_CREW=false 858 + # (or omit the variable entirely) 859 + ``` 860 + 861 + **Edge cases handled:** 862 + 863 + - Record exists with different pattern → Won't delete (safety) 864 + - OAuth fails → Service won't start (explicit failure) 865 + - PDS unreachable → Startup fails (can't verify state) 866 + - Record exists but env unset → Deletes wildcard (opt-in behavior) 867 + 868 + **Custom patterns preserved:** 869 + 870 + Hold owners can still manually create pattern-based crew records with different rkeys: 871 + 872 + ```bash 873 + # Manually created pattern (rkey: "community") 874 + atproto put-record \ 875 + --collection io.atcr.hold.crew \ 876 + --rkey "community" \ 877 + --value '{ 878 + "memberPattern": "*.my-community.social", 879 + "role": "write" 880 + }' 881 + ``` 882 + 883 + The `HOLD_ALLOW_ALL_CREW` management **only touches** the `"allow-all"` rkey with exact `memberPattern: "*"`. 884 + 885 + ## Migration Path 886 + 887 + **Backward Compatibility:** Fully compatible with existing deployments. 888 + 889 + 1. **Existing crew records work unchanged** 890 + - Records with `member` (DID) continue to work 891 + - No changes needed to existing records 892 + 893 + 2. **Opt-in patterns** 894 + - Hold owners can add pattern-based crew records 895 + - Mix explicit DIDs and patterns freely 896 + 897 + 3. **Barred list is optional** 898 + - Only needed for selective access revocation 899 + - Empty barred list = no blocking 900 + 901 + 4. **Lexicon evolution** 902 + - Making `member` optional is backward compatible (existing records still have it) 903 + - Adding `memberPattern` is additive (old clients ignore it) 904 + 905 + ## Future Enhancements 906 + 907 + ### 1. PDS-Based Access Control 908 + 909 + **Goal:** Allow/bar users based on their PDS (not handle). 910 + 911 + **Challenge:** ATProto doesn't give PDSes stable identifiers. PDS endpoints are mutable URLs. 912 + 913 + **Potential Solutions:** 914 + 915 + #### Option A: PDS DID Standard (if ATProto adds it) 916 + 917 + If ATProto introduces PDS DIDs: 918 + 919 + ```json 920 + { 921 + "$type": "io.atcr.hold.crew", 922 + "hold": "at://did:plc:owner/io.atcr.hold/community", 923 + "memberPattern": "pds:did:plc:pds-id", 924 + "role": "write" 925 + } 926 + ``` 927 + 928 + #### Option B: Accept PDS URL Mutability 929 + 930 + Store PDS URLs with understanding they can change: 931 + 932 + ```json 933 + { 934 + "$type": "io.atcr.hold.crew", 935 + "hold": "at://did:plc:owner/io.atcr.hold/community", 936 + "memberPattern": "pds:https://my-community.social", 937 + "role": "write" 938 + } 939 + ``` 940 + 941 + **Trade-off:** User migration bypasses access control, but this requires effort. 942 + 943 + #### Option C: PDS Trust Lists (Federated Model) 944 + 945 + Reference curated lists of trusted PDSes: 946 + 947 + ```json 948 + { 949 + "$type": "io.atcr.hold.crew", 950 + "hold": "at://did:plc:owner/io.atcr.hold/community", 951 + "memberPattern": "trust-list:at://did:plc:curator/trust.list/vetted-pds", 952 + "role": "write" 953 + } 954 + ``` 955 + 956 + **Status:** Experimental. Requires additional standards. 957 + 958 + ### 2. Advanced Pattern Matching 959 + 960 + **Goal:** Support more sophisticated patterns. 961 + 962 + **Potential patterns:** 963 + 964 + - **Regex:** `memberPattern: "regex:^eng-.*@company.com$"` 965 + - **Multiple patterns:** `memberPattern: ["*.example.com", "*.other.com"]` 966 + - **NOT patterns:** `memberPattern: "!*.spam.com"` (everything except) 967 + 968 + **Implementation:** Extend `matchPattern()` function with pattern type detection. 969 + 970 + ### 3. Temporary Access 971 + 972 + **Goal:** Time-limited crew membership. 973 + 974 + **Current support:** `expiresAt` field already in schema (optional). 975 + 976 + **Enhancement:** Hold service automatically checks expiration during authorization: 977 + 978 + ```go 979 + if record.ExpiresAt != nil && time.Now().After(*record.ExpiresAt) { 980 + continue // Skip expired crew record 981 + } 982 + ``` 983 + 984 + ### 4. Role-Based Access Control (RBAC) 985 + 986 + **Goal:** Fine-grained permissions beyond read/write. 987 + 988 + **Potential roles:** 989 + - `"read"` - Pull only 990 + - `"write"` - Push + pull 991 + - `"admin"` - Manage crew records 992 + - `"owner"` - Full control 993 + 994 + **Current status:** `role` field exists but only `"owner"` and `"write"` are used. 995 + 996 + ### 5. Audit Logging 997 + 998 + **Goal:** Track access grants/denials for compliance. 999 + 1000 + **Implementation:** 1001 + - Log crew checks to structured log 1002 + - Include: DID, handle, result (allow/deny), reason 1003 + - Optional: Write to ATProto audit log record 1004 + 1005 + ## Security Considerations 1006 + 1007 + ### 1. Public Records 1008 + 1009 + **Consideration:** Crew and barred records are public ATProto records. 1010 + 1011 + **Implications:** 1012 + - Anyone can see who has access to a hold 1013 + - Anyone can see who is barred (and why) 1014 + - Similar to Bluesky block lists being public 1015 + 1016 + **Mitigation:** This is intentional transparency. Hold owners should use generic reasons in barred records if privacy is a concern. 1017 + 1018 + ### 2. Handle Changes 1019 + 1020 + **Consideration:** Handles can change, but DIDs are permanent. 1021 + 1022 + **Implications:** 1023 + - Pattern matching based on handles can be bypassed by changing handle 1024 + - DID-based rules are more stable 1025 + - However, changing handles or acquiring new domains requires significant effort: 1026 + - Purchasing new domain names ($10-100+/year) 1027 + - Updating identity across platforms 1028 + - Loss of established reputation/identity 1029 + 1030 + **Recommendation:** 1031 + - Use DID-based crew/barred records for critical access control (permanent) 1032 + - Use pattern-based rules for convenience and community management 1033 + - The effort required to bypass handle patterns makes them an acceptable deterrent 1034 + - Combine both approaches for defense in depth 1035 + 1036 + ### 3. PDS Migration 1037 + 1038 + **Consideration:** Users can migrate to different PDSes. 1039 + 1040 + **Implications:** 1041 + - PDS-based patterns (future) can be bypassed by migration 1042 + - Handle patterns persist across PDS migration (if handle stays same) 1043 + 1044 + **Recommendation:** Accept this as inherent trade-off. Migration requires user effort and is acceptable "escape hatch." 1045 + 1046 + ### 4. Pattern Matching Performance 1047 + 1048 + **Consideration:** Complex patterns could cause ReDoS (regex denial of service). 1049 + 1050 + **Mitigation:** 1051 + - Limit pattern complexity (only basic globs in v1) 1052 + - Cache handle lookups to minimize repeated work 1053 + - Set timeout on pattern matching operations 1054 + 1055 + ### 5. Barred List Circumvention 1056 + 1057 + **Consideration:** Barred users might create new DIDs. 1058 + 1059 + **Mitigation:** 1060 + - This is fundamental to decentralized identity (users control DIDs) 1061 + - Hold owners can add new DIDs to barred list as discovered 1062 + - Pattern-based barring (handle/PDS patterns) provides broader coverage 1063 + 1064 + ## Testing Strategy 1065 + 1066 + ### Unit Tests 1067 + 1068 + **Pattern matching:** 1069 + ```go 1070 + func TestMatchPattern(t *testing.T) { 1071 + tests := []struct{ 1072 + pattern string 1073 + handle string 1074 + want bool 1075 + }{ 1076 + {"*", "anything.com", true}, 1077 + {"*.example.com", "alice.example.com", true}, 1078 + {"*.example.com", "bob.other.com", false}, 1079 + {"eng.*", "eng.company.com", true}, 1080 + {"eng.*", "sales.company.com", false}, 1081 + } 1082 + // ... 1083 + } 1084 + ``` 1085 + 1086 + **Authorization logic:** 1087 + ```go 1088 + func TestIsAuthorizedWrite(t *testing.T) { 1089 + // Test: owner always allowed 1090 + // Test: explicit crew member allowed 1091 + // Test: pattern match allowed 1092 + // Test: barred user denied 1093 + // Test: barred pattern denied 1094 + // Test: barred overrides crew 1095 + } 1096 + ``` 1097 + 1098 + ### Integration Tests 1099 + 1100 + 1. **Create hold with wildcard crew** → verify any user can write 1101 + 2. **Add barred record** → verify barred user rejected 1102 + 3. **Pattern-based crew** → verify matching handles allowed 1103 + 4. **Mixed access** → verify explicit + pattern both work 1104 + 5. **Handle resolution failure** → verify fallback to DID-only matching 1105 + 1106 + ### Performance Tests 1107 + 1108 + 1. **Large crew list** (1000+ records) → measure query time 1109 + 2. **Complex patterns** → measure pattern matching time 1110 + 3. **Handle cache** → verify cache hit rate 1111 + 4. **Concurrent requests** → verify no race conditions 1112 + 1113 + ## References 1114 + 1115 + - [ATProto Lexicon Spec](https://atproto.com/specs/lexicon) 1116 + - [Bluesky Block Lists](https://bsky.app/profile/bsky.app/post/3l7wzyc6i622o) (analogous public records) 1117 + - [Go Glob Matching](https://pkg.go.dev/path/filepath#Match) 1118 + - [OAuth Scopes](https://atproto.com/specs/oauth#scopes) (for crew management permissions) 1119 + 1120 + ## Appendix: Lexicon Definitions 1121 + 1122 + ### lexicons/io/atcr/hold/crew.json (Updated) 1123 + 1124 + ```json 1125 + { 1126 + "lexicon": 1, 1127 + "id": "io.atcr.hold.crew", 1128 + "defs": { 1129 + "main": { 1130 + "type": "record", 1131 + "description": "Crew membership for a storage hold. Stored in the hold owner's PDS to maintain control over write access. Supports explicit DIDs (with backlinks), wildcard access, and handle patterns.", 1132 + "key": "any", 1133 + "record": { 1134 + "type": "object", 1135 + "required": ["hold", "role", "createdAt"], 1136 + "properties": { 1137 + "hold": { 1138 + "type": "string", 1139 + "format": "at-uri", 1140 + "description": "AT-URI of the hold record (e.g., 'at://did:plc:owner/io.atcr.hold/hold1')" 1141 + }, 1142 + "member": { 1143 + "type": "string", 1144 + "format": "did", 1145 + "description": "DID of crew member (for individual access with backlinks). Exactly one of 'member' or 'memberPattern' must be set." 1146 + }, 1147 + "memberPattern": { 1148 + "type": "string", 1149 + "description": "Pattern for matching multiple users. Supports wildcards: '*' (all users), '*.domain.com' (handle glob). Exactly one of 'member' or 'memberPattern' must be set." 1150 + }, 1151 + "role": { 1152 + "type": "string", 1153 + "description": "Member's role/permissions. 'owner' = hold owner, 'write' = can push blobs.", 1154 + "knownValues": ["owner", "write"] 1155 + }, 1156 + "expiresAt": { 1157 + "type": "string", 1158 + "format": "datetime", 1159 + "description": "Optional expiration for this membership" 1160 + }, 1161 + "createdAt": { 1162 + "type": "string", 1163 + "format": "datetime", 1164 + "description": "Membership creation timestamp" 1165 + } 1166 + } 1167 + } 1168 + } 1169 + } 1170 + } 1171 + ``` 1172 + 1173 + ### lexicons/io/atcr/hold/crew/barred.json (New) 1174 + 1175 + ```json 1176 + { 1177 + "lexicon": 1, 1178 + "id": "io.atcr.hold.crew.barred", 1179 + "defs": { 1180 + "main": { 1181 + "type": "record", 1182 + "description": "Barred (banned) list for a storage hold. Users/patterns in this list are denied write access, overriding crew membership. Stored in the hold owner's PDS.", 1183 + "key": "any", 1184 + "record": { 1185 + "type": "object", 1186 + "required": ["hold", "barredAt"], 1187 + "properties": { 1188 + "hold": { 1189 + "type": "string", 1190 + "format": "at-uri", 1191 + "description": "AT-URI of the hold record" 1192 + }, 1193 + "member": { 1194 + "type": "string", 1195 + "format": "did", 1196 + "description": "DID of user to bar. Exactly one of 'member' or 'memberPattern' must be set." 1197 + }, 1198 + "memberPattern": { 1199 + "type": "string", 1200 + "description": "Pattern for barring multiple users. Supports wildcards: '*.spam.com', 'bot*', etc. Exactly one of 'member' or 'memberPattern' must be set." 1201 + }, 1202 + "reason": { 1203 + "type": "string", 1204 + "maxLength": 300, 1205 + "description": "Optional human-readable reason for barring (e.g., 'spam', 'abuse', 'policy violation')" 1206 + }, 1207 + "barredAt": { 1208 + "type": "string", 1209 + "format": "datetime", 1210 + "description": "When the user/pattern was barred" 1211 + } 1212 + } 1213 + } 1214 + } 1215 + } 1216 + } 1217 + ``` 1218 + 1219 + ## Summary 1220 + 1221 + This design enables scalable, flexible access control for ATCR holds while: 1222 + 1223 + - **Preserving ATProto semantics** (DID backlinks, public records) 1224 + - **Supporting massive scale** (one record for thousands of users) 1225 + - **Enabling selective revocation** (barred list) 1226 + - **Maintaining backward compatibility** (existing records work unchanged) 1227 + - **Planning for future enhancements** (PDS-based filtering when possible) 1228 + 1229 + --- 1230 + 1231 + **Note on terminology:** "Barred" is an ironic reversal of the idiom "no holds barred" (meaning "without restrictions"). In wrestling, when all holds are allowed, it's unrestricted. In ATCR, being "barred from a hold" means you're restricted from access. The pun works in reverse! 🥁
+7 -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 write access. Crew members can push blobs to the hold. Read access is controlled by the hold's public flag, not crew membership.", 7 + "description": "Crew membership for a storage hold. Stored in the hold owner's PDS to maintain control over write access. Supports explicit DIDs (with backlinks), wildcard access, and handle patterns. 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", 11 - "required": ["hold", "member", "role", "createdAt"], 11 + "required": ["hold", "role", "createdAt"], 12 12 "properties": { 13 13 "hold": { 14 14 "type": "string", ··· 18 18 "member": { 19 19 "type": "string", 20 20 "format": "did", 21 - "description": "DID of the crew member who can use this hold" 21 + "description": "DID of crew member (for individual access with backlinks). Exactly one of 'member' or 'memberPattern' must be set." 22 + }, 23 + "memberPattern": { 24 + "type": "string", 25 + "description": "Pattern for matching multiple users. Supports wildcards: '*' (all users), '*.domain.com' (handle glob). Exactly one of 'member' or 'memberPattern' must be set." 22 26 }, 23 27 "role": { 24 28 "type": "string",
+25 -4
pkg/atproto/lexicon.go
··· 205 205 // HoldCrewRecord represents membership in a storage hold 206 206 // Stored in the hold owner's PDS (not the crew member's PDS) to ensure owner maintains full control 207 207 // Owner can add/remove crew members by creating/deleting these records in their own PDS 208 + // Supports both explicit DIDs (with backlinks) and pattern-based matching (wildcards, handle globs) 208 209 type HoldCrewRecord struct { 209 210 // Type should be "io.atcr.hold.crew" 210 211 Type string `json:"$type"` ··· 213 214 // e.g., "at://did:plc:owner/io.atcr.hold/hold1" 214 215 Hold string `json:"hold"` 215 216 216 - // Member is the DID of the crew member 217 - Member string `json:"member"` 217 + // Member is the DID of the crew member (optional, for explicit access) 218 + // Exactly one of Member or MemberPattern must be set 219 + Member *string `json:"member,omitempty"` 220 + 221 + // MemberPattern is a pattern for matching multiple users (optional, for pattern-based access) 222 + // Supports wildcards: "*" (all users), "*.domain.com" (handle glob) 223 + // Exactly one of Member or MemberPattern must be set 224 + MemberPattern *string `json:"memberPattern,omitempty"` 218 225 219 226 // Role defines permissions: "owner", "write", "read" 220 227 Role string `json:"role"` 221 228 229 + // ExpiresAt is optional expiration for this membership 230 + ExpiresAt *time.Time `json:"expiresAt,omitempty"` 231 + 222 232 // AddedAt timestamp 223 233 AddedAt time.Time `json:"createdAt"` 224 234 } 225 235 226 - // NewHoldCrewRecord creates a new hold crew record 236 + // NewHoldCrewRecord creates a new hold crew record with explicit DID 227 237 func NewHoldCrewRecord(hold, member, role string) *HoldCrewRecord { 228 238 return &HoldCrewRecord{ 229 239 Type: HoldCrewCollection, 230 240 Hold: hold, 231 - Member: member, 241 + Member: &member, 232 242 Role: role, 233 243 AddedAt: time.Now(), 244 + } 245 + } 246 + 247 + // NewHoldCrewRecordWithPattern creates a new hold crew record with pattern matching 248 + func NewHoldCrewRecordWithPattern(hold, pattern, role string) *HoldCrewRecord { 249 + return &HoldCrewRecord{ 250 + Type: HoldCrewCollection, 251 + Hold: hold, 252 + MemberPattern: &pattern, 253 + Role: role, 254 + AddedAt: time.Now(), 234 255 } 235 256 } 236 257
+37 -3
pkg/hold/authorization.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log" 8 + "time" 8 9 9 10 "atcr.io/pkg/atproto" 10 11 "github.com/bluesky-social/indigo/atproto/identity" ··· 79 80 } 80 81 81 82 // isCrewMember checks if a DID is a crew member of this hold 83 + // Supports both explicit DID matching and pattern-based matching (wildcards, handle globs) 82 84 func (s *HoldService) isCrewMember(did string) (bool, error) { 83 85 ownerDID := s.config.Registration.OwnerDID 84 86 if ownerDID == "" { ··· 114 116 return false, fmt.Errorf("failed to list crew records: %w", err) 115 117 } 116 118 117 - // Check if DID is in crew list 119 + // Resolve handle once for pattern matching (lazily, only if needed) 120 + var handle string 121 + var handleResolved bool 122 + 123 + // Check crew records for both explicit DID and pattern matches 118 124 for _, record := range records { 119 125 var crewRecord atproto.HoldCrewRecord 120 126 if err := json.Unmarshal(record.Value, &crewRecord); err != nil { 121 127 continue 122 128 } 123 129 124 - if crewRecord.Member == did { 125 - // Found crew membership 130 + // Check expiration (if set) 131 + if crewRecord.ExpiresAt != nil && time.Now().After(*crewRecord.ExpiresAt) { 132 + continue // Skip expired membership 133 + } 134 + 135 + // Check explicit DID match 136 + if crewRecord.Member != nil && *crewRecord.Member == did { 137 + // Found explicit crew membership 126 138 return true, nil 139 + } 140 + 141 + // Check pattern match (if pattern is set) 142 + if crewRecord.MemberPattern != nil && *crewRecord.MemberPattern != "" { 143 + // Lazy handle resolution - only resolve if we encounter a pattern 144 + if !handleResolved { 145 + handle, err = resolveHandle(did) 146 + if err != nil { 147 + log.Printf("Warning: failed to resolve handle for DID %s: %v", did, err) 148 + // Continue checking explicit DIDs even if handle resolution fails 149 + handleResolved = true // Mark as attempted (don't retry) 150 + handle = "" // Empty handle won't match patterns 151 + } else { 152 + handleResolved = true 153 + } 154 + } 155 + 156 + // If we have a handle, check pattern match 157 + if handle != "" && matchPattern(*crewRecord.MemberPattern, handle) { 158 + // Found pattern-based crew membership 159 + return true, nil 160 + } 127 161 } 128 162 } 129 163
+6
pkg/hold/config.go
··· 21 21 // OwnerDID is the owner's ATProto DID (from env: HOLD_OWNER) 22 22 // If set, auto-registration is enabled 23 23 OwnerDID string `yaml:"owner_did"` 24 + 25 + // AllowAllCrew controls whether to create a wildcard crew record (from env: HOLD_ALLOW_ALL_CREW) 26 + // If true, creates/maintains a crew record with memberPattern: "*" (allows all authenticated users) 27 + // If false, deletes the wildcard crew record if it exists 28 + AllowAllCrew bool `yaml:"allow_all_crew"` 24 29 } 25 30 26 31 // StorageConfig wraps distribution's storage configuration ··· 72 77 73 78 // Registration configuration (optional) 74 79 cfg.Registration.OwnerDID = os.Getenv("HOLD_OWNER") 80 + cfg.Registration.AllowAllCrew = os.Getenv("HOLD_ALLOW_ALL_CREW") == "true" 75 81 76 82 // Storage configuration - build from env vars based on storage type 77 83 storageType := getEnvOrDefault("STORAGE_DRIVER", "s3")
+40
pkg/hold/patterns.go
··· 1 + package hold 2 + 3 + import ( 4 + "regexp" 5 + "strings" 6 + ) 7 + 8 + // matchPattern checks if a handle matches a pattern 9 + // Supports wildcards: "*" (all), "*.domain.com" (suffix), "prefix.*" (prefix), "*.mid.*" (contains) 10 + func matchPattern(pattern, handle string) bool { 11 + if pattern == "*" { 12 + // Wildcard matches all 13 + return true 14 + } 15 + 16 + // Convert glob to regex and match 17 + regex := globToRegex(pattern) 18 + matched, err := regexp.MatchString(regex, handle) 19 + if err != nil { 20 + // Log error but fail closed (don't grant access on regex error) 21 + return false 22 + } 23 + return matched 24 + } 25 + 26 + // globToRegex converts a glob pattern to a regex pattern 27 + // Examples: 28 + // - "*.example.com" → "^.*\.example\.com$" 29 + // - "subdomain.*" → "^subdomain\..*$" 30 + // - "*.bsky.*" → "^.*\.bsky\..*$" 31 + func globToRegex(pattern string) string { 32 + // Escape special regex characters (except *) 33 + escaped := regexp.QuoteMeta(pattern) 34 + 35 + // Replace escaped \* with .* 36 + regex := strings.ReplaceAll(escaped, "\\*", ".*") 37 + 38 + // Anchor to start and end 39 + return "^" + regex + "$" 40 + }
+247 -60
pkg/hold/registration.go
··· 115 115 116 116 // registerWithOAuth performs OAuth flow and registers the hold 117 117 func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string, callbackHandler *http.HandlerFunc) error { 118 - // Define the scopes we need for hold registration 119 - holdScopes := []string{ 120 - "atproto", 121 - fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection), 122 - fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection), 123 - fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection), 124 - fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection), 125 - fmt.Sprintf("repo:%s?action=create", atproto.SailorProfileCollection), 126 - fmt.Sprintf("repo:%s?action=update", atproto.SailorProfileCollection), 127 - } 128 - 129 - // Determine base URL based on mode 130 - // Callback path standardized to /auth/oauth/callback across ATCR 131 - var baseURL string 132 - 133 - if s.config.Server.TestMode { 134 - // Test mode: Use localhost for OAuth (browser accessible) but store real URL in hold record 135 - // Extract port from publicURL (e.g., "http://172.28.0.3:8080" -> ":8080") 136 - parsedURL, err := url.Parse(publicURL) 137 - if err != nil { 138 - return fmt.Errorf("failed to parse public URL: %w", err) 139 - } 140 - port := parsedURL.Port() 141 - if port == "" { 142 - port = "8080" // default 143 - } 144 - baseURL = fmt.Sprintf("http://127.0.0.1:%s", port) 145 - } else { 146 - baseURL = publicURL 147 - } 148 - 149 - // Run interactive OAuth flow with persistent server 150 - ctx := context.Background() 151 - 152 - result, err := oauth.InteractiveFlowWithCallback( 153 - ctx, 154 - baseURL, 155 - handle, 156 - holdScopes, // Pass hold-specific scopes 157 - func(handler http.HandlerFunc) error { 158 - // Populate the pre-registered callback handler 159 - *callbackHandler = handler 160 - return nil 161 - }, 162 - func(authURL string) error { 163 - // Display OAuth URL for user to visit 164 - log.Print("\n" + strings.Repeat("=", 80)) 165 - log.Printf("OAUTH AUTHORIZATION REQUIRED") 166 - log.Print(strings.Repeat("=", 80)) 167 - log.Printf("\nPlease visit this URL to authorize the hold service:\n") 168 - log.Printf(" %s\n", authURL) 169 - log.Printf("Waiting for authorization...") 170 - log.Print(strings.Repeat("=", 80) + "\n") 171 - return nil 172 - }, 173 - ) 118 + // Run OAuth flow to get authenticated client 119 + client, err := s.runOAuthFlow(callbackHandler, "Hold service registration") 174 120 if err != nil { 175 121 return err 176 122 } ··· 179 125 log.Printf("OAuth session obtained successfully") 180 126 log.Printf("DID: %s", did) 181 127 log.Printf("PDS: %s", pdsEndpoint) 182 - 183 - // Create ATProto client with indigo's API client (handles DPoP automatically) 184 - apiClient := result.Session.APIClient() 185 - client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient) 186 128 187 129 return s.registerWithClient(publicURL, did, client) 188 130 } ··· 265 207 } 266 208 return hostname, nil 267 209 } 210 + 211 + // ReconcileAllowAllCrew reconciles the allow-all crew record state with the environment variable 212 + // Called on every startup to ensure the PDS record matches the desired configuration 213 + func (s *HoldService) ReconcileAllowAllCrew(callbackHandler *http.HandlerFunc) error { 214 + ownerDID := s.config.Registration.OwnerDID 215 + if ownerDID == "" { 216 + // No owner DID configured, skip reconciliation 217 + return nil 218 + } 219 + 220 + desiredState := s.config.Registration.AllowAllCrew 221 + 222 + log.Printf("Checking allow-all crew state (desired: %v)", desiredState) 223 + 224 + // Query PDS for current state 225 + actualState, err := s.hasAllowAllCrewRecord() 226 + if err != nil { 227 + return fmt.Errorf("failed to check allow-all crew record: %w", err) 228 + } 229 + 230 + log.Printf("Allow-all crew record exists: %v", actualState) 231 + 232 + // States match - nothing to do 233 + if desiredState == actualState { 234 + if desiredState { 235 + log.Printf("✓ Allow-all crew enabled (all authenticated users can push)") 236 + } else { 237 + log.Printf("✓ Allow-all crew disabled (explicit crew membership required)") 238 + } 239 + return nil 240 + } 241 + 242 + // State mismatch - need to reconcile 243 + if desiredState && !actualState { 244 + // Need to create wildcard crew record 245 + log.Printf("Creating allow-all crew record (HOLD_ALLOW_ALL_CREW=true)") 246 + return s.createAllowAllCrewRecord(callbackHandler) 247 + } 248 + 249 + if !desiredState && actualState { 250 + // Need to delete wildcard crew record 251 + log.Printf("Deleting allow-all crew record (HOLD_ALLOW_ALL_CREW=false)") 252 + return s.deleteAllowAllCrewRecord(callbackHandler) 253 + } 254 + 255 + return nil 256 + } 257 + 258 + // hasAllowAllCrewRecord checks if the allow-all crew record exists in the PDS 259 + func (s *HoldService) hasAllowAllCrewRecord() (bool, error) { 260 + ownerDID := s.config.Registration.OwnerDID 261 + if ownerDID == "" { 262 + return false, fmt.Errorf("hold owner DID not configured") 263 + } 264 + 265 + ctx := context.Background() 266 + 267 + // Resolve owner's PDS endpoint 268 + directory := identity.DefaultDirectory() 269 + ownerDIDParsed, err := syntax.ParseDID(ownerDID) 270 + if err != nil { 271 + return false, fmt.Errorf("invalid owner DID: %w", err) 272 + } 273 + 274 + ident, err := directory.LookupDID(ctx, ownerDIDParsed) 275 + if err != nil { 276 + return false, fmt.Errorf("failed to resolve owner PDS: %w", err) 277 + } 278 + 279 + pdsEndpoint := ident.PDSEndpoint() 280 + if pdsEndpoint == "" { 281 + return false, fmt.Errorf("no PDS endpoint found for owner") 282 + } 283 + 284 + // Create unauthenticated client to read public records 285 + client := atproto.NewClient(pdsEndpoint, ownerDID, "") 286 + 287 + // Query for specific rkey "allow-all" 288 + record, err := client.GetRecord(ctx, atproto.HoldCrewCollection, "allow-all") 289 + if err != nil { 290 + // Record doesn't exist 291 + if strings.Contains(err.Error(), "not found") { 292 + return false, nil 293 + } 294 + return false, fmt.Errorf("failed to get crew record: %w", err) 295 + } 296 + 297 + // Verify it's the wildcard record (memberPattern: "*") 298 + var crewRecord atproto.HoldCrewRecord 299 + if err := json.Unmarshal(record.Value, &crewRecord); err != nil { 300 + return false, fmt.Errorf("failed to unmarshal crew record: %w", err) 301 + } 302 + 303 + // Check if it's the exact wildcard pattern 304 + return crewRecord.MemberPattern != nil && *crewRecord.MemberPattern == "*", nil 305 + } 306 + 307 + // createAllowAllCrewRecord creates a wildcard crew record allowing all authenticated users 308 + func (s *HoldService) createAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error { 309 + ownerDID := s.config.Registration.OwnerDID 310 + publicURL := s.config.Server.PublicURL 311 + 312 + // Run OAuth flow to get authenticated client 313 + client, err := s.runOAuthFlow(callbackHandler, "Creating allow-all crew record") 314 + if err != nil { 315 + return err 316 + } 317 + 318 + ctx := context.Background() 319 + 320 + // Get hold URI 321 + holdName, err := extractHostname(publicURL) 322 + if err != nil { 323 + return fmt.Errorf("failed to extract hostname: %w", err) 324 + } 325 + 326 + holdURI := fmt.Sprintf("at://%s/%s/%s", ownerDID, atproto.HoldCollection, holdName) 327 + 328 + // Create wildcard crew record 329 + crewRecord := atproto.NewHoldCrewRecordWithPattern(holdURI, "*", "write") 330 + 331 + _, err = client.PutRecord(ctx, atproto.HoldCrewCollection, "allow-all", crewRecord) 332 + if err != nil { 333 + return fmt.Errorf("failed to create allow-all crew record: %w", err) 334 + } 335 + 336 + log.Printf("✓ Created allow-all crew record (allows all authenticated users)") 337 + return nil 338 + } 339 + 340 + // deleteAllowAllCrewRecord deletes the wildcard crew record 341 + func (s *HoldService) deleteAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error { 342 + // Safety check: only delete if it's the exact wildcard pattern 343 + isWildcard, err := s.hasAllowAllCrewRecord() 344 + if err != nil { 345 + return fmt.Errorf("failed to check allow-all crew record: %w", err) 346 + } 347 + 348 + if !isWildcard { 349 + log.Printf("Warning: 'allow-all' crew record exists but is not wildcard - skipping deletion") 350 + return nil 351 + } 352 + 353 + // Run OAuth flow to get authenticated client 354 + client, err := s.runOAuthFlow(callbackHandler, "Deleting allow-all crew record") 355 + if err != nil { 356 + return err 357 + } 358 + 359 + ctx := context.Background() 360 + 361 + // Delete the record 362 + err = client.DeleteRecord(ctx, atproto.HoldCrewCollection, "allow-all") 363 + if err != nil { 364 + return fmt.Errorf("failed to delete allow-all crew record: %w", err) 365 + } 366 + 367 + log.Printf("✓ Deleted allow-all crew record") 368 + return nil 369 + } 370 + 371 + // getHoldRegistrationScopes returns the OAuth scopes needed for hold registration and crew management 372 + func getHoldRegistrationScopes() []string { 373 + return []string{ 374 + "atproto", 375 + fmt.Sprintf("repo:%s", atproto.HoldCollection), 376 + fmt.Sprintf("repo:%s", atproto.HoldCrewCollection), 377 + fmt.Sprintf("repo:%s", atproto.SailorProfileCollection), 378 + } 379 + } 380 + 381 + // runOAuthFlow performs OAuth flow and returns an authenticated client 382 + // Reusable helper to avoid code duplication across registration and reconciliation 383 + func (s *HoldService) runOAuthFlow(callbackHandler *http.HandlerFunc, purpose string) (*atproto.Client, error) { 384 + ownerDID := s.config.Registration.OwnerDID 385 + publicURL := s.config.Server.PublicURL 386 + 387 + ctx := context.Background() 388 + 389 + // Resolve owner's PDS endpoint 390 + directory := identity.DefaultDirectory() 391 + ownerDIDParsed, err := syntax.ParseDID(ownerDID) 392 + if err != nil { 393 + return nil, fmt.Errorf("invalid owner DID: %w", err) 394 + } 395 + 396 + ident, err := directory.LookupDID(ctx, ownerDIDParsed) 397 + if err != nil { 398 + return nil, fmt.Errorf("failed to resolve owner PDS: %w", err) 399 + } 400 + 401 + pdsEndpoint := ident.PDSEndpoint() 402 + if pdsEndpoint == "" { 403 + return nil, fmt.Errorf("no PDS endpoint found for owner") 404 + } 405 + 406 + handle := ident.Handle.String() 407 + if handle == "" || handle == "handle.invalid" { 408 + return nil, fmt.Errorf("no valid handle found for DID") 409 + } 410 + 411 + // Determine base URL for OAuth 412 + var baseURL string 413 + if s.config.Server.TestMode { 414 + parsedURL, err := url.Parse(publicURL) 415 + if err != nil { 416 + return nil, fmt.Errorf("failed to parse public URL: %w", err) 417 + } 418 + port := parsedURL.Port() 419 + if port == "" { 420 + port = "8080" 421 + } 422 + baseURL = fmt.Sprintf("http://127.0.0.1:%s", port) 423 + } else { 424 + baseURL = publicURL 425 + } 426 + 427 + // Run OAuth flow 428 + result, err := oauth.InteractiveFlowWithCallback( 429 + ctx, 430 + baseURL, 431 + handle, 432 + getHoldRegistrationScopes(), 433 + func(handler http.HandlerFunc) error { 434 + *callbackHandler = handler 435 + return nil 436 + }, 437 + func(authURL string) error { 438 + log.Print("\n" + strings.Repeat("=", 80)) 439 + log.Printf("OAUTH REQUIRED: %s", purpose) 440 + log.Print(strings.Repeat("=", 80)) 441 + log.Printf("\nVisit: %s\n", authURL) 442 + log.Printf("Waiting for authorization...") 443 + log.Print(strings.Repeat("=", 80) + "\n") 444 + return nil 445 + }, 446 + ) 447 + if err != nil { 448 + return nil, fmt.Errorf("OAuth flow failed: %w", err) 449 + } 450 + 451 + // Create authenticated client 452 + apiClient := result.Session.APIClient() 453 + return atproto.NewClientWithIndigoClient(pdsEndpoint, ownerDID, apiClient), nil 454 + }
+88
pkg/hold/resolve.go
··· 1 + package hold 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "sync" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/identity" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + ) 12 + 13 + // handleCache provides caching for DID → handle resolution 14 + // This reduces latency for pattern matching authorization checks 15 + type handleCache struct { 16 + mu sync.RWMutex 17 + cache map[string]cacheEntry // did → handle 18 + } 19 + 20 + type cacheEntry struct { 21 + handle string 22 + expiresAt time.Time 23 + } 24 + 25 + const handleCacheTTL = 10 * time.Minute 26 + 27 + var ( 28 + // Global handle cache instance 29 + globalHandleCache = &handleCache{ 30 + cache: make(map[string]cacheEntry), 31 + } 32 + ) 33 + 34 + // get retrieves a cached handle for a DID 35 + func (c *handleCache) get(did string) (string, bool) { 36 + c.mu.RLock() 37 + defer c.mu.RUnlock() 38 + 39 + entry, ok := c.cache[did] 40 + if !ok || time.Now().After(entry.expiresAt) { 41 + return "", false 42 + } 43 + return entry.handle, true 44 + } 45 + 46 + // set stores a handle in the cache 47 + func (c *handleCache) set(did, handle string) { 48 + c.mu.Lock() 49 + defer c.mu.Unlock() 50 + 51 + c.cache[did] = cacheEntry{ 52 + handle: handle, 53 + expiresAt: time.Now().Add(handleCacheTTL), 54 + } 55 + } 56 + 57 + // resolveHandle resolves a DID to its current handle using ATProto identity resolution 58 + // Results are cached for 10 minutes to reduce latency 59 + func resolveHandle(did string) (string, error) { 60 + // Check cache first 61 + if handle, ok := globalHandleCache.get(did); ok { 62 + return handle, nil 63 + } 64 + 65 + // Cache miss - resolve from network 66 + ctx := context.Background() 67 + directory := identity.DefaultDirectory() 68 + 69 + didParsed, err := syntax.ParseDID(did) 70 + if err != nil { 71 + return "", fmt.Errorf("invalid DID: %w", err) 72 + } 73 + 74 + ident, err := directory.LookupDID(ctx, didParsed) 75 + if err != nil { 76 + return "", fmt.Errorf("failed to resolve DID: %w", err) 77 + } 78 + 79 + handle := ident.Handle.String() 80 + if handle == "" || handle == "handle.invalid" { 81 + return "", fmt.Errorf("no valid handle found for DID") 82 + } 83 + 84 + // Cache the result 85 + globalHandleCache.set(did, handle) 86 + 87 + return handle, nil 88 + }