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:
- Public/shared holds - Thousands of users would need individual crew records
- Community holds - PDS operators want to allow all their users
- Default registries - AppView operators want to allow all authenticated users
- Access revocation - No way to selectively remove access from wildcard/pattern grants
Design Goals
- Preserve ATProto semantics - Keep
memberas DID type for backlinks - Scalable - Support thousands of users with minimal records
- Flexible patterns - Support wildcards, handle globs, future regex
- Clear semantics - Separate allow/deny (crew vs barred)
- Backward compatible - Existing crew records work unchanged
- 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 recordmember(string, did, optional) - Explicit DID for individual access (enables backlinks)memberPattern(string, optional) - Pattern for matching multiple usersrole(string, required) - Role:"owner"or"write"expiresAt(string, datetime, optional) - Optional expirationcreatedAt(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 withsubdomain."*.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 recordmember(string, did, optional) - Explicit DID to barmemberPattern(string, optional) - Pattern for barring multiple usersreason(string, optional) - Human-readable reason for access revocationbarredAt(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:
-
lexicons/io/atcr/hold/crew.json- Make
memberoptional (remove fromrequired) - Add
memberPatternfield (string, optional) - Update description
- Make
-
lexicons/io/atcr/hold/crew/barred.json(new file)- Define new lexicon for barred records
- Same structure as crew (member + memberPattern)
- Add
reasonfield
-
pkg/atproto/lexicon.go- Update
HoldCrewRecordstruct (addMemberPatternfield, makeMemberpointer for optional) - Add
BarredRecordstruct - Add
NewBarredRecord()constructor - Add
BarredCollectionconstant
- Update
-
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
- Update
-
pkg/hold/registration.go- Add
HOLD_ALLOW_ALL_CREWenv 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
- Add
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):
- Read env var:
HOLD_ALLOW_ALL_CREW(true/false) - Query PDS: Check for crew record with rkey
"allow-all"andmemberPattern: "*" - Reconcile state:
- If env=
trueand 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
- If env=
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:
- Idempotent: Safe to run on every startup
- Well-known rkey: Uses
"allow-all"exclusively for managed record - Safety: Only deletes if
memberPatternis exactly"*"(won't touch custom patterns like*.example.com) - OAuth required: Both create and delete operations need authentication
- 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.
-
Existing crew records work unchanged
- Records with
member(DID) continue to work - No changes needed to existing records
- Records with
-
Opt-in patterns
- Hold owners can add pattern-based crew records
- Mix explicit DIDs and patterns freely
-
Barred list is optional
- Only needed for selective access revocation
- Empty barred list = no blocking
-
Lexicon evolution
- Making
memberoptional is backward compatible (existing records still have it) - Adding
memberPatternis additive (old clients ignore it)
- Making
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
- Create hold with wildcard crew → verify any user can write
- Add barred record → verify barred user rejected
- Pattern-based crew → verify matching handles allowed
- Mixed access → verify explicit + pattern both work
- Handle resolution failure → verify fallback to DID-only matching
Performance Tests
- Large crew list (1000+ records) → measure query time
- Complex patterns → measure pattern matching time
- Handle cache → verify cache hit rate
- Concurrent requests → verify no race conditions
References
- ATProto Lexicon Spec
- Bluesky Block Lists (analogous public records)
- Go Glob Matching
- OAuth Scopes (for crew management permissions)
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! 🥁