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

Configure Feed

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

at refactor 784 lines 25 kB view raw
1// Package auth provides UserContext for managing authenticated user state 2// throughout request handling in the AppView. 3package auth 4 5import ( 6 "context" 7 "database/sql" 8 "encoding/json" 9 "fmt" 10 "io" 11 "log/slog" 12 "net/http" 13 "sync" 14 "time" 15 16 "atcr.io/pkg/appview/db" 17 "atcr.io/pkg/atproto" 18 "atcr.io/pkg/auth/oauth" 19) 20 21// Auth method constants (duplicated from token package to avoid import cycle) 22const ( 23 AuthMethodOAuth = "oauth" 24 AuthMethodAppPassword = "app_password" 25) 26 27// RequestAction represents the type of registry operation 28type RequestAction int 29 30const ( 31 ActionUnknown RequestAction = iota 32 ActionPull // GET/HEAD - reading from registry 33 ActionPush // PUT/POST/DELETE - writing to registry 34 ActionInspect // Metadata operations only 35) 36 37func (a RequestAction) String() string { 38 switch a { 39 case ActionPull: 40 return "pull" 41 case ActionPush: 42 return "push" 43 case ActionInspect: 44 return "inspect" 45 default: 46 return "unknown" 47 } 48} 49 50// HoldPermissions describes what the user can do on a specific hold 51type HoldPermissions struct { 52 HoldDID string // Hold being checked 53 IsOwner bool // User is captain of this hold 54 IsCrew bool // User is a crew member 55 IsPublic bool // Hold allows public reads 56 CanRead bool // Computed: can user read blobs? 57 CanWrite bool // Computed: can user write blobs? 58 CanAdmin bool // Computed: can user manage crew? 59 Permissions []string // Raw permissions from crew record 60} 61 62// contextKey is unexported to prevent collisions 63type contextKey struct{} 64 65// userContextKey is the context key for UserContext 66var userContextKey = contextKey{} 67 68// userSetupCache tracks which users have had their profile/crew setup ensured 69var userSetupCache sync.Map // did -> time.Time 70 71// userSetupTTL is how long to cache user setup status (1 hour) 72const userSetupTTL = 1 * time.Hour 73 74// Dependencies bundles services needed by UserContext 75type Dependencies struct { 76 Refresher *oauth.Refresher 77 Authorizer HoldAuthorizer 78 DefaultHoldDID string // AppView's default hold DID 79} 80 81// UserContext encapsulates authenticated user state for a request. 82// Built early in the middleware chain and available throughout request processing. 83// 84// Two-phase initialization: 85// 1. Middleware phase: Identity is set (DID, authMethod, action) 86// 2. Repository() phase: Target is set via SetTarget() (owner, repo, holdDID) 87type UserContext struct { 88 // === User Identity (set in middleware) === 89 DID string // User's DID (empty if unauthenticated) 90 Handle string // User's handle (may be empty) 91 PDSEndpoint string // User's PDS endpoint 92 AuthMethod string // "oauth", "app_password", or "" 93 IsAuthenticated bool 94 95 // === Request Info === 96 Action RequestAction 97 HTTPMethod string 98 99 // === Target Info (set by SetTarget) === 100 TargetOwnerDID string // whose repo is being accessed 101 TargetOwnerHandle string 102 TargetOwnerPDS string 103 TargetRepo string // image name (e.g., "quickslice") 104 TargetHoldDID string // hold where blobs live/will live 105 106 // === Dependencies (injected) === 107 refresher *oauth.Refresher 108 authorizer HoldAuthorizer 109 defaultHoldDID string 110 111 // === Cached State (lazy-loaded) === 112 serviceTokens sync.Map // holdDID -> *serviceTokenEntry 113 permissions sync.Map // holdDID -> *HoldPermissions 114 pdsResolved bool 115 pdsResolveErr error 116 mu sync.Mutex // protects PDS resolution 117 atprotoClient *atproto.Client 118 atprotoClientOnce sync.Once 119} 120 121// FromContext retrieves UserContext from context. 122// Returns nil if not present (unauthenticated or before middleware). 123func FromContext(ctx context.Context) *UserContext { 124 uc, _ := ctx.Value(userContextKey).(*UserContext) 125 return uc 126} 127 128// WithUserContext adds UserContext to context 129func WithUserContext(ctx context.Context, uc *UserContext) context.Context { 130 return context.WithValue(ctx, userContextKey, uc) 131} 132 133// NewUserContext creates a UserContext from extracted JWT claims. 134// The deps parameter provides access to services needed for lazy operations. 135func NewUserContext(did, authMethod, httpMethod string, deps *Dependencies) *UserContext { 136 action := ActionUnknown 137 switch httpMethod { 138 case "GET", "HEAD": 139 action = ActionPull 140 case "PUT", "POST", "PATCH", "DELETE": 141 action = ActionPush 142 } 143 144 var refresher *oauth.Refresher 145 var authorizer HoldAuthorizer 146 var defaultHoldDID string 147 148 if deps != nil { 149 refresher = deps.Refresher 150 authorizer = deps.Authorizer 151 defaultHoldDID = deps.DefaultHoldDID 152 } 153 154 return &UserContext{ 155 DID: did, 156 AuthMethod: authMethod, 157 IsAuthenticated: did != "", 158 Action: action, 159 HTTPMethod: httpMethod, 160 refresher: refresher, 161 authorizer: authorizer, 162 defaultHoldDID: defaultHoldDID, 163 } 164} 165 166// SetPDS sets the user's PDS endpoint directly, bypassing network resolution. 167// Use when PDS is already known (e.g., from previous resolution or client). 168func (uc *UserContext) SetPDS(handle, pdsEndpoint string) { 169 uc.mu.Lock() 170 defer uc.mu.Unlock() 171 uc.Handle = handle 172 uc.PDSEndpoint = pdsEndpoint 173 uc.pdsResolved = true 174 uc.pdsResolveErr = nil 175} 176 177// SetTarget sets the target repository information. 178// Called in Repository() after resolving the owner identity. 179func (uc *UserContext) SetTarget(ownerDID, ownerHandle, ownerPDS, repo, holdDID string) { 180 uc.TargetOwnerDID = ownerDID 181 uc.TargetOwnerHandle = ownerHandle 182 uc.TargetOwnerPDS = ownerPDS 183 uc.TargetRepo = repo 184 uc.TargetHoldDID = holdDID 185} 186 187// ResolvePDS resolves the user's PDS endpoint (lazy, cached). 188// Safe to call multiple times; resolution happens once. 189func (uc *UserContext) ResolvePDS(ctx context.Context) error { 190 if !uc.IsAuthenticated { 191 return nil // Nothing to resolve for anonymous users 192 } 193 194 uc.mu.Lock() 195 defer uc.mu.Unlock() 196 197 if uc.pdsResolved { 198 return uc.pdsResolveErr 199 } 200 201 _, handle, pds, err := atproto.ResolveIdentity(ctx, uc.DID) 202 if err != nil { 203 uc.pdsResolveErr = err 204 uc.pdsResolved = true 205 return err 206 } 207 208 uc.Handle = handle 209 uc.PDSEndpoint = pds 210 uc.pdsResolved = true 211 return nil 212} 213 214// GetServiceToken returns a service token for the target hold. 215// Uses internal caching with sync.Once per holdDID. 216// Requires target to be set via SetTarget(). 217func (uc *UserContext) GetServiceToken(ctx context.Context) (string, error) { 218 if uc.TargetHoldDID == "" { 219 return "", fmt.Errorf("target hold not set (call SetTarget first)") 220 } 221 return uc.GetServiceTokenForHold(ctx, uc.TargetHoldDID) 222} 223 224// GetServiceTokenForHold returns a service token for an arbitrary hold. 225// Uses internal caching with sync.Once per holdDID. 226func (uc *UserContext) GetServiceTokenForHold(ctx context.Context, holdDID string) (string, error) { 227 if !uc.IsAuthenticated { 228 return "", fmt.Errorf("cannot get service token: user not authenticated") 229 } 230 231 // Ensure PDS is resolved 232 if err := uc.ResolvePDS(ctx); err != nil { 233 return "", fmt.Errorf("failed to resolve PDS: %w", err) 234 } 235 236 // Load or create cache entry 237 entryVal, _ := uc.serviceTokens.LoadOrStore(holdDID, &serviceTokenEntry{}) 238 entry := entryVal.(*serviceTokenEntry) 239 240 entry.once.Do(func() { 241 slog.Debug("Fetching service token", 242 "component", "auth/context", 243 "userDID", uc.DID, 244 "holdDID", holdDID, 245 "authMethod", uc.AuthMethod) 246 247 // Use unified service token function (handles both OAuth and app-password) 248 serviceToken, err := GetOrFetchServiceToken( 249 ctx, uc.AuthMethod, uc.refresher, uc.DID, holdDID, uc.PDSEndpoint, 250 ) 251 252 entry.token = serviceToken 253 entry.err = err 254 if err == nil { 255 // Parse JWT to get expiry 256 expiry, parseErr := ParseJWTExpiry(serviceToken) 257 if parseErr == nil { 258 entry.expiresAt = expiry.Add(-10 * time.Second) // Safety margin 259 } else { 260 entry.expiresAt = time.Now().Add(45 * time.Second) // Default fallback 261 } 262 } 263 }) 264 265 return entry.token, entry.err 266} 267 268// CanRead checks if user can read blobs from target hold. 269// - Public hold: any user (even anonymous) 270// - Private hold: owner OR crew with blob:read/blob:write 271func (uc *UserContext) CanRead(ctx context.Context) (bool, error) { 272 if uc.TargetHoldDID == "" { 273 return false, fmt.Errorf("target hold not set (call SetTarget first)") 274 } 275 276 if uc.authorizer == nil { 277 return false, fmt.Errorf("authorizer not configured") 278 } 279 280 return uc.authorizer.CheckReadAccess(ctx, uc.TargetHoldDID, uc.DID) 281} 282 283// CanWrite checks if user can write blobs to target hold. 284// - Must be authenticated 285// - Must be owner OR crew with blob:write 286func (uc *UserContext) CanWrite(ctx context.Context) (bool, error) { 287 if uc.TargetHoldDID == "" { 288 return false, fmt.Errorf("target hold not set (call SetTarget first)") 289 } 290 291 if !uc.IsAuthenticated { 292 return false, nil // Anonymous writes never allowed 293 } 294 295 if uc.authorizer == nil { 296 return false, fmt.Errorf("authorizer not configured") 297 } 298 299 return uc.authorizer.CheckWriteAccess(ctx, uc.TargetHoldDID, uc.DID) 300} 301 302// GetPermissions returns detailed permissions for target hold. 303// Lazy-loaded and cached per holdDID. 304func (uc *UserContext) GetPermissions(ctx context.Context) (*HoldPermissions, error) { 305 if uc.TargetHoldDID == "" { 306 return nil, fmt.Errorf("target hold not set (call SetTarget first)") 307 } 308 return uc.GetPermissionsForHold(ctx, uc.TargetHoldDID) 309} 310 311// GetPermissionsForHold returns detailed permissions for an arbitrary hold. 312// Lazy-loaded and cached per holdDID. 313func (uc *UserContext) GetPermissionsForHold(ctx context.Context, holdDID string) (*HoldPermissions, error) { 314 // Check cache first 315 if cached, ok := uc.permissions.Load(holdDID); ok { 316 return cached.(*HoldPermissions), nil 317 } 318 319 if uc.authorizer == nil { 320 return nil, fmt.Errorf("authorizer not configured") 321 } 322 323 // Build permissions by querying authorizer 324 captain, err := uc.authorizer.GetCaptainRecord(ctx, holdDID) 325 if err != nil { 326 return nil, fmt.Errorf("failed to get captain record: %w", err) 327 } 328 329 perms := &HoldPermissions{ 330 HoldDID: holdDID, 331 IsPublic: captain.Public, 332 IsOwner: uc.DID != "" && uc.DID == captain.Owner, 333 } 334 335 // Check crew membership if authenticated and not owner 336 if uc.IsAuthenticated && !perms.IsOwner { 337 isCrew, crewErr := uc.authorizer.IsCrewMember(ctx, holdDID, uc.DID) 338 if crewErr != nil { 339 slog.Warn("Failed to check crew membership", 340 "component", "auth/context", 341 "holdDID", holdDID, 342 "userDID", uc.DID, 343 "error", crewErr) 344 } 345 perms.IsCrew = isCrew 346 } 347 348 // Compute permissions based on role 349 if perms.IsOwner { 350 perms.CanRead = true 351 perms.CanWrite = true 352 perms.CanAdmin = true 353 } else if perms.IsCrew { 354 // Crew members can read and write (for now, all crew have blob:write) 355 // TODO: Check specific permissions from crew record 356 perms.CanRead = true 357 perms.CanWrite = true 358 perms.CanAdmin = false 359 } else if perms.IsPublic { 360 // Public hold - anyone can read 361 perms.CanRead = true 362 perms.CanWrite = false 363 perms.CanAdmin = false 364 } else if uc.IsAuthenticated { 365 // Private hold, authenticated non-crew 366 // Per permission matrix: cannot read private holds 367 perms.CanRead = false 368 perms.CanWrite = false 369 perms.CanAdmin = false 370 } else { 371 // Anonymous on private hold 372 perms.CanRead = false 373 perms.CanWrite = false 374 perms.CanAdmin = false 375 } 376 377 // Cache and return 378 uc.permissions.Store(holdDID, perms) 379 return perms, nil 380} 381 382// IsCrewMember checks if user is crew of target hold. 383func (uc *UserContext) IsCrewMember(ctx context.Context) (bool, error) { 384 if uc.TargetHoldDID == "" { 385 return false, fmt.Errorf("target hold not set (call SetTarget first)") 386 } 387 388 if !uc.IsAuthenticated { 389 return false, nil 390 } 391 392 if uc.authorizer == nil { 393 return false, fmt.Errorf("authorizer not configured") 394 } 395 396 return uc.authorizer.IsCrewMember(ctx, uc.TargetHoldDID, uc.DID) 397} 398 399// EnsureCrewMembership is a standalone function to register as crew on a hold. 400// Use this when you don't have a UserContext (e.g., OAuth callback). 401// This is best-effort and logs errors without failing. 402func EnsureCrewMembership(ctx context.Context, did, pdsEndpoint string, refresher *oauth.Refresher, holdDID string) { 403 if holdDID == "" { 404 return 405 } 406 407 // Only works with OAuth (refresher required) - app passwords can't get service tokens 408 if refresher == nil { 409 slog.Debug("skipping crew registration - no OAuth refresher (app password flow)", "holdDID", holdDID) 410 return 411 } 412 413 // Normalize URL to DID if needed 414 if !atproto.IsDID(holdDID) { 415 holdDID = atproto.ResolveHoldDIDFromURL(holdDID) 416 if holdDID == "" { 417 slog.Warn("failed to resolve hold DID", "defaultHold", holdDID) 418 return 419 } 420 } 421 422 // Get service token for the hold (OAuth only at this point) 423 serviceToken, err := GetOrFetchServiceToken(ctx, AuthMethodOAuth, refresher, did, holdDID, pdsEndpoint) 424 if err != nil { 425 slog.Warn("failed to get service token", "holdDID", holdDID, "error", err) 426 return 427 } 428 429 // Resolve hold DID to HTTP endpoint 430 holdEndpoint := atproto.ResolveHoldURL(holdDID) 431 if holdEndpoint == "" { 432 slog.Warn("failed to resolve hold endpoint", "holdDID", holdDID) 433 return 434 } 435 436 // Call requestCrew endpoint 437 if err := requestCrewMembership(ctx, holdEndpoint, serviceToken); err != nil { 438 slog.Warn("failed to request crew membership", "holdDID", holdDID, "error", err) 439 return 440 } 441 442 slog.Info("successfully registered as crew member", "holdDID", holdDID, "userDID", did) 443} 444 445// ensureCrewMembership attempts to register as crew on target hold (UserContext method). 446// Called automatically during first push; idempotent. 447// This is a best-effort operation and logs errors without failing. 448// Requires SetTarget() to be called first. 449func (uc *UserContext) ensureCrewMembership(ctx context.Context) error { 450 if uc.TargetHoldDID == "" { 451 return fmt.Errorf("target hold not set (call SetTarget first)") 452 } 453 return uc.EnsureCrewMembershipForHold(ctx, uc.TargetHoldDID) 454} 455 456// EnsureCrewMembershipForHold attempts to register as crew on the specified hold. 457// This is the core implementation that can be called with any holdDID. 458// Called automatically during first push; idempotent. 459// This is a best-effort operation and logs errors without failing. 460func (uc *UserContext) EnsureCrewMembershipForHold(ctx context.Context, holdDID string) error { 461 if holdDID == "" { 462 return nil // Nothing to do 463 } 464 465 // Normalize URL to DID if needed 466 if !atproto.IsDID(holdDID) { 467 holdDID = atproto.ResolveHoldDIDFromURL(holdDID) 468 if holdDID == "" { 469 return fmt.Errorf("failed to resolve hold DID from URL") 470 } 471 } 472 473 if !uc.IsAuthenticated { 474 return fmt.Errorf("cannot register as crew: user not authenticated") 475 } 476 477 if uc.refresher == nil { 478 return fmt.Errorf("cannot register as crew: OAuth session required") 479 } 480 481 // Get service token for the hold 482 serviceToken, err := uc.GetServiceTokenForHold(ctx, holdDID) 483 if err != nil { 484 return fmt.Errorf("failed to get service token: %w", err) 485 } 486 487 // Resolve hold DID to HTTP endpoint 488 holdEndpoint := atproto.ResolveHoldURL(holdDID) 489 if holdEndpoint == "" { 490 return fmt.Errorf("failed to resolve hold endpoint for %s", holdDID) 491 } 492 493 // Call requestCrew endpoint 494 return requestCrewMembership(ctx, holdEndpoint, serviceToken) 495} 496 497// requestCrewMembership calls the hold's requestCrew endpoint 498// The endpoint handles all authorization and duplicate checking internally 499func requestCrewMembership(ctx context.Context, holdEndpoint, serviceToken string) error { 500 // Add 5 second timeout to prevent hanging on offline holds 501 ctx, cancel := context.WithTimeout(ctx, 5*time.Second) 502 defer cancel() 503 504 url := fmt.Sprintf("%s%s", holdEndpoint, atproto.HoldRequestCrew) 505 506 req, err := http.NewRequestWithContext(ctx, "POST", url, nil) 507 if err != nil { 508 return err 509 } 510 511 req.Header.Set("Authorization", "Bearer "+serviceToken) 512 req.Header.Set("Content-Type", "application/json") 513 514 resp, err := http.DefaultClient.Do(req) 515 if err != nil { 516 return err 517 } 518 defer resp.Body.Close() 519 520 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 521 // Read response body to capture actual error message from hold 522 body, readErr := io.ReadAll(resp.Body) 523 if readErr != nil { 524 return fmt.Errorf("requestCrew failed with status %d (failed to read error body: %w)", resp.StatusCode, readErr) 525 } 526 return fmt.Errorf("requestCrew failed with status %d: %s", resp.StatusCode, string(body)) 527 } 528 529 return nil 530} 531 532// GetUserClient returns an authenticated ATProto client for the user's own PDS. 533// Used for profile operations (reading/writing to user's own repo). 534// Returns nil if not authenticated or PDS not resolved. 535func (uc *UserContext) GetUserClient() *atproto.Client { 536 if !uc.IsAuthenticated || uc.PDSEndpoint == "" { 537 return nil 538 } 539 540 if uc.AuthMethod == AuthMethodOAuth && uc.refresher != nil { 541 return atproto.NewClientWithSessionProvider(uc.PDSEndpoint, uc.DID, uc.refresher) 542 } else if uc.AuthMethod == AuthMethodAppPassword { 543 accessToken, _ := GetGlobalTokenCache().Get(uc.DID) 544 return atproto.NewClient(uc.PDSEndpoint, uc.DID, accessToken) 545 } 546 547 return nil 548} 549 550// EnsureUserSetup ensures the user has a profile and crew membership. 551// Called once per user (cached for userSetupTTL). Runs in background - does not block. 552// Safe to call on every request. 553func (uc *UserContext) EnsureUserSetup() { 554 if !uc.IsAuthenticated || uc.DID == "" { 555 return 556 } 557 558 // Check cache - skip if recently set up 559 if lastSetup, ok := userSetupCache.Load(uc.DID); ok { 560 if time.Since(lastSetup.(time.Time)) < userSetupTTL { 561 return 562 } 563 } 564 565 // Run in background to avoid blocking requests 566 go func() { 567 bgCtx := context.Background() 568 569 // 1. Ensure profile exists 570 if client := uc.GetUserClient(); client != nil { 571 uc.ensureProfile(bgCtx, client) 572 } 573 574 // 2. Ensure crew membership on default hold 575 if uc.defaultHoldDID != "" { 576 EnsureCrewMembership(bgCtx, uc.DID, uc.PDSEndpoint, uc.refresher, uc.defaultHoldDID) 577 } 578 579 // Mark as set up 580 userSetupCache.Store(uc.DID, time.Now()) 581 slog.Debug("User setup complete", 582 "component", "auth/usercontext", 583 "did", uc.DID, 584 "defaultHoldDID", uc.defaultHoldDID) 585 }() 586} 587 588// ensureProfile creates sailor profile if it doesn't exist. 589// Inline implementation to avoid circular import with storage package. 590func (uc *UserContext) ensureProfile(ctx context.Context, client *atproto.Client) { 591 // Check if profile already exists 592 profile, err := client.GetRecord(ctx, atproto.SailorProfileCollection, "self") 593 if err == nil && profile != nil { 594 return // Already exists 595 } 596 597 // Create profile with default hold 598 normalizedDID := "" 599 if uc.defaultHoldDID != "" { 600 normalizedDID = atproto.ResolveHoldDIDFromURL(uc.defaultHoldDID) 601 } 602 603 newProfile := atproto.NewSailorProfileRecord(normalizedDID) 604 if _, err := client.PutRecord(ctx, atproto.SailorProfileCollection, "self", newProfile); err != nil { 605 slog.Warn("Failed to create sailor profile", 606 "component", "auth/usercontext", 607 "did", uc.DID, 608 "error", err) 609 return 610 } 611 612 slog.Debug("Created sailor profile", 613 "component", "auth/usercontext", 614 "did", uc.DID, 615 "defaultHold", normalizedDID) 616} 617 618// GetATProtoClient returns a cached ATProto client for the target owner's PDS. 619// Authenticated if user is owner, otherwise anonymous. 620// Cached per-request (uses sync.Once). 621func (uc *UserContext) GetATProtoClient() *atproto.Client { 622 uc.atprotoClientOnce.Do(func() { 623 if uc.TargetOwnerPDS == "" { 624 return 625 } 626 627 // If puller is owner and authenticated, use authenticated client 628 if uc.DID == uc.TargetOwnerDID && uc.IsAuthenticated { 629 if uc.AuthMethod == AuthMethodOAuth && uc.refresher != nil { 630 uc.atprotoClient = atproto.NewClientWithSessionProvider(uc.TargetOwnerPDS, uc.TargetOwnerDID, uc.refresher) 631 return 632 } else if uc.AuthMethod == AuthMethodAppPassword { 633 accessToken, _ := GetGlobalTokenCache().Get(uc.TargetOwnerDID) 634 uc.atprotoClient = atproto.NewClient(uc.TargetOwnerPDS, uc.TargetOwnerDID, accessToken) 635 return 636 } 637 } 638 639 // Anonymous client for reads 640 uc.atprotoClient = atproto.NewClient(uc.TargetOwnerPDS, uc.TargetOwnerDID, "") 641 }) 642 return uc.atprotoClient 643} 644 645// ResolveHoldDID finds the hold for the target repository. 646// - Pull: uses database lookup (historical from manifest) 647// - Push: uses discovery (sailor profile → default) 648// 649// Must be called after SetTarget() is called with at least TargetOwnerDID and TargetRepo set. 650// Updates TargetHoldDID on success. 651func (uc *UserContext) ResolveHoldDID(ctx context.Context, sqlDB *sql.DB) (string, error) { 652 if uc.TargetOwnerDID == "" { 653 return "", fmt.Errorf("target owner not set") 654 } 655 656 var holdDID string 657 var err error 658 659 switch uc.Action { 660 case ActionPull: 661 // For pulls, look up historical hold from database 662 holdDID, err = uc.resolveHoldForPull(ctx, sqlDB) 663 case ActionPush: 664 // For pushes, discover hold from owner's profile 665 holdDID, err = uc.resolveHoldForPush(ctx) 666 default: 667 // Default to push discovery 668 holdDID, err = uc.resolveHoldForPush(ctx) 669 } 670 671 if err != nil { 672 return "", err 673 } 674 675 if holdDID == "" { 676 return "", fmt.Errorf("no hold DID found for %s/%s", uc.TargetOwnerDID, uc.TargetRepo) 677 } 678 679 uc.TargetHoldDID = holdDID 680 return holdDID, nil 681} 682 683// resolveHoldForPull looks up the hold from the database (historical reference) 684func (uc *UserContext) resolveHoldForPull(ctx context.Context, sqlDB *sql.DB) (string, error) { 685 // If no database is available, fall back to discovery 686 if sqlDB == nil { 687 return uc.resolveHoldForPush(ctx) 688 } 689 690 // Try database lookup first 691 holdDID, err := db.GetLatestHoldDIDForRepo(sqlDB, uc.TargetOwnerDID, uc.TargetRepo) 692 if err != nil { 693 slog.Debug("Database lookup failed, falling back to discovery", 694 "component", "auth/context", 695 "ownerDID", uc.TargetOwnerDID, 696 "repo", uc.TargetRepo, 697 "error", err) 698 return uc.resolveHoldForPush(ctx) 699 } 700 701 if holdDID != "" { 702 return holdDID, nil 703 } 704 705 // No historical hold found, fall back to discovery 706 return uc.resolveHoldForPush(ctx) 707} 708 709// resolveHoldForPush discovers hold from owner's sailor profile or default 710func (uc *UserContext) resolveHoldForPush(ctx context.Context) (string, error) { 711 // Create anonymous client to query owner's profile 712 client := atproto.NewClient(uc.TargetOwnerPDS, uc.TargetOwnerDID, "") 713 714 // Try to get owner's sailor profile 715 record, err := client.GetRecord(ctx, atproto.SailorProfileCollection, "self") 716 if err == nil && record != nil { 717 var profile atproto.SailorProfileRecord 718 if jsonErr := json.Unmarshal(record.Value, &profile); jsonErr == nil { 719 if profile.DefaultHold != "" { 720 // Normalize to DID if needed 721 holdDID := profile.DefaultHold 722 if !atproto.IsDID(holdDID) { 723 holdDID = atproto.ResolveHoldDIDFromURL(holdDID) 724 } 725 slog.Debug("Found hold from owner's profile", 726 "component", "auth/context", 727 "ownerDID", uc.TargetOwnerDID, 728 "holdDID", holdDID) 729 return holdDID, nil 730 } 731 } 732 } 733 734 // Fall back to default hold 735 if uc.defaultHoldDID != "" { 736 slog.Debug("Using default hold", 737 "component", "auth/context", 738 "ownerDID", uc.TargetOwnerDID, 739 "defaultHoldDID", uc.defaultHoldDID) 740 return uc.defaultHoldDID, nil 741 } 742 743 return "", fmt.Errorf("no hold configured for %s and no default hold set", uc.TargetOwnerDID) 744} 745 746// ============================================================================= 747// Test Helper Methods 748// ============================================================================= 749// These methods are designed to make UserContext testable by allowing tests 750// to bypass network-dependent code paths (PDS resolution, OAuth token fetching). 751// Only use these in tests - they are not intended for production use. 752 753// SetPDSForTest sets the PDS endpoint directly, bypassing ResolvePDS network calls. 754// This allows tests to skip DID resolution which would make network requests. 755// Deprecated: Use SetPDS instead. 756func (uc *UserContext) SetPDSForTest(handle, pdsEndpoint string) { 757 uc.SetPDS(handle, pdsEndpoint) 758} 759 760// SetServiceTokenForTest pre-populates a service token for the given holdDID, 761// bypassing the sync.Once and OAuth/app-password fetching logic. 762// The token will appear as if it was already fetched and cached. 763func (uc *UserContext) SetServiceTokenForTest(holdDID, token string) { 764 entry := &serviceTokenEntry{ 765 token: token, 766 expiresAt: time.Now().Add(5 * time.Minute), 767 err: nil, 768 } 769 // Mark the sync.Once as done so real fetch won't happen 770 entry.once.Do(func() {}) 771 uc.serviceTokens.Store(holdDID, entry) 772} 773 774// SetAuthorizerForTest sets the authorizer for permission checks. 775// Use with MockHoldAuthorizer to control CanRead/CanWrite behavior in tests. 776func (uc *UserContext) SetAuthorizerForTest(authorizer HoldAuthorizer) { 777 uc.authorizer = authorizer 778} 779 780// SetDefaultHoldDIDForTest sets the default hold DID for tests. 781// This is used as fallback when resolving hold for push operations. 782func (uc *UserContext) SetDefaultHoldDIDForTest(holdDID string) { 783 uc.defaultHoldDID = holdDID 784}