mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-05-01 21:45:46 +00:00
1232 lines
35 KiB
Markdown
1232 lines
35 KiB
Markdown
# 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**.
|
|
|
|
```json
|
|
{
|
|
"$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:**
|
|
|
|
```json
|
|
// 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**.
|
|
|
|
```json
|
|
{
|
|
"$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:**
|
|
|
|
```json
|
|
// 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:**
|
|
|
|
```go
|
|
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:**
|
|
|
|
```go
|
|
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:**
|
|
```bash
|
|
# 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:**
|
|
```bash
|
|
# 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:**
|
|
```bash
|
|
# 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:**
|
|
```bash
|
|
# 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:**
|
|
```bash
|
|
# 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
|
|
|
|
```go
|
|
// 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
|
|
|
|
```go
|
|
// 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.
|
|
|
|
```go
|
|
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:**
|
|
|
|
```go
|
|
// 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:**
|
|
|
|
```go
|
|
// 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:**
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
```json
|
|
{
|
|
"$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:
|
|
|
|
```json
|
|
{
|
|
"$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:
|
|
|
|
```json
|
|
{
|
|
"$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:
|
|
|
|
```go
|
|
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:**
|
|
```go
|
|
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:**
|
|
```go
|
|
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
|
|
|
|
- [ATProto Lexicon Spec](https://atproto.com/specs/lexicon)
|
|
- [Bluesky Block Lists](https://bsky.app/profile/bsky.app/post/3l7wzyc6i622o) (analogous public records)
|
|
- [Go Glob Matching](https://pkg.go.dev/path/filepath#Match)
|
|
- [OAuth Scopes](https://atproto.com/specs/oauth#scopes) (for crew management permissions)
|
|
|
|
## Appendix: Lexicon Definitions
|
|
|
|
### lexicons/io/atcr/hold/crew.json (Updated)
|
|
|
|
```json
|
|
{
|
|
"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)
|
|
|
|
```json
|
|
{
|
|
"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! 🥁
|