785 lines
24 KiB
Go
785 lines
24 KiB
Go
// Package auth provides UserContext for managing authenticated user state
|
|
// throughout request handling in the AppView.
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
"atcr.io/pkg/atproto"
|
|
"atcr.io/pkg/auth/oauth"
|
|
)
|
|
|
|
// Auth method constants (duplicated from token package to avoid import cycle)
|
|
const (
|
|
AuthMethodOAuth = "oauth"
|
|
AuthMethodAppPassword = "app_password"
|
|
)
|
|
|
|
// RequestAction represents the type of registry operation
|
|
type RequestAction int
|
|
|
|
const (
|
|
ActionUnknown RequestAction = iota
|
|
ActionPull // GET/HEAD - reading from registry
|
|
ActionPush // PUT/POST/DELETE - writing to registry
|
|
ActionInspect // Metadata operations only
|
|
)
|
|
|
|
func (a RequestAction) String() string {
|
|
switch a {
|
|
case ActionPull:
|
|
return "pull"
|
|
case ActionPush:
|
|
return "push"
|
|
case ActionInspect:
|
|
return "inspect"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// HoldPermissions describes what the user can do on a specific hold
|
|
type HoldPermissions struct {
|
|
HoldDID string // Hold being checked
|
|
IsOwner bool // User is captain of this hold
|
|
IsCrew bool // User is a crew member
|
|
IsPublic bool // Hold allows public reads
|
|
CanRead bool // Computed: can user read blobs?
|
|
CanWrite bool // Computed: can user write blobs?
|
|
CanAdmin bool // Computed: can user manage crew?
|
|
Permissions []string // Raw permissions from crew record
|
|
}
|
|
|
|
// contextKey is unexported to prevent collisions
|
|
type contextKey struct{}
|
|
|
|
// userContextKey is the context key for UserContext
|
|
var userContextKey = contextKey{}
|
|
|
|
// userSetupCache tracks which users have had their profile/crew setup ensured
|
|
var userSetupCache sync.Map // did -> time.Time
|
|
|
|
// userSetupTTL is how long to cache user setup status (1 hour)
|
|
const userSetupTTL = 1 * time.Hour
|
|
|
|
// Dependencies bundles services needed by UserContext
|
|
type Dependencies struct {
|
|
Refresher *oauth.Refresher
|
|
Authorizer HoldAuthorizer
|
|
DefaultHoldDID string // AppView's default hold DID
|
|
}
|
|
|
|
// UserContext encapsulates authenticated user state for a request.
|
|
// Built early in the middleware chain and available throughout request processing.
|
|
//
|
|
// Two-phase initialization:
|
|
// 1. Middleware phase: Identity is set (DID, authMethod, action)
|
|
// 2. Repository() phase: Target is set via SetTarget() (owner, repo, holdDID)
|
|
type UserContext struct {
|
|
// === User Identity (set in middleware) ===
|
|
DID string // User's DID (empty if unauthenticated)
|
|
Handle string // User's handle (may be empty)
|
|
PDSEndpoint string // User's PDS endpoint
|
|
AuthMethod string // "oauth", "app_password", or ""
|
|
IsAuthenticated bool
|
|
|
|
// === Request Info ===
|
|
Action RequestAction
|
|
HTTPMethod string
|
|
|
|
// === Target Info (set by SetTarget) ===
|
|
TargetOwnerDID string // whose repo is being accessed
|
|
TargetOwnerHandle string
|
|
TargetOwnerPDS string
|
|
TargetRepo string // image name (e.g., "quickslice")
|
|
TargetHoldDID string // hold where blobs live/will live
|
|
|
|
// === Dependencies (injected) ===
|
|
refresher *oauth.Refresher
|
|
authorizer HoldAuthorizer
|
|
defaultHoldDID string
|
|
|
|
// === Cached State (lazy-loaded) ===
|
|
serviceTokens sync.Map // holdDID -> *serviceTokenEntry
|
|
permissions sync.Map // holdDID -> *HoldPermissions
|
|
pdsResolved bool
|
|
pdsResolveErr error
|
|
mu sync.Mutex // protects PDS resolution
|
|
atprotoClient *atproto.Client
|
|
atprotoClientOnce sync.Once
|
|
}
|
|
|
|
// FromContext retrieves UserContext from context.
|
|
// Returns nil if not present (unauthenticated or before middleware).
|
|
func FromContext(ctx context.Context) *UserContext {
|
|
uc, _ := ctx.Value(userContextKey).(*UserContext)
|
|
return uc
|
|
}
|
|
|
|
// WithUserContext adds UserContext to context
|
|
func WithUserContext(ctx context.Context, uc *UserContext) context.Context {
|
|
return context.WithValue(ctx, userContextKey, uc)
|
|
}
|
|
|
|
// NewUserContext creates a UserContext from extracted JWT claims.
|
|
// The deps parameter provides access to services needed for lazy operations.
|
|
func NewUserContext(did, authMethod, httpMethod string, deps *Dependencies) *UserContext {
|
|
action := ActionUnknown
|
|
switch httpMethod {
|
|
case "GET", "HEAD":
|
|
action = ActionPull
|
|
case "PUT", "POST", "PATCH", "DELETE":
|
|
action = ActionPush
|
|
}
|
|
|
|
var refresher *oauth.Refresher
|
|
var authorizer HoldAuthorizer
|
|
var defaultHoldDID string
|
|
|
|
if deps != nil {
|
|
refresher = deps.Refresher
|
|
authorizer = deps.Authorizer
|
|
defaultHoldDID = deps.DefaultHoldDID
|
|
}
|
|
|
|
return &UserContext{
|
|
DID: did,
|
|
AuthMethod: authMethod,
|
|
IsAuthenticated: did != "",
|
|
Action: action,
|
|
HTTPMethod: httpMethod,
|
|
refresher: refresher,
|
|
authorizer: authorizer,
|
|
defaultHoldDID: defaultHoldDID,
|
|
}
|
|
}
|
|
|
|
// SetPDS sets the user's PDS endpoint directly, bypassing network resolution.
|
|
// Use when PDS is already known (e.g., from previous resolution or client).
|
|
func (uc *UserContext) SetPDS(handle, pdsEndpoint string) {
|
|
uc.mu.Lock()
|
|
defer uc.mu.Unlock()
|
|
uc.Handle = handle
|
|
uc.PDSEndpoint = pdsEndpoint
|
|
uc.pdsResolved = true
|
|
uc.pdsResolveErr = nil
|
|
}
|
|
|
|
// SetTarget sets the target repository information.
|
|
// Called in Repository() after resolving the owner identity.
|
|
func (uc *UserContext) SetTarget(ownerDID, ownerHandle, ownerPDS, repo, holdDID string) {
|
|
uc.TargetOwnerDID = ownerDID
|
|
uc.TargetOwnerHandle = ownerHandle
|
|
uc.TargetOwnerPDS = ownerPDS
|
|
uc.TargetRepo = repo
|
|
uc.TargetHoldDID = holdDID
|
|
}
|
|
|
|
// ResolvePDS resolves the user's PDS endpoint (lazy, cached).
|
|
// Safe to call multiple times; resolution happens once.
|
|
func (uc *UserContext) ResolvePDS(ctx context.Context) error {
|
|
if !uc.IsAuthenticated {
|
|
return nil // Nothing to resolve for anonymous users
|
|
}
|
|
|
|
uc.mu.Lock()
|
|
defer uc.mu.Unlock()
|
|
|
|
if uc.pdsResolved {
|
|
return uc.pdsResolveErr
|
|
}
|
|
|
|
_, handle, pds, err := atproto.ResolveIdentity(ctx, uc.DID)
|
|
if err != nil {
|
|
uc.pdsResolveErr = err
|
|
uc.pdsResolved = true
|
|
return err
|
|
}
|
|
|
|
uc.Handle = handle
|
|
uc.PDSEndpoint = pds
|
|
uc.pdsResolved = true
|
|
return nil
|
|
}
|
|
|
|
// GetServiceToken returns a service token for the target hold.
|
|
// Uses internal caching with sync.Once per holdDID.
|
|
// Requires target to be set via SetTarget().
|
|
func (uc *UserContext) GetServiceToken(ctx context.Context) (string, error) {
|
|
if uc.TargetHoldDID == "" {
|
|
return "", fmt.Errorf("target hold not set (call SetTarget first)")
|
|
}
|
|
return uc.GetServiceTokenForHold(ctx, uc.TargetHoldDID)
|
|
}
|
|
|
|
// GetServiceTokenForHold returns a service token for an arbitrary hold.
|
|
// Uses internal caching with sync.Once per holdDID.
|
|
func (uc *UserContext) GetServiceTokenForHold(ctx context.Context, holdDID string) (string, error) {
|
|
if !uc.IsAuthenticated {
|
|
return "", fmt.Errorf("cannot get service token: user not authenticated")
|
|
}
|
|
|
|
// Ensure PDS is resolved
|
|
if err := uc.ResolvePDS(ctx); err != nil {
|
|
return "", fmt.Errorf("failed to resolve PDS: %w", err)
|
|
}
|
|
|
|
// Load or create cache entry
|
|
entryVal, _ := uc.serviceTokens.LoadOrStore(holdDID, &serviceTokenEntry{})
|
|
entry := entryVal.(*serviceTokenEntry)
|
|
|
|
entry.once.Do(func() {
|
|
slog.Debug("Fetching service token",
|
|
"component", "auth/context",
|
|
"userDID", uc.DID,
|
|
"holdDID", holdDID,
|
|
"authMethod", uc.AuthMethod)
|
|
|
|
// Use unified service token function (handles both OAuth and app-password)
|
|
serviceToken, err := GetOrFetchServiceToken(
|
|
ctx, uc.AuthMethod, uc.refresher, uc.DID, holdDID, uc.PDSEndpoint,
|
|
)
|
|
|
|
entry.token = serviceToken
|
|
entry.err = err
|
|
if err == nil {
|
|
// Parse JWT to get expiry
|
|
expiry, parseErr := ParseJWTExpiry(serviceToken)
|
|
if parseErr == nil {
|
|
entry.expiresAt = expiry.Add(-10 * time.Second) // Safety margin
|
|
} else {
|
|
entry.expiresAt = time.Now().Add(45 * time.Second) // Default fallback
|
|
}
|
|
}
|
|
})
|
|
|
|
return entry.token, entry.err
|
|
}
|
|
|
|
// CanRead checks if user can read blobs from target hold.
|
|
// - Public hold: any user (even anonymous)
|
|
// - Private hold: owner OR crew with blob:read/blob:write
|
|
func (uc *UserContext) CanRead(ctx context.Context) (bool, error) {
|
|
if uc.TargetHoldDID == "" {
|
|
return false, fmt.Errorf("target hold not set (call SetTarget first)")
|
|
}
|
|
|
|
if uc.authorizer == nil {
|
|
return false, fmt.Errorf("authorizer not configured")
|
|
}
|
|
|
|
return uc.authorizer.CheckReadAccess(ctx, uc.TargetHoldDID, uc.DID)
|
|
}
|
|
|
|
// CanWrite checks if user can write blobs to target hold.
|
|
// - Must be authenticated
|
|
// - Must be owner OR crew with blob:write
|
|
func (uc *UserContext) CanWrite(ctx context.Context) (bool, error) {
|
|
if uc.TargetHoldDID == "" {
|
|
return false, fmt.Errorf("target hold not set (call SetTarget first)")
|
|
}
|
|
|
|
if !uc.IsAuthenticated {
|
|
return false, nil // Anonymous writes never allowed
|
|
}
|
|
|
|
if uc.authorizer == nil {
|
|
return false, fmt.Errorf("authorizer not configured")
|
|
}
|
|
|
|
return uc.authorizer.CheckWriteAccess(ctx, uc.TargetHoldDID, uc.DID)
|
|
}
|
|
|
|
// GetPermissions returns detailed permissions for target hold.
|
|
// Lazy-loaded and cached per holdDID.
|
|
func (uc *UserContext) GetPermissions(ctx context.Context) (*HoldPermissions, error) {
|
|
if uc.TargetHoldDID == "" {
|
|
return nil, fmt.Errorf("target hold not set (call SetTarget first)")
|
|
}
|
|
return uc.GetPermissionsForHold(ctx, uc.TargetHoldDID)
|
|
}
|
|
|
|
// GetPermissionsForHold returns detailed permissions for an arbitrary hold.
|
|
// Lazy-loaded and cached per holdDID.
|
|
func (uc *UserContext) GetPermissionsForHold(ctx context.Context, holdDID string) (*HoldPermissions, error) {
|
|
// Check cache first
|
|
if cached, ok := uc.permissions.Load(holdDID); ok {
|
|
return cached.(*HoldPermissions), nil
|
|
}
|
|
|
|
if uc.authorizer == nil {
|
|
return nil, fmt.Errorf("authorizer not configured")
|
|
}
|
|
|
|
// Build permissions by querying authorizer
|
|
captain, err := uc.authorizer.GetCaptainRecord(ctx, holdDID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get captain record: %w", err)
|
|
}
|
|
|
|
perms := &HoldPermissions{
|
|
HoldDID: holdDID,
|
|
IsPublic: captain.Public,
|
|
IsOwner: uc.DID != "" && uc.DID == captain.Owner,
|
|
}
|
|
|
|
// Check crew membership if authenticated and not owner
|
|
if uc.IsAuthenticated && !perms.IsOwner {
|
|
isCrew, crewErr := uc.authorizer.IsCrewMember(ctx, holdDID, uc.DID)
|
|
if crewErr != nil {
|
|
slog.Warn("Failed to check crew membership",
|
|
"component", "auth/context",
|
|
"holdDID", holdDID,
|
|
"userDID", uc.DID,
|
|
"error", crewErr)
|
|
}
|
|
perms.IsCrew = isCrew
|
|
}
|
|
|
|
// Compute permissions based on role
|
|
if perms.IsOwner {
|
|
perms.CanRead = true
|
|
perms.CanWrite = true
|
|
perms.CanAdmin = true
|
|
} else if perms.IsCrew {
|
|
// Crew members can read and write (for now, all crew have blob:write)
|
|
// TODO: Check specific permissions from crew record
|
|
perms.CanRead = true
|
|
perms.CanWrite = true
|
|
perms.CanAdmin = false
|
|
} else if perms.IsPublic {
|
|
// Public hold - anyone can read
|
|
perms.CanRead = true
|
|
perms.CanWrite = false
|
|
perms.CanAdmin = false
|
|
} else if uc.IsAuthenticated {
|
|
// Private hold, authenticated non-crew
|
|
// Per permission matrix: cannot read private holds
|
|
perms.CanRead = false
|
|
perms.CanWrite = false
|
|
perms.CanAdmin = false
|
|
} else {
|
|
// Anonymous on private hold
|
|
perms.CanRead = false
|
|
perms.CanWrite = false
|
|
perms.CanAdmin = false
|
|
}
|
|
|
|
// Cache and return
|
|
uc.permissions.Store(holdDID, perms)
|
|
return perms, nil
|
|
}
|
|
|
|
// IsCrewMember checks if user is crew of target hold.
|
|
func (uc *UserContext) IsCrewMember(ctx context.Context) (bool, error) {
|
|
if uc.TargetHoldDID == "" {
|
|
return false, fmt.Errorf("target hold not set (call SetTarget first)")
|
|
}
|
|
|
|
if !uc.IsAuthenticated {
|
|
return false, nil
|
|
}
|
|
|
|
if uc.authorizer == nil {
|
|
return false, fmt.Errorf("authorizer not configured")
|
|
}
|
|
|
|
return uc.authorizer.IsCrewMember(ctx, uc.TargetHoldDID, uc.DID)
|
|
}
|
|
|
|
// EnsureCrewMembership is a standalone function to register as crew on a hold.
|
|
// Use this when you don't have a UserContext (e.g., OAuth callback).
|
|
// This is best-effort and logs errors without failing.
|
|
func EnsureCrewMembership(ctx context.Context, did, pdsEndpoint string, refresher *oauth.Refresher, holdDID string) {
|
|
if holdDID == "" {
|
|
return
|
|
}
|
|
|
|
// Only works with OAuth (refresher required) - app passwords can't get service tokens
|
|
if refresher == nil {
|
|
slog.Debug("skipping crew registration - no OAuth refresher (app password flow)", "holdDID", holdDID)
|
|
return
|
|
}
|
|
|
|
// Normalize URL to DID if needed
|
|
if !atproto.IsDID(holdDID) {
|
|
holdDID = atproto.ResolveHoldDIDFromURL(holdDID)
|
|
if holdDID == "" {
|
|
slog.Warn("failed to resolve hold DID", "defaultHold", holdDID)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Get service token for the hold (OAuth only at this point)
|
|
serviceToken, err := GetOrFetchServiceToken(ctx, AuthMethodOAuth, refresher, did, holdDID, pdsEndpoint)
|
|
if err != nil {
|
|
slog.Warn("failed to get service token", "holdDID", holdDID, "error", err)
|
|
return
|
|
}
|
|
|
|
// Resolve hold DID to HTTP endpoint
|
|
holdEndpoint := atproto.ResolveHoldURL(holdDID)
|
|
if holdEndpoint == "" {
|
|
slog.Warn("failed to resolve hold endpoint", "holdDID", holdDID)
|
|
return
|
|
}
|
|
|
|
// Call requestCrew endpoint
|
|
if err := requestCrewMembership(ctx, holdEndpoint, serviceToken); err != nil {
|
|
slog.Warn("failed to request crew membership", "holdDID", holdDID, "error", err)
|
|
return
|
|
}
|
|
|
|
slog.Info("successfully registered as crew member", "holdDID", holdDID, "userDID", did)
|
|
}
|
|
|
|
// ensureCrewMembership attempts to register as crew on target hold (UserContext method).
|
|
// Called automatically during first push; idempotent.
|
|
// This is a best-effort operation and logs errors without failing.
|
|
// Requires SetTarget() to be called first.
|
|
func (uc *UserContext) ensureCrewMembership(ctx context.Context) error {
|
|
if uc.TargetHoldDID == "" {
|
|
return fmt.Errorf("target hold not set (call SetTarget first)")
|
|
}
|
|
return uc.EnsureCrewMembershipForHold(ctx, uc.TargetHoldDID)
|
|
}
|
|
|
|
// EnsureCrewMembershipForHold attempts to register as crew on the specified hold.
|
|
// This is the core implementation that can be called with any holdDID.
|
|
// Called automatically during first push; idempotent.
|
|
// This is a best-effort operation and logs errors without failing.
|
|
func (uc *UserContext) EnsureCrewMembershipForHold(ctx context.Context, holdDID string) error {
|
|
if holdDID == "" {
|
|
return nil // Nothing to do
|
|
}
|
|
|
|
// Normalize URL to DID if needed
|
|
if !atproto.IsDID(holdDID) {
|
|
holdDID = atproto.ResolveHoldDIDFromURL(holdDID)
|
|
if holdDID == "" {
|
|
return fmt.Errorf("failed to resolve hold DID from URL")
|
|
}
|
|
}
|
|
|
|
if !uc.IsAuthenticated {
|
|
return fmt.Errorf("cannot register as crew: user not authenticated")
|
|
}
|
|
|
|
if uc.refresher == nil {
|
|
return fmt.Errorf("cannot register as crew: OAuth session required")
|
|
}
|
|
|
|
// Get service token for the hold
|
|
serviceToken, err := uc.GetServiceTokenForHold(ctx, holdDID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get service token: %w", err)
|
|
}
|
|
|
|
// Resolve hold DID to HTTP endpoint
|
|
holdEndpoint := atproto.ResolveHoldURL(holdDID)
|
|
if holdEndpoint == "" {
|
|
return fmt.Errorf("failed to resolve hold endpoint for %s", holdDID)
|
|
}
|
|
|
|
// Call requestCrew endpoint
|
|
return requestCrewMembership(ctx, holdEndpoint, serviceToken)
|
|
}
|
|
|
|
// requestCrewMembership calls the hold's requestCrew endpoint
|
|
// The endpoint handles all authorization and duplicate checking internally
|
|
func requestCrewMembership(ctx context.Context, holdEndpoint, serviceToken string) error {
|
|
// Add 5 second timeout to prevent hanging on offline holds
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
url := fmt.Sprintf("%s%s", holdEndpoint, atproto.HoldRequestCrew)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+serviceToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
// Read response body to capture actual error message from hold
|
|
body, readErr := io.ReadAll(resp.Body)
|
|
if readErr != nil {
|
|
return fmt.Errorf("requestCrew failed with status %d (failed to read error body: %w)", resp.StatusCode, readErr)
|
|
}
|
|
return fmt.Errorf("requestCrew failed with status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetUserClient returns an authenticated ATProto client for the user's own PDS.
|
|
// Used for profile operations (reading/writing to user's own repo).
|
|
// Returns nil if not authenticated or PDS not resolved.
|
|
func (uc *UserContext) GetUserClient() *atproto.Client {
|
|
if !uc.IsAuthenticated || uc.PDSEndpoint == "" {
|
|
return nil
|
|
}
|
|
|
|
if uc.AuthMethod == AuthMethodOAuth && uc.refresher != nil {
|
|
return atproto.NewClientWithSessionProvider(uc.PDSEndpoint, uc.DID, uc.refresher)
|
|
} else if uc.AuthMethod == AuthMethodAppPassword {
|
|
accessToken, _ := GetGlobalTokenCache().Get(uc.DID)
|
|
return atproto.NewClient(uc.PDSEndpoint, uc.DID, accessToken)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// EnsureUserSetup ensures the user has a profile and crew membership.
|
|
// Called once per user (cached for userSetupTTL). Runs in background - does not block.
|
|
// Safe to call on every request.
|
|
func (uc *UserContext) EnsureUserSetup() {
|
|
if !uc.IsAuthenticated || uc.DID == "" {
|
|
return
|
|
}
|
|
|
|
// Check cache - skip if recently set up
|
|
if lastSetup, ok := userSetupCache.Load(uc.DID); ok {
|
|
if time.Since(lastSetup.(time.Time)) < userSetupTTL {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Run in background to avoid blocking requests
|
|
go func() {
|
|
bgCtx := context.Background()
|
|
|
|
// 1. Ensure profile exists
|
|
if client := uc.GetUserClient(); client != nil {
|
|
uc.ensureProfile(bgCtx, client)
|
|
}
|
|
|
|
// 2. Ensure crew membership on default hold
|
|
if uc.defaultHoldDID != "" {
|
|
EnsureCrewMembership(bgCtx, uc.DID, uc.PDSEndpoint, uc.refresher, uc.defaultHoldDID)
|
|
}
|
|
|
|
// Mark as set up
|
|
userSetupCache.Store(uc.DID, time.Now())
|
|
slog.Debug("User setup complete",
|
|
"component", "auth/usercontext",
|
|
"did", uc.DID,
|
|
"defaultHoldDID", uc.defaultHoldDID)
|
|
}()
|
|
}
|
|
|
|
// ensureProfile creates sailor profile if it doesn't exist.
|
|
// Inline implementation to avoid circular import with storage package.
|
|
func (uc *UserContext) ensureProfile(ctx context.Context, client *atproto.Client) {
|
|
// Check if profile already exists
|
|
profile, err := client.GetRecord(ctx, atproto.SailorProfileCollection, "self")
|
|
if err == nil && profile != nil {
|
|
return // Already exists
|
|
}
|
|
|
|
// Create profile with default hold
|
|
normalizedDID := ""
|
|
if uc.defaultHoldDID != "" {
|
|
normalizedDID = atproto.ResolveHoldDIDFromURL(uc.defaultHoldDID)
|
|
}
|
|
|
|
newProfile := atproto.NewSailorProfileRecord(normalizedDID)
|
|
if _, err := client.PutRecord(ctx, atproto.SailorProfileCollection, "self", newProfile); err != nil {
|
|
slog.Warn("Failed to create sailor profile",
|
|
"component", "auth/usercontext",
|
|
"did", uc.DID,
|
|
"error", err)
|
|
return
|
|
}
|
|
|
|
slog.Debug("Created sailor profile",
|
|
"component", "auth/usercontext",
|
|
"did", uc.DID,
|
|
"defaultHold", normalizedDID)
|
|
}
|
|
|
|
// GetATProtoClient returns a cached ATProto client for the target owner's PDS.
|
|
// Authenticated if user is owner, otherwise anonymous.
|
|
// Cached per-request (uses sync.Once).
|
|
func (uc *UserContext) GetATProtoClient() *atproto.Client {
|
|
uc.atprotoClientOnce.Do(func() {
|
|
if uc.TargetOwnerPDS == "" {
|
|
return
|
|
}
|
|
|
|
// If puller is owner and authenticated, use authenticated client
|
|
if uc.DID == uc.TargetOwnerDID && uc.IsAuthenticated {
|
|
if uc.AuthMethod == AuthMethodOAuth && uc.refresher != nil {
|
|
uc.atprotoClient = atproto.NewClientWithSessionProvider(uc.TargetOwnerPDS, uc.TargetOwnerDID, uc.refresher)
|
|
return
|
|
} else if uc.AuthMethod == AuthMethodAppPassword {
|
|
accessToken, _ := GetGlobalTokenCache().Get(uc.TargetOwnerDID)
|
|
uc.atprotoClient = atproto.NewClient(uc.TargetOwnerPDS, uc.TargetOwnerDID, accessToken)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Anonymous client for reads
|
|
uc.atprotoClient = atproto.NewClient(uc.TargetOwnerPDS, uc.TargetOwnerDID, "")
|
|
})
|
|
return uc.atprotoClient
|
|
}
|
|
|
|
// ResolveHoldDID finds the hold for the target repository.
|
|
// - Pull: uses database lookup (historical from manifest)
|
|
// - Push: uses discovery (sailor profile → default)
|
|
//
|
|
// Must be called after SetTarget() is called with at least TargetOwnerDID and TargetRepo set.
|
|
// Updates TargetHoldDID on success.
|
|
func (uc *UserContext) ResolveHoldDID(ctx context.Context, sqlDB *sql.DB) (string, error) {
|
|
if uc.TargetOwnerDID == "" {
|
|
return "", fmt.Errorf("target owner not set")
|
|
}
|
|
|
|
var holdDID string
|
|
var err error
|
|
|
|
switch uc.Action {
|
|
case ActionPull:
|
|
// For pulls, look up historical hold from database
|
|
holdDID, err = uc.resolveHoldForPull(ctx, sqlDB)
|
|
case ActionPush:
|
|
// For pushes, discover hold from owner's profile
|
|
holdDID, err = uc.resolveHoldForPush(ctx)
|
|
default:
|
|
// Default to push discovery
|
|
holdDID, err = uc.resolveHoldForPush(ctx)
|
|
}
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if holdDID == "" {
|
|
return "", fmt.Errorf("no hold DID found for %s/%s", uc.TargetOwnerDID, uc.TargetRepo)
|
|
}
|
|
|
|
uc.TargetHoldDID = holdDID
|
|
return holdDID, nil
|
|
}
|
|
|
|
// resolveHoldForPull looks up the hold from the database (historical reference)
|
|
func (uc *UserContext) resolveHoldForPull(ctx context.Context, sqlDB *sql.DB) (string, error) {
|
|
// If no database is available, fall back to discovery
|
|
if sqlDB == nil {
|
|
return uc.resolveHoldForPush(ctx)
|
|
}
|
|
|
|
// Try database lookup first
|
|
holdDID, err := db.GetLatestHoldDIDForRepo(sqlDB, uc.TargetOwnerDID, uc.TargetRepo)
|
|
if err != nil {
|
|
slog.Debug("Database lookup failed, falling back to discovery",
|
|
"component", "auth/context",
|
|
"ownerDID", uc.TargetOwnerDID,
|
|
"repo", uc.TargetRepo,
|
|
"error", err)
|
|
return uc.resolveHoldForPush(ctx)
|
|
}
|
|
|
|
if holdDID != "" {
|
|
return holdDID, nil
|
|
}
|
|
|
|
// No historical hold found, fall back to discovery
|
|
return uc.resolveHoldForPush(ctx)
|
|
}
|
|
|
|
// resolveHoldForPush discovers hold from owner's sailor profile or default
|
|
func (uc *UserContext) resolveHoldForPush(ctx context.Context) (string, error) {
|
|
// Create anonymous client to query owner's profile
|
|
client := atproto.NewClient(uc.TargetOwnerPDS, uc.TargetOwnerDID, "")
|
|
|
|
// Try to get owner's sailor profile
|
|
record, err := client.GetRecord(ctx, atproto.SailorProfileCollection, "self")
|
|
if err == nil && record != nil {
|
|
var profile atproto.SailorProfileRecord
|
|
if jsonErr := json.Unmarshal(record.Value, &profile); jsonErr == nil {
|
|
if profile.DefaultHold != "" {
|
|
// Normalize to DID if needed
|
|
holdDID := profile.DefaultHold
|
|
if !atproto.IsDID(holdDID) {
|
|
holdDID = atproto.ResolveHoldDIDFromURL(holdDID)
|
|
}
|
|
slog.Debug("Found hold from owner's profile",
|
|
"component", "auth/context",
|
|
"ownerDID", uc.TargetOwnerDID,
|
|
"holdDID", holdDID)
|
|
return holdDID, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fall back to default hold
|
|
if uc.defaultHoldDID != "" {
|
|
slog.Debug("Using default hold",
|
|
"component", "auth/context",
|
|
"ownerDID", uc.TargetOwnerDID,
|
|
"defaultHoldDID", uc.defaultHoldDID)
|
|
return uc.defaultHoldDID, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("no hold configured for %s and no default hold set", uc.TargetOwnerDID)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test Helper Methods
|
|
// =============================================================================
|
|
// These methods are designed to make UserContext testable by allowing tests
|
|
// to bypass network-dependent code paths (PDS resolution, OAuth token fetching).
|
|
// Only use these in tests - they are not intended for production use.
|
|
|
|
// SetPDSForTest sets the PDS endpoint directly, bypassing ResolvePDS network calls.
|
|
// This allows tests to skip DID resolution which would make network requests.
|
|
// Deprecated: Use SetPDS instead.
|
|
func (uc *UserContext) SetPDSForTest(handle, pdsEndpoint string) {
|
|
uc.SetPDS(handle, pdsEndpoint)
|
|
}
|
|
|
|
// SetServiceTokenForTest pre-populates a service token for the given holdDID,
|
|
// bypassing the sync.Once and OAuth/app-password fetching logic.
|
|
// The token will appear as if it was already fetched and cached.
|
|
func (uc *UserContext) SetServiceTokenForTest(holdDID, token string) {
|
|
entry := &serviceTokenEntry{
|
|
token: token,
|
|
expiresAt: time.Now().Add(5 * time.Minute),
|
|
err: nil,
|
|
}
|
|
// Mark the sync.Once as done so real fetch won't happen
|
|
entry.once.Do(func() {})
|
|
uc.serviceTokens.Store(holdDID, entry)
|
|
}
|
|
|
|
// SetAuthorizerForTest sets the authorizer for permission checks.
|
|
// Use with MockHoldAuthorizer to control CanRead/CanWrite behavior in tests.
|
|
func (uc *UserContext) SetAuthorizerForTest(authorizer HoldAuthorizer) {
|
|
uc.authorizer = authorizer
|
|
}
|
|
|
|
// SetDefaultHoldDIDForTest sets the default hold DID for tests.
|
|
// This is used as fallback when resolving hold for push operations.
|
|
func (uc *UserContext) SetDefaultHoldDIDForTest(holdDID string) {
|
|
uc.defaultHoldDID = holdDID
|
|
}
|