Files
at-container-registry/pkg/hold/authorization.go
2025-10-13 20:59:14 -05:00

182 lines
5.1 KiB
Go

package hold
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// isAuthorizedRead checks if a DID can read from this hold
// Authorization:
// - Public hold: allow anonymous (empty DID) or any authenticated user
// - Private hold: require authentication (any user with sailor.profile)
func (s *HoldService) isAuthorizedRead(did string) bool {
// Check hold public flag
isPublic, err := s.isHoldPublic()
if err != nil {
log.Printf("ERROR: Failed to check hold public flag: %v", err)
// Fail secure - deny access on error
return false
}
if isPublic {
// Public hold - allow anyone (even anonymous)
return true
}
// Private hold - require authentication
// Any authenticated user with sailor.profile can read
if did == "" {
// Anonymous user trying to access private hold
return false
}
// For MVP: assume DID presence means they have sailor.profile
// Future: could query PDS to verify sailor.profile exists
return true
}
// isAuthorizedWrite checks if a DID can write to this hold
// Authorization: must be hold owner OR crew member
func (s *HoldService) isAuthorizedWrite(did string) bool {
if did == "" {
// Anonymous writes not allowed
return false
}
// Check if DID is the hold owner
ownerDID := s.config.Registration.OwnerDID
if ownerDID == "" {
log.Printf("ERROR: Hold owner DID not configured")
return false
}
if did == ownerDID {
// Owner always has write access
return true
}
// Check if DID is a crew member
isCrew, err := s.isCrewMember(did)
if err != nil {
log.Printf("ERROR: Failed to check crew membership: %v", err)
return false
}
return isCrew
}
// isHoldPublic checks if this hold allows public (anonymous) reads
func (s *HoldService) isHoldPublic() (bool, error) {
// Use cached config value for now
// Future: could query PDS for hold record to get live value
return s.config.Server.Public, nil
}
// isCrewMember checks if a DID is a crew member of this hold
// Supports both explicit DID matching and pattern-based matching (wildcards, handle globs)
func (s *HoldService) isCrewMember(did string) (bool, error) {
ownerDID := s.config.Registration.OwnerDID
if ownerDID == "" {
return false, fmt.Errorf("hold owner DID not configured")
}
ctx := context.Background()
// Resolve owner's PDS endpoint using indigo
directory := identity.DefaultDirectory()
ownerDIDParsed, err := syntax.ParseDID(ownerDID)
if err != nil {
return false, fmt.Errorf("invalid owner DID: %w", err)
}
ident, err := directory.LookupDID(ctx, ownerDIDParsed)
if err != nil {
return false, fmt.Errorf("failed to resolve owner PDS: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return false, fmt.Errorf("no PDS endpoint found for owner")
}
// Build this hold's URI for filtering
publicURL := s.config.Server.PublicURL
if publicURL == "" {
return false, fmt.Errorf("hold public URL not configured")
}
holdName, err := extractHostname(publicURL)
if err != nil {
return false, fmt.Errorf("failed to extract hold name: %w", err)
}
holdURI := fmt.Sprintf("at://%s/%s/%s", ownerDID, atproto.HoldCollection, holdName)
// Create unauthenticated client to read public records
client := atproto.NewClient(pdsEndpoint, ownerDID, "")
// List crew records for this hold
// Crew records are public, so we can read them without auth
records, err := client.ListRecords(ctx, atproto.HoldCrewCollection, 100)
if err != nil {
return false, fmt.Errorf("failed to list crew records: %w", err)
}
// Resolve handle once for pattern matching (lazily, only if needed)
var handle string
var handleResolved bool
// Check crew records for both explicit DID and pattern matches
for _, record := range records {
var crewRecord atproto.HoldCrewRecord
if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
continue
}
// Only check crew records for THIS hold (prevents cross-hold access)
if crewRecord.Hold != holdURI {
continue
}
// Check expiration (if set)
if crewRecord.ExpiresAt != nil && time.Now().After(*crewRecord.ExpiresAt) {
continue // Skip expired membership
}
// Check explicit DID match
if crewRecord.Member != nil && *crewRecord.Member == did {
// Found explicit crew membership
return true, nil
}
// Check pattern match (if pattern is set)
if crewRecord.MemberPattern != nil && *crewRecord.MemberPattern != "" {
// Lazy handle resolution - only resolve if we encounter a pattern
if !handleResolved {
handle, err = resolveHandle(did)
if err != nil {
log.Printf("Warning: failed to resolve handle for DID %s: %v", did, err)
// Continue checking explicit DIDs even if handle resolution fails
handleResolved = true // Mark as attempted (don't retry)
handle = "" // Empty handle won't match patterns
} else {
handleResolved = true
}
}
// If we have a handle, check pattern match
if handle != "" && matchPattern(*crewRecord.MemberPattern, handle) {
// Found pattern-based crew membership
return true, nil
}
}
}
return false, nil
}