Files
at-container-registry/docs/CREW_ACCESS_CONTROL.md
2025-10-13 10:40:03 -05:00

35 KiB

Hold Crew Access Control

Overview

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:

  • Individual access - Explicit DID-based crew membership
  • Wildcard access - Allow all authenticated users
  • Pattern-based access - Match users by handle patterns (e.g., *.example.com)
  • Access revocation - Bar (ban) specific users or patterns

Problem Statement

The original crew system required one io.atcr.hold.crew record per user. This doesn't scale for:

  1. Public/shared holds - Thousands of users would need individual crew records
  2. Community holds - PDS operators want to allow all their users
  3. Default registries - AppView operators want to allow all authenticated users
  4. Access revocation - No way to selectively remove access from wildcard/pattern grants

Design Goals

  1. Preserve ATProto semantics - Keep member as DID type for backlinks
  2. Scalable - Support thousands of users with minimal records
  3. Flexible patterns - Support wildcards, handle globs, future regex
  4. Clear semantics - Separate allow/deny (crew vs barred)
  5. Backward compatible - Existing crew records work unchanged
  6. Performance - Minimize PDS queries, enable caching

Record Schemas

io.atcr.hold.crew (Updated)

Crew membership grants write access to a hold. Stored in the hold owner's PDS.

{
  "$type": "io.atcr.hold.crew",
  "hold": "at://did:plc:owner/io.atcr.hold/shared",
  "member": "did:plc:alice123",          // Optional: Explicit DID (for backlinks)
  "memberPattern": "*.bsky.social",      // Optional: Pattern matching
  "role": "write",
  "createdAt": "2025-10-13T12:00:00Z"
}

Fields:

  • hold (string, at-uri, required) - AT-URI of the hold record
  • member (string, did, optional) - Explicit DID for individual access (enables backlinks)
  • memberPattern (string, optional) - Pattern for matching multiple users
  • role (string, required) - Role: "owner" or "write"
  • expiresAt (string, datetime, optional) - Optional expiration
  • createdAt (string, datetime, required) - Creation timestamp

Validation: Exactly one of member or memberPattern must be set.

Pattern syntax:

  • "*" - Matches all authenticated users
  • "*.domain.com" - Matches handles ending with .domain.com
  • "subdomain.*" - Matches handles starting with subdomain.
  • "*.bsky.*" - Matches handles containing .bsky.

Examples:

// Explicit DID (current behavior, preserved)
{
  "$type": "io.atcr.hold.crew",
  "hold": "at://did:plc:owner/io.atcr.hold/team",
  "member": "did:plc:alice123",
  "role": "write",
  "createdAt": "2025-10-13T12:00:00Z"
}

// Allow all authenticated users (public hold)
{
  "$type": "io.atcr.hold.crew",
  "hold": "at://did:plc:owner/io.atcr.hold/shared",
  "memberPattern": "*",
  "role": "write",
  "createdAt": "2025-10-13T12:00:00Z"
}

// Allow all users from a community
{
  "$type": "io.atcr.hold.crew",
  "hold": "at://did:plc:owner/io.atcr.hold/community",
  "memberPattern": "*.my-community.social",
  "role": "write",
  "createdAt": "2025-10-13T12:00:00Z"
}

// Allow specific subdomain
{
  "$type": "io.atcr.hold.crew",
  "hold": "at://did:plc:owner/io.atcr.hold/corp",
  "memberPattern": "*.eng.company.com",
  "role": "write",
  "createdAt": "2025-10-13T12:00:00Z"
}

io.atcr.hold.crew.barred (New)

Barred list revokes access for specific users or patterns. Overrides crew membership. Stored in the hold owner's PDS.

{
  "$type": "io.atcr.hold.crew.barred",
  "hold": "at://did:plc:owner/io.atcr.hold/shared",
  "member": "did:plc:spammer",           // Optional: Explicit DID
  "memberPattern": "*.spam-instance.com", // Optional: Pattern matching
  "reason": "spam/abuse/policy violation",
  "barredAt": "2025-10-13T12:00:00Z"
}

Fields:

  • hold (string, at-uri, required) - AT-URI of the hold record
  • member (string, did, optional) - Explicit DID to bar
  • memberPattern (string, optional) - Pattern for barring multiple users
  • reason (string, optional) - Human-readable reason for access revocation
  • barredAt (string, datetime, required) - When user was barred

Validation: Exactly one of member or memberPattern must be set.

Pattern syntax: Same as crew patterns (wildcards, handle globs).

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).

Examples:

// Bar specific user
{
  "$type": "io.atcr.hold.crew.barred",
  "hold": "at://did:plc:owner/io.atcr.hold/shared",
  "member": "did:plc:badactor",
  "reason": "Terms of service violation",
  "barredAt": "2025-10-13T12:00:00Z"
}

// Bar all users from a spam PDS
{
  "$type": "io.atcr.hold.crew.barred",
  "hold": "at://did:plc:owner/io.atcr.hold/shared",
  "memberPattern": "*.spam-pds.com",
  "reason": "Spam instance",
  "barredAt": "2025-10-13T14:30:00Z"
}

// Bar pattern of suspicious accounts
{
  "$type": "io.atcr.hold.crew.barred",
  "hold": "at://did:plc:owner/io.atcr.hold/shared",
  "memberPattern": "bot*",
  "reason": "Automated account abuse",
  "barredAt": "2025-10-13T15:00:00Z"
}

Authorization Logic

Write authorization follows this priority order:

isAuthorizedWrite(did, handle):
  1. If DID is hold owner → ALLOW
  2. If DID or handle matches barred list → DENY
  3. If DID explicitly in crew list → ALLOW
  4. If handle matches crew pattern → ALLOW
  5. Default → DENY

Detailed algorithm:

func (s *HoldService) isAuthorizedWrite(did string) bool {
    // 1. Check if owner
    if did == s.config.Registration.OwnerDID {
        return true  // Owner always has access
    }

    // 2. Resolve handle from DID
    handle, err := resolveHandle(did)
    if err != nil {
        log.Printf("Failed to resolve handle for DID %s: %v", did, err)
        handle = ""  // Continue without handle matching
    }

    // 3. Check barred list (explicit deny overrides everything)
    barred, err := s.isBarred(did, handle)
    if err != nil {
        log.Printf("Error checking barred status: %v", err)
        return false  // Fail secure
    }
    if barred {
        return false  // Explicitly barred
    }

    // 4. Check crew list (explicit allow)
    crew, err := s.isCrewMember(did, handle)
    if err != nil {
        log.Printf("Error checking crew status: %v", err)
        return false  // Fail secure
    }

    return crew  // Allow if crew member, deny otherwise
}

func (s *HoldService) isBarred(did, handle string) (bool, error) {
    records := listBarredRecords()

    for _, record := range records {
        // Check explicit DID match
        if record.Member != "" && record.Member == did {
            return true, nil
        }

        // Check pattern match (if handle available)
        if record.MemberPattern != "" && handle != "" {
            if matchPattern(record.MemberPattern, handle) {
                return true, nil
            }
        }
    }

    return false, nil
}

func (s *HoldService) isCrewMember(did, handle string) (bool, error) {
    records := listCrewRecords()

    for _, record := range records {
        // Check explicit DID match
        if record.Member != "" && record.Member == did {
            return true, nil
        }

        // Check pattern match (if handle available)
        if record.MemberPattern != "" && handle != "" {
            if matchPattern(record.MemberPattern, handle) {
                return true, nil
            }
        }
    }

    return false, nil
}

Pattern matching:

func matchPattern(pattern, handle string) bool {
    if pattern == "*" {
        return true  // Wildcard matches all
    }

    // Convert glob pattern to regex
    // *.example.com → ^.*\.example\.com$
    // subdomain.* → ^subdomain\..*$
    // *.bsky.* → ^.*\.bsky\..*$

    regex := globToRegex(pattern)
    matched, _ := regexp.MatchString(regex, handle)
    return matched
}

Use Cases

1. Public Hold (Allow All Users)

Goal: Shared storage for any authenticated ATCR user.

Setup:

# Create crew record with wildcard
atproto put-record \
  --collection io.atcr.hold.crew \
  --rkey "all-users" \
  --value '{
    "$type": "io.atcr.hold.crew",
    "hold": "at://did:plc:owner/io.atcr.hold/public",
    "memberPattern": "*",
    "role": "write"
  }'

Result: All authenticated users can push. Owner can selectively bar bad actors.

2. Community Hold (PDS-Specific)

Goal: Storage for all users from a specific community/PDS.

Setup:

# Allow all community members
atproto put-record \
  --collection io.atcr.hold.crew \
  --rkey "community-hold" \
  --value '{
    "$type": "io.atcr.hold.crew",
    "hold": "at://did:plc:owner/io.atcr.hold/community",
    "memberPattern": "*.my-community.social",
    "role": "write"
  }'

Result: Anyone with a @someone.my-community.social handle can push.

3. Team Hold with Selective Banning

Goal: Shared team storage, but remove access from former employees.

Setup:

# Allow team domain
atproto put-record \
  --collection io.atcr.hold.crew \
  --rkey "team-hold" \
  --value '{
    "$type": "io.atcr.hold.crew",
    "hold": "at://did:plc:owner/io.atcr.hold/team",
    "memberPattern": "*.company.com",
    "role": "write"
  }'

# Bar former employee
atproto put-record \
  --collection io.atcr.hold.crew.barred \
  --rkey "bar-former-employee" \
  --value '{
    "$type": "io.atcr.hold.crew.barred",
    "hold": "at://did:plc:owner/io.atcr.hold/team",
    "member": "did:plc:former-employee",
    "reason": "No longer with company"
  }'

Result: All @*.company.com users can push, except the explicitly barred DID.

4. Anti-Spam with Barred Patterns

Goal: Public hold with protection against known spam instances.

Setup:

# Allow all users
atproto put-record \
  --collection io.atcr.hold.crew \
  --rkey "public-hold" \
  --value '{
    "$type": "io.atcr.hold.crew",
    "hold": "at://did:plc:owner/io.atcr.hold/public",
    "memberPattern": "*",
    "role": "write"
  }'

# Bar spam instance
atproto put-record \
  --collection io.atcr.hold.crew.barred \
  --rkey "bar-spam-pds" \
  --value '{
    "$type": "io.atcr.hold.crew.barred",
    "hold": "at://did:plc:owner/io.atcr.hold/public",
    "memberPattern": "*.known-spam.com",
    "reason": "Spam source"
  }'

Result: Everyone can push except users from *.known-spam.com.

5. Mixed Access (Explicit + Patterns)

Goal: Team pattern plus individual guests.

Setup:

# Team pattern
atproto put-record \
  --collection io.atcr.hold.crew \
  --rkey "team-pattern" \
  --value '{
    "$type": "io.atcr.hold.crew",
    "hold": "at://did:plc:owner/io.atcr.hold/team",
    "memberPattern": "*.company.com",
    "role": "write"
  }'

# Individual contractor
atproto put-record \
  --collection io.atcr.hold.crew \
  --rkey "contractor-alice" \
  --value '{
    "$type": "io.atcr.hold.crew",
    "hold": "at://did:plc:owner/io.atcr.hold/team",
    "member": "did:plc:alice-contractor",
    "role": "write"
  }'

Result: Team members + specific contractor all have access.

Implementation Details

Code Changes Required

Files to modify:

  1. lexicons/io/atcr/hold/crew.json

    • Make member optional (remove from required)
    • Add memberPattern field (string, optional)
    • Update description
  2. lexicons/io/atcr/hold/crew/barred.json (new file)

    • Define new lexicon for barred records
    • Same structure as crew (member + memberPattern)
    • Add reason field
  3. pkg/atproto/lexicon.go

    • Update HoldCrewRecord struct (add MemberPattern field, make Member pointer for optional)
    • Add BarredRecord struct
    • Add NewBarredRecord() constructor
    • Add BarredCollection constant
  4. pkg/hold/authorization.go

    • Update isCrewMember() to check patterns
    • Add isBarred() function
    • Add resolveHandle() helper (DID → handle lookup)
    • Add matchPattern() helper (glob matching)
    • Update isAuthorizedWrite() to check barred first
  5. pkg/hold/registration.go

    • Add HOLD_ALLOW_ALL_CREW env var handling
    • Check env var on every startup (not just first registration)
    • Reconcile desired state (env) vs actual state (PDS)
    • Create/delete wildcard crew record as needed

Pattern Matching Implementation

// pkg/hold/patterns.go (new file)

package hold

import (
    "regexp"
    "strings"
)

// matchPattern checks if a handle matches a pattern
func matchPattern(pattern, handle string) bool {
    if pattern == "*" {
        return true
    }

    // Convert glob to regex
    regex := globToRegex(pattern)
    matched, err := regexp.MatchString(regex, handle)
    if err != nil {
        return false
    }
    return matched
}

// globToRegex converts a glob pattern to a regex
// *.example.com → ^.*\.example\.com$
// subdomain.* → ^subdomain\..*$
// *.bsky.* → ^.*\.bsky\..*$
func globToRegex(pattern string) string {
    // Escape special regex characters except *
    escaped := regexp.QuoteMeta(pattern)

    // Replace escaped \* with .*
    regex := strings.ReplaceAll(escaped, "\\*", ".*")

    // Anchor to start and end
    return "^" + regex + "$"
}

Handle Resolution

// pkg/hold/resolve.go

package hold

import (
    "context"
    "github.com/bluesky-social/indigo/atproto/identity"
    "github.com/bluesky-social/indigo/atproto/syntax"
)

// resolveHandle resolves a DID to its current handle
func resolveHandle(did string) (string, error) {
    ctx := context.Background()
    directory := identity.DefaultDirectory()

    didParsed, err := syntax.ParseDID(did)
    if err != nil {
        return "", err
    }

    ident, err := directory.LookupDID(ctx, didParsed)
    if err != nil {
        return "", err
    }

    return ident.Handle.String(), nil
}

Caching Considerations

Problem: Pattern matching requires handle resolution, which adds latency.

Solution: Cache handle lookups with TTL.

type handleCache struct {
    mu    sync.RWMutex
    cache map[string]cacheEntry  // did → handle
}

type cacheEntry struct {
    handle    string
    expiresAt time.Time
}

const handleCacheTTL = 10 * time.Minute

func (c *handleCache) get(did string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()

    entry, ok := c.cache[did]
    if !ok || time.Now().After(entry.expiresAt) {
        return "", false
    }
    return entry.handle, true
}

func (c *handleCache) set(did, handle string) {
    c.mu.Lock()
    defer c.mu.Unlock()

    c.cache[did] = cacheEntry{
        handle:    handle,
        expiresAt: time.Now().Add(handleCacheTTL),
    }
}

Trade-offs:

  • Cache hit: Authorization instant
  • Cache miss: One additional PDS lookup (acceptable for writes)
  • TTL: 10 minutes balances freshness vs performance

HOLD_ALLOW_ALL_CREW Environment Variable

Purpose: Automatically manage wildcard crew access via environment variable.

Behavior: Checked on every startup (not just first registration):

  1. Read env var: HOLD_ALLOW_ALL_CREW (true/false)
  2. Query PDS: Check for crew record with rkey "allow-all" and memberPattern: "*"
  3. Reconcile state:
    • If env=true and record missing → Create wildcard crew record (requires OAuth)
    • If env=false (or unset) and record exists → Delete wildcard crew record (requires OAuth)
    • Otherwise → No action needed

Well-known record key: "allow-all" (used exclusively for the managed wildcard record)

Implementation:

// pkg/hold/config.go
type Config struct {
    Registration struct {
        OwnerDID       string
        AllowAllCrew   bool   // HOLD_ALLOW_ALL_CREW
    }
    // ...
}

// pkg/hold/registration.go
func (s *HoldService) ReconcileAllowAllCrew(callbackHandler *http.HandlerFunc) error {
    desiredState := s.config.Registration.AllowAllCrew

    // Query PDS for "allow-all" crew record
    actualState, err := s.hasAllowAllCrewRecord()
    if err != nil {
        return fmt.Errorf("failed to check allow-all crew record: %w", err)
    }

    // States match - nothing to do
    if desiredState == actualState {
        log.Printf("Allow-all crew state matches desired state: %v", desiredState)
        return nil
    }

    // State mismatch - need to reconcile
    if desiredState && !actualState {
        // Need to create wildcard crew record
        log.Printf("Creating allow-all crew record (HOLD_ALLOW_ALL_CREW=true)")
        return s.createAllowAllCrewRecord(callbackHandler)
    }

    if !desiredState && actualState {
        // Need to delete wildcard crew record
        log.Printf("Deleting allow-all crew record (HOLD_ALLOW_ALL_CREW removed/false)")
        return s.deleteAllowAllCrewRecord(callbackHandler)
    }

    return nil
}

func (s *HoldService) hasAllowAllCrewRecord() (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
    pdsEndpoint, err := s.resolveOwnerPDS(ownerDID)
    if err != nil {
        return false, err
    }

    // Query for specific rkey
    client := atproto.NewClient(pdsEndpoint, ownerDID, "")
    record, err := client.GetRecord(ctx, atproto.HoldCrewCollection, "allow-all")

    if err != nil {
        // Record doesn't exist
        return false, nil
    }

    // Verify it's the wildcard record (memberPattern: "*")
    var crewRecord atproto.HoldCrewRecord
    if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
        return false, err
    }

    // Check if it's the exact wildcard pattern
    return crewRecord.MemberPattern == "*", nil
}

func (s *HoldService) createAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error {
    // This requires OAuth - reuse registration OAuth flow
    // Need authenticated client to create record

    ownerDID := s.config.Registration.OwnerDID
    pdsEndpoint, err := s.resolveOwnerPDS(ownerDID)
    if err != nil {
        return err
    }

    // Get handle for OAuth
    handle, err := resolveHandleFromDID(ownerDID)
    if err != nil {
        return err
    }

    // Run OAuth flow (similar to registration)
    ctx := context.Background()
    result, err := oauth.InteractiveFlowWithCallback(
        ctx,
        s.config.Server.PublicURL,
        handle,
        s.getCrewManagementScopes(),
        func(handler http.HandlerFunc) error {
            *callbackHandler = handler
            return nil
        },
        func(authURL string) error {
            log.Printf("\n%s", strings.Repeat("=", 80))
            log.Printf("OAUTH REQUIRED: Creating allow-all crew record")
            log.Printf("%s", strings.Repeat("=", 80))
            log.Printf("\nVisit: %s\n", authURL)
            log.Printf("Waiting for authorization...")
            log.Printf("%s\n", strings.Repeat("=", 80))
            return nil
        },
    )
    if err != nil {
        return err
    }

    // Create authenticated client
    apiClient := result.Session.APIClient()
    client := atproto.NewClientWithIndigoClient(pdsEndpoint, ownerDID, apiClient)

    // Get hold URI (need to know which hold to grant access to)
    holdURI, err := s.getHoldURI()
    if err != nil {
        return err
    }

    // Create wildcard crew record
    crewRecord := atproto.HoldCrewRecord{
        Type:          atproto.HoldCrewCollection,
        Hold:          holdURI,
        MemberPattern: ptr("*"),  // Wildcard - allow all
        Role:          "write",
        CreatedAt:     time.Now(),
    }

    _, err = client.PutRecord(ctx, atproto.HoldCrewCollection, "allow-all", &crewRecord)
    if err != nil {
        return fmt.Errorf("failed to create allow-all crew record: %w", err)
    }

    log.Printf("✓ Created allow-all crew record (allows all authenticated users)")
    return nil
}

func (s *HoldService) deleteAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error {
    // Similar OAuth flow for deletion
    // Only delete if it's the exact wildcard pattern (safety check)

    isWildcard, err := s.hasAllowAllCrewRecord()
    if err != nil {
        return err
    }

    if !isWildcard {
        log.Printf("Warning: 'allow-all' crew record exists but is not wildcard - skipping deletion")
        return nil
    }

    // OAuth flow (same as create)
    ownerDID := s.config.Registration.OwnerDID
    pdsEndpoint, err := s.resolveOwnerPDS(ownerDID)
    if err != nil {
        return err
    }

    handle, err := resolveHandleFromDID(ownerDID)
    if err != nil {
        return err
    }

    ctx := context.Background()
    result, err := oauth.InteractiveFlowWithCallback(
        ctx,
        s.config.Server.PublicURL,
        handle,
        s.getCrewManagementScopes(),
        func(handler http.HandlerFunc) error {
            *callbackHandler = handler
            return nil
        },
        func(authURL string) error {
            log.Printf("\n%s", strings.Repeat("=", 80))
            log.Printf("OAUTH REQUIRED: Deleting allow-all crew record")
            log.Printf("%s", strings.Repeat("=", 80))
            log.Printf("\nVisit: %s\n", authURL)
            log.Printf("Waiting for authorization...")
            log.Printf("%s\n", strings.Repeat("=", 80))
            return nil
        },
    )
    if err != nil {
        return err
    }

    // Create authenticated client
    apiClient := result.Session.APIClient()
    client := atproto.NewClientWithIndigoClient(pdsEndpoint, ownerDID, apiClient)

    // Delete the record
    err = client.DeleteRecord(ctx, atproto.HoldCrewCollection, "allow-all")
    if err != nil {
        return fmt.Errorf("failed to delete allow-all crew record: %w", err)
    }

    log.Printf("✓ Deleted allow-all crew record")
    return nil
}

func (s *HoldService) getCrewManagementScopes() []string {
    return []string{
        "atproto",
        fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection),
        fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection),
        fmt.Sprintf("repo:%s?action=delete", atproto.HoldCrewCollection),
    }
}

// Helper for pointer
func ptr(s string) *string {
    return &s
}

Startup sequence:

// cmd/hold/main.go
func main() {
    // ... load config ...

    holdService := hold.NewHoldService(config)

    // Register HTTP routes
    var oauthCallbackHandler http.HandlerFunc
    http.HandleFunc("/auth/oauth/callback", func(w http.ResponseWriter, r *http.Request) {
        if oauthCallbackHandler != nil {
            oauthCallbackHandler(w, r)
        } else {
            http.Error(w, "OAuth callback not initialized", http.StatusInternalServerError)
        }
    })

    // Auto-register hold (if HOLD_OWNER set)
    if config.Registration.OwnerDID != "" {
        err := holdService.AutoRegister(&oauthCallbackHandler)
        if err != nil {
            log.Fatalf("Failed to register hold: %v", err)
        }

        // Reconcile allow-all crew record
        err = holdService.ReconcileAllowAllCrew(&oauthCallbackHandler)
        if err != nil {
            log.Fatalf("Failed to reconcile allow-all crew: %v", err)
        }
    }

    // Start server...
}

Key properties:

  1. Idempotent: Safe to run on every startup
  2. Well-known rkey: Uses "allow-all" exclusively for managed record
  3. Safety: Only deletes if memberPattern is exactly "*" (won't touch custom patterns like *.example.com)
  4. OAuth required: Both create and delete operations need authentication
  5. Reuses infrastructure: Same OAuth flow as registration

Example configurations:

# Public hold - allow all users
HOLD_ALLOW_ALL_CREW=true

# Private hold - explicit crew only
HOLD_ALLOW_ALL_CREW=false
# (or omit the variable entirely)

Edge cases handled:

  • Record exists with different pattern → Won't delete (safety)
  • OAuth fails → Service won't start (explicit failure)
  • PDS unreachable → Startup fails (can't verify state)
  • Record exists but env unset → Deletes wildcard (opt-in behavior)

Custom patterns preserved:

Hold owners can still manually create pattern-based crew records with different rkeys:

# Manually created pattern (rkey: "community")
atproto put-record \
  --collection io.atcr.hold.crew \
  --rkey "community" \
  --value '{
    "memberPattern": "*.my-community.social",
    "role": "write"
  }'

The HOLD_ALLOW_ALL_CREW management only touches the "allow-all" rkey with exact memberPattern: "*".

Migration Path

Backward Compatibility: Fully compatible with existing deployments.

  1. Existing crew records work unchanged

    • Records with member (DID) continue to work
    • No changes needed to existing records
  2. Opt-in patterns

    • Hold owners can add pattern-based crew records
    • Mix explicit DIDs and patterns freely
  3. Barred list is optional

    • Only needed for selective access revocation
    • Empty barred list = no blocking
  4. Lexicon evolution

    • Making member optional is backward compatible (existing records still have it)
    • Adding memberPattern is additive (old clients ignore it)

Future Enhancements

1. PDS-Based Access Control

Goal: Allow/bar users based on their PDS (not handle).

Challenge: ATProto doesn't give PDSes stable identifiers. PDS endpoints are mutable URLs.

Potential Solutions:

Option A: PDS DID Standard (if ATProto adds it)

If ATProto introduces PDS DIDs:

{
  "$type": "io.atcr.hold.crew",
  "hold": "at://did:plc:owner/io.atcr.hold/community",
  "memberPattern": "pds:did:plc:pds-id",
  "role": "write"
}

Option B: Accept PDS URL Mutability

Store PDS URLs with understanding they can change:

{
  "$type": "io.atcr.hold.crew",
  "hold": "at://did:plc:owner/io.atcr.hold/community",
  "memberPattern": "pds:https://my-community.social",
  "role": "write"
}

Trade-off: User migration bypasses access control, but this requires effort.

Option C: PDS Trust Lists (Federated Model)

Reference curated lists of trusted PDSes:

{
  "$type": "io.atcr.hold.crew",
  "hold": "at://did:plc:owner/io.atcr.hold/community",
  "memberPattern": "trust-list:at://did:plc:curator/trust.list/vetted-pds",
  "role": "write"
}

Status: Experimental. Requires additional standards.

2. Advanced Pattern Matching

Goal: Support more sophisticated patterns.

Potential patterns:

  • Regex: memberPattern: "regex:^eng-.*@company.com$"
  • Multiple patterns: memberPattern: ["*.example.com", "*.other.com"]
  • NOT patterns: memberPattern: "!*.spam.com" (everything except)

Implementation: Extend matchPattern() function with pattern type detection.

3. Temporary Access

Goal: Time-limited crew membership.

Current support: expiresAt field already in schema (optional).

Enhancement: Hold service automatically checks expiration during authorization:

if record.ExpiresAt != nil && time.Now().After(*record.ExpiresAt) {
    continue  // Skip expired crew record
}

4. Role-Based Access Control (RBAC)

Goal: Fine-grained permissions beyond read/write.

Potential roles:

  • "read" - Pull only
  • "write" - Push + pull
  • "admin" - Manage crew records
  • "owner" - Full control

Current status: role field exists but only "owner" and "write" are used.

5. Audit Logging

Goal: Track access grants/denials for compliance.

Implementation:

  • Log crew checks to structured log
  • Include: DID, handle, result (allow/deny), reason
  • Optional: Write to ATProto audit log record

Security Considerations

1. Public Records

Consideration: Crew and barred records are public ATProto records.

Implications:

  • Anyone can see who has access to a hold
  • Anyone can see who is barred (and why)
  • Similar to Bluesky block lists being public

Mitigation: This is intentional transparency. Hold owners should use generic reasons in barred records if privacy is a concern.

2. Handle Changes

Consideration: Handles can change, but DIDs are permanent.

Implications:

  • Pattern matching based on handles can be bypassed by changing handle
  • DID-based rules are more stable
  • However, changing handles or acquiring new domains requires significant effort:
    • Purchasing new domain names ($10-100+/year)
    • Updating identity across platforms
    • Loss of established reputation/identity

Recommendation:

  • Use DID-based crew/barred records for critical access control (permanent)
  • Use pattern-based rules for convenience and community management
  • The effort required to bypass handle patterns makes them an acceptable deterrent
  • Combine both approaches for defense in depth

3. PDS Migration

Consideration: Users can migrate to different PDSes.

Implications:

  • PDS-based patterns (future) can be bypassed by migration
  • Handle patterns persist across PDS migration (if handle stays same)

Recommendation: Accept this as inherent trade-off. Migration requires user effort and is acceptable "escape hatch."

4. Pattern Matching Performance

Consideration: Complex patterns could cause ReDoS (regex denial of service).

Mitigation:

  • Limit pattern complexity (only basic globs in v1)
  • Cache handle lookups to minimize repeated work
  • Set timeout on pattern matching operations

5. Barred List Circumvention

Consideration: Barred users might create new DIDs.

Mitigation:

  • This is fundamental to decentralized identity (users control DIDs)
  • Hold owners can add new DIDs to barred list as discovered
  • Pattern-based barring (handle/PDS patterns) provides broader coverage

Testing Strategy

Unit Tests

Pattern matching:

func TestMatchPattern(t *testing.T) {
    tests := []struct{
        pattern string
        handle  string
        want    bool
    }{
        {"*", "anything.com", true},
        {"*.example.com", "alice.example.com", true},
        {"*.example.com", "bob.other.com", false},
        {"eng.*", "eng.company.com", true},
        {"eng.*", "sales.company.com", false},
    }
    // ...
}

Authorization logic:

func TestIsAuthorizedWrite(t *testing.T) {
    // Test: owner always allowed
    // Test: explicit crew member allowed
    // Test: pattern match allowed
    // Test: barred user denied
    // Test: barred pattern denied
    // Test: barred overrides crew
}

Integration Tests

  1. Create hold with wildcard crew → verify any user can write
  2. Add barred record → verify barred user rejected
  3. Pattern-based crew → verify matching handles allowed
  4. Mixed access → verify explicit + pattern both work
  5. Handle resolution failure → verify fallback to DID-only matching

Performance Tests

  1. Large crew list (1000+ records) → measure query time
  2. Complex patterns → measure pattern matching time
  3. Handle cache → verify cache hit rate
  4. Concurrent requests → verify no race conditions

References

Appendix: Lexicon Definitions

lexicons/io/atcr/hold/crew.json (Updated)

{
  "lexicon": 1,
  "id": "io.atcr.hold.crew",
  "defs": {
    "main": {
      "type": "record",
      "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.",
      "key": "any",
      "record": {
        "type": "object",
        "required": ["hold", "role", "createdAt"],
        "properties": {
          "hold": {
            "type": "string",
            "format": "at-uri",
            "description": "AT-URI of the hold record (e.g., 'at://did:plc:owner/io.atcr.hold/hold1')"
          },
          "member": {
            "type": "string",
            "format": "did",
            "description": "DID of crew member (for individual access with backlinks). Exactly one of 'member' or 'memberPattern' must be set."
          },
          "memberPattern": {
            "type": "string",
            "description": "Pattern for matching multiple users. Supports wildcards: '*' (all users), '*.domain.com' (handle glob). Exactly one of 'member' or 'memberPattern' must be set."
          },
          "role": {
            "type": "string",
            "description": "Member's role/permissions. 'owner' = hold owner, 'write' = can push blobs.",
            "knownValues": ["owner", "write"]
          },
          "expiresAt": {
            "type": "string",
            "format": "datetime",
            "description": "Optional expiration for this membership"
          },
          "createdAt": {
            "type": "string",
            "format": "datetime",
            "description": "Membership creation timestamp"
          }
        }
      }
    }
  }
}

lexicons/io/atcr/hold/crew/barred.json (New)

{
  "lexicon": 1,
  "id": "io.atcr.hold.crew.barred",
  "defs": {
    "main": {
      "type": "record",
      "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.",
      "key": "any",
      "record": {
        "type": "object",
        "required": ["hold", "barredAt"],
        "properties": {
          "hold": {
            "type": "string",
            "format": "at-uri",
            "description": "AT-URI of the hold record"
          },
          "member": {
            "type": "string",
            "format": "did",
            "description": "DID of user to bar. Exactly one of 'member' or 'memberPattern' must be set."
          },
          "memberPattern": {
            "type": "string",
            "description": "Pattern for barring multiple users. Supports wildcards: '*.spam.com', 'bot*', etc. Exactly one of 'member' or 'memberPattern' must be set."
          },
          "reason": {
            "type": "string",
            "maxLength": 300,
            "description": "Optional human-readable reason for barring (e.g., 'spam', 'abuse', 'policy violation')"
          },
          "barredAt": {
            "type": "string",
            "format": "datetime",
            "description": "When the user/pattern was barred"
          }
        }
      }
    }
  }
}

Summary

This design enables scalable, flexible access control for ATCR holds while:

  • Preserving ATProto semantics (DID backlinks, public records)
  • Supporting massive scale (one record for thousands of users)
  • Enabling selective revocation (barred list)
  • Maintaining backward compatibility (existing records work unchanged)
  • Planning for future enhancements (PDS-based filtering when possible)

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! 🥁