package auth import ( "context" "fmt" "log/slog" "atcr.io/pkg/atproto" ) // HoldAuthorizer checks if a DID has read/write access to a hold // Implementations can query local PDS (hold service) or remote XRPC (appview) type HoldAuthorizer interface { // CheckReadAccess checks if userDID can read from holdDID // Returns: (allowed bool, error) CheckReadAccess(ctx context.Context, holdDID, userDID string) (bool, error) // CheckWriteAccess checks if userDID can write to holdDID // Returns: (allowed bool, error) CheckWriteAccess(ctx context.Context, holdDID, userDID string) (bool, error) // GetCaptainRecord retrieves the captain record for a hold // Used to check public flag and allowAllCrew settings GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) // IsCrewMember checks if userDID is a crew member of holdDID IsCrewMember(ctx context.Context, holdDID, userDID string) (bool, error) // ClearCrewDenial removes any cached denial for a user/hold pair // Called when user successfully becomes a crew member to ensure immediate access // Returns nil if no denial cache exists or invalidation succeeds ClearCrewDenial(ctx context.Context, holdDID, userDID string) error // IsCachedCrewMember returns true only if there is a non-expired approval // in the cache. It MUST NOT make any network calls. Cache miss returns (false, nil). IsCachedCrewMember(ctx context.Context, holdDID, userDID string) (bool, error) // RecordCrewApproval writes an approval to the cache with the implementation's // standard TTL. Used to warm the cache after an out-of-band confirmation of crew // membership (e.g. a successful requestCrew POST). No-op for implementations // without a cache. RecordCrewApproval(ctx context.Context, holdDID, userDID string) error } // CheckReadAccessWithCaptain implements the standard read authorization logic // This is shared across all HoldAuthorizer implementations // Read access rules: // - Public hold: allow anyone (even anonymous) // - Private hold: hold owner or crew member only // // The two settings on a captain record are orthogonal and this is the read // half: public decides who may pull (anyone, or crew only), while // allowAllCrew decides who may become crew and therefore who may push. An // anonymous reader has no identity to be crew with, so public is the only // thing that can admit one. // // This previously admitted any authenticated DID to a private hold, on an // explicitly-MVP assumption that holding a DID was close enough to being a // sailor. It is not: "private" means crew-only, and every authenticated user // on the network has a DID. The hold has always enforced the correct rule // (ValidateBlobReadAccess: owner, or crew carrying blob:read/blob:write), so // this brings the appview's local gate into agreement with the authority // rather than loosening anything. func CheckReadAccessWithCaptain(captain *atproto.CaptainRecord, userDID string, isCrew bool) bool { if captain.Public { // Public hold - allow anyone (even anonymous) return true } // Private hold - require authentication if userDID == "" { // Anonymous user trying to access private hold slog.Debug("Read access denied", "denial_reason", "anonymous_on_private_hold", "message", "anonymous reads require a public hold") return false } // Owner always has read access to their own hold if userDID == captain.Owner { return true } if !isCrew { slog.Debug("Read access denied", "userDID", userDID, "owner", captain.Owner, "denial_reason", "not_owner_or_crew", "message", "private hold reads require crew membership") return false } return true } // CheckWriteAccessWithCaptain implements the standard write authorization logic // This is shared across all HoldAuthorizer implementations // Write access rules: // - Must be authenticated // - Must be hold owner OR crew member func CheckWriteAccessWithCaptain(captain *atproto.CaptainRecord, userDID string, isCrew bool) bool { slog.Debug("Checking write access", "userDID", userDID, "owner", captain.Owner, "isCrew", isCrew) if userDID == "" { // Anonymous writes not allowed slog.Debug("Write access denied", "userDID", userDID, "denial_reason", "anonymous_user", "message", "anonymous writes not allowed") return false } // Check if DID is the hold owner if userDID == captain.Owner { // Owner always has write access slog.Debug("Write access allowed: user is hold owner") return true } // Check if DID is a crew member if isCrew { slog.Debug("Write access allowed: user is crew member") } else { slog.Debug("Write access denied", "userDID", userDID, "owner", captain.Owner, "denial_reason", "not_owner_or_crew", "message", "user is not owner or crew member") } return isCrew } // ErrHoldNotFound is returned when a hold's captain record cannot be found var ErrHoldNotFound = fmt.Errorf("hold not found") // ErrUnauthorized is returned when access is denied var ErrUnauthorized = fmt.Errorf("unauthorized")