Chapter 7: Sharing and Permissions Patterns

When building collaborative AI coding assistants, one of the trickiest aspects isn't the AI itself—it's figuring out how to let people share their work without accidentally exposing something they shouldn't. This chapter explores patterns for implementing sharing and permissions that balance security, usability, and implementation complexity.

The Three-Tier Sharing Model

A common pattern for collaborative AI assistants is a three-tier sharing model. This approach balances simplicity with flexibility, using two boolean flags—private and public—to create three distinct states:

interface ShareableResource {
    private: boolean
    public: boolean
}

// Three sharing states:
// 1. Private (private: true, public: false) - Only creator access
// 2. Team (private: false, public: false) - Shared with team members  
// 3. Public (private: false, public: true) - Anyone with URL can access

async updateSharingState(
    resourceID: string,
    meta: Pick<ShareableResource, 'private' | 'public'>
): Promise<void> {
    // Validate state transition
    if (meta.private && meta.public) {
        throw new Error('Invalid state: cannot be both private and public')
    }
    
    // Optimistic update for UI responsiveness
    this.updateLocalState(resourceID, meta)
    
    try {
        // Sync with server
        await this.syncToServer(resourceID, meta)
    } catch (error) {
        // Rollback on failure
        this.revertLocalState(resourceID)
        throw error
    }
}

This design choice uses two booleans instead of an enum for several reasons:

  • State transitions become more explicit
  • Prevents accidental visibility changes through single field updates
  • Creates an invalid fourth state that can be detected and rejected
  • Maps naturally to user interface controls

Permission Inheritance Patterns

When designing permission systems for hierarchical resources, you face a fundamental choice: inheritance versus independence. Complex permission inheritance can lead to unexpected exposure when parent permissions change. A simpler approach treats each resource independently.

interface HierarchicalResource {
    id: string
    parentID?: string
    childIDs: string[]
    permissions: ResourcePermissions
}

// Independent permissions - each resource manages its own access
class IndependentPermissionModel {
    async updatePermissions(
        resourceID: string, 
        newPermissions: ResourcePermissions
    ): Promise<void> {
        // Only affects this specific resource
        await this.permissionStore.update(resourceID, newPermissions)
        
        // No cascading to children or parents
        // Users must explicitly manage each resource
    }
    
    async getEffectivePermissions(
        resourceID: string, 
        userID: string
    ): Promise<EffectivePermissions> {
        // Only check the resource itself
        const resource = await this.getResource(resourceID)
        return this.evaluatePermissions(resource.permissions, userID)
    }
}

// When syncing resources, treat each independently
for (const resource of resourcesToSync) {
    if (processed.has(resource.id)) {
        continue
    }
    processed.add(resource.id)
    
    // Each resource carries its own permission metadata
    syncRequest.resources.push({
        id: resource.id,
        permissions: resource.permissions,
        // No inheritance from parents
    })
}

This approach keeps the permission model simple and predictable. Users understand exactly what happens when they change sharing settings without worrying about cascading effects.

URL-Based Sharing Implementation

URL-based sharing creates a capability system where knowledge of the URL grants access. This pattern is widely used in modern applications.

// Generate unguessable resource identifiers
type ResourceID = `R-${string}`

function generateResourceID(): ResourceID {
    return `R-${crypto.randomUUID()}`
}

function buildResourceURL(baseURL: URL, resourceID: ResourceID): URL {
    return new URL(`/shared/${resourceID}`, baseURL)
}

// Security considerations for URL-based sharing
class URLSharingService {
    async createShareableLink(
        resourceID: ResourceID,
        permissions: SharePermissions
    ): Promise<ShareableLink> {
        // Generate unguessable token
        const shareToken = crypto.randomUUID()
        
        // Store mapping with expiration
        await this.shareStore.create({
            token: shareToken,
            resourceID,
            permissions,
            expiresAt: new Date(Date.now() + permissions.validForMs),
            createdBy: permissions.creatorID
        })
        
        return {
            url: new URL(`/share/${shareToken}`, this.baseURL),
            expiresAt: new Date(Date.now() + permissions.validForMs),
            permissions
        }
    }
    
    async validateShareAccess(
        shareToken: string,
        requesterID: string
    ): Promise<AccessResult> {
        const share = await this.shareStore.get(shareToken)
        
        if (!share || share.expiresAt < new Date()) {
            return { allowed: false, reason: 'Link expired or invalid' }
        }
        
        // Check if additional authentication is required
        if (share.permissions.requiresAuth && !requesterID) {
            return { allowed: false, reason: 'Authentication required' }
        }
        
        return { 
            allowed: true, 
            resourceID: share.resourceID,
            effectivePermissions: share.permissions
        }
    }
}

// Defense in depth: URL capability + authentication
class SecureAPIClient {
    async makeRequest(endpoint: string, options: RequestOptions): Promise<Response> {
        return fetch(new URL(endpoint, this.baseURL), {
            ...options,
            headers: {
                ...options.headers,
                'Content-Type': 'application/json',
                'Authorization': `Bearer ${this.apiKey}`,
                'X-Client-ID': this.clientID,
            },
        })
    }
}

This dual approach provides defense in depth: the URL grants capability, but authentication verifies identity. Even if someone discovers a shared URL, they still need valid credentials for sensitive operations.

Security Considerations

Implementing secure sharing requires several defensive patterns:

Optimistic Updates with Rollback

For responsive UIs, optimistic updates show changes immediately while syncing in the background:

class SecurePermissionService {
    async updatePermissions(
        resourceID: string, 
        newPermissions: ResourcePermissions
    ): Promise<void> {
        // Capture current state for rollback
        const previousState = this.localState.get(resourceID)
        
        try {
            // Optimistic update for immediate UI feedback
            this.localState.set(resourceID, {
                status: 'syncing',
                permissions: newPermissions,
                lastUpdated: Date.now()
            })
            this.notifyStateChange(resourceID)
            
            // Sync with server
            await this.syncToServer(resourceID, newPermissions)
            
            // Mark as synced
            this.localState.set(resourceID, {
                status: 'synced',
                permissions: newPermissions,
                lastUpdated: Date.now()
            })
            
        } catch (error) {
            // Rollback on failure
            if (previousState) {
                this.localState.set(resourceID, previousState)
            } else {
                this.localState.delete(resourceID)
            }
            this.notifyStateChange(resourceID)
            throw error
        }
    }
}

Intelligent Retry Logic

Network failures shouldn't result in permanent inconsistency:

class ResilientSyncService {
    private readonly RETRY_BACKOFF_MS = 60000 // 1 minute
    private failedAttempts = new Map<string, number>()
    
    shouldRetrySync(resourceID: string): boolean {
        const lastFailed = this.failedAttempts.get(resourceID)
        if (!lastFailed) {
            return true // Never failed, okay to try
        }
        
        const elapsed = Date.now() - lastFailed
        return elapsed >= this.RETRY_BACKOFF_MS
    }
    
    async attemptSync(resourceID: string): Promise<void> {
        try {
            await this.performSync(resourceID)
            // Clear failure record on success
            this.failedAttempts.delete(resourceID)
        } catch (error) {
            // Record failure time
            this.failedAttempts.set(resourceID, Date.now())
            throw error
        }
    }
}

Support Access Patterns

Separate mechanisms for support access maintain clear boundaries:

class SupportAccessService {
    async grantSupportAccess(
        resourceID: string,
        userID: string,
        reason: string
    ): Promise<SupportAccessGrant> {
        // Validate user can grant support access
        const resource = await this.getResource(resourceID)
        if (!this.canGrantSupportAccess(resource, userID)) {
            throw new Error('Insufficient permissions to grant support access')
        }
        
        // Create time-limited support access
        const grant: SupportAccessGrant = {
            id: crypto.randomUUID(),
            resourceID,
            grantedBy: userID,
            reason,
            expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
            permissions: { read: true, debug: true }
        }
        
        await this.supportAccessStore.create(grant)
        
        // Audit log
        await this.auditLogger.log({
            action: 'support_access_granted',
            resourceID,
            grantedBy: userID,
            grantID: grant.id,
            reason
        })
        
        return grant
    }
}

These patterns provide multiple layers of protection while maintaining usability and supporting legitimate operational needs.

Real-World Implementation Details

Production systems require pragmatic solutions for common challenges:

API Versioning and Fallbacks

When evolving APIs, graceful degradation ensures system reliability:

class VersionedAPIClient {
    private useNewAPI: boolean = true
    
    async updateResource(
        resourceID: string, 
        updates: ResourceUpdates
    ): Promise<void> {
        let newAPISucceeded = false
        
        if (this.useNewAPI) {
            try {
                const response = await this.callNewAPI(resourceID, updates)
                if (response.ok) {
                    newAPISucceeded = true
                }
            } catch (error) {
                // Log but don't fail - will try fallback
                this.logAPIError('new_api_failed', error)
            }
        }
        
        if (!newAPISucceeded) {
            // Fallback to older API format
            await this.callLegacyAPI(resourceID, this.transformToLegacy(updates))
        }
    }
    
    private transformToLegacy(updates: ResourceUpdates): LegacyUpdates {
        // Transform new format to legacy API expectations
        return {
            private: updates.visibility === 'private',
            public: updates.visibility === 'public',
            // Map other fields...
        }
    }
}

Avoiding Empty State Sync

Don't synchronize resources that provide no value:

class IntelligentSyncService {
    shouldSyncResource(resource: SyncableResource): boolean {
        // Skip empty or placeholder resources
        if (this.isEmpty(resource)) {
            return false
        }
        
        // Skip resources that haven't been meaningfully used
        if (this.isUnused(resource)) {
            return false
        }
        
        // Skip resources with only metadata
        if (this.hasOnlyMetadata(resource)) {
            return false
        }
        
        return true
    }
    
    private isEmpty(resource: SyncableResource): boolean {
        return (
            !resource.content?.length &&
            !resource.interactions?.length &&
            !resource.modifications?.length
        )
    }
    
    private isUnused(resource: SyncableResource): boolean {
        const timeSinceCreation = Date.now() - resource.createdAt
        const hasMinimalUsage = resource.interactionCount < 3
        
        // Created recently but barely used
        return timeSinceCreation < 5 * 60 * 1000 && hasMinimalUsage
    }
}

Configuration-Driven Behavior

Use feature flags for gradual rollouts and emergency rollbacks:

interface FeatureFlags {
    enableNewPermissionSystem: boolean
    strictPermissionValidation: boolean
    allowCrossTeamSharing: boolean
    enableAuditLogging: boolean
}

class ConfigurablePermissionService {
    constructor(
        private config: FeatureFlags,
        private legacyService: LegacyPermissionService,
        private newService: NewPermissionService
    ) {}
    
    async checkPermissions(
        resourceID: string, 
        userID: string
    ): Promise<PermissionResult> {
        if (this.config.enableNewPermissionSystem) {
            const result = await this.newService.check(resourceID, userID)
            
            if (this.config.strictPermissionValidation) {
                // Also validate with legacy system for comparison
                const legacyResult = await this.legacyService.check(resourceID, userID)
                this.compareResults(result, legacyResult, resourceID, userID)
            }
            
            return result
        } else {
            return this.legacyService.check(resourceID, userID)
        }
    }
}

These patterns acknowledge that production systems evolve gradually and need mechanisms for safe transitions.

Performance Optimizations

Permission systems can become performance bottlenecks without careful optimization:

Batching and Debouncing

Group rapid changes to reduce server load:

class OptimizedSyncService {
    private pendingUpdates = new BehaviorSubject<Set<string>>(new Set())
    
    constructor() {
        // Batch updates with debouncing
        this.pendingUpdates.pipe(
            filter(updates => updates.size > 0),
            debounceTime(3000), // Wait 3 seconds for additional changes
            map(updates => Array.from(updates))
        ).subscribe(resourceIDs => {
            this.processBatch(resourceIDs).catch(error => {
                this.logger.error('Batch sync failed:', error)
            })
        })
    }
    
    queueUpdate(resourceID: string): void {
        const current = this.pendingUpdates.value
        current.add(resourceID)
        this.pendingUpdates.next(current)
    }
    
    private async processBatch(resourceIDs: string[]): Promise<void> {
        // Batch API call instead of individual requests
        const updates = await this.gatherUpdates(resourceIDs)
        await this.apiClient.batchUpdate(updates)
        
        // Clear processed items
        const remaining = this.pendingUpdates.value
        resourceIDs.forEach(id => remaining.delete(id))
        this.pendingUpdates.next(remaining)
    }
}

Local Caching Strategy

Cache permission state locally for immediate UI responses:

class CachedPermissionService {
    private permissionCache = new Map<string, CachedPermission>()
    private readonly CACHE_TTL = 5 * 60 * 1000 // 5 minutes
    
    async checkPermission(
        resourceID: string, 
        userID: string
    ): Promise<PermissionResult> {
        const cacheKey = `${resourceID}:${userID}`
        const cached = this.permissionCache.get(cacheKey)
        
        // Return cached result if fresh
        if (cached && this.isFresh(cached)) {
            return cached.result
        }
        
        // Fetch from server
        const result = await this.fetchPermission(resourceID, userID)
        
        // Cache for future use
        this.permissionCache.set(cacheKey, {
            result,
            timestamp: Date.now()
        })
        
        return result
    }
    
    private isFresh(cached: CachedPermission): boolean {
        return Date.now() - cached.timestamp < this.CACHE_TTL
    }
    
    // Invalidate cache when permissions change
    invalidateUser(userID: string): void {
        for (const [key, _] of this.permissionCache) {
            if (key.endsWith(`:${userID}`)) {
                this.permissionCache.delete(key)
            }
        }
    }
    
    invalidateResource(resourceID: string): void {
        for (const [key, _] of this.permissionCache) {
            if (key.startsWith(`${resourceID}:`)) {
                this.permissionCache.delete(key)
            }
        }
    }
}

Preemptive Permission Loading

Load permissions for likely-needed resources:

class PreemptivePermissionLoader {
    async preloadPermissions(context: UserContext): Promise<void> {
        // Load permissions for recently accessed resources
        const recentResources = await this.getRecentResources(context.userID)
        
        // Load permissions for team resources
        const teamResources = await this.getTeamResources(context.teamIDs)
        
        // Batch load to minimize API calls
        const allResources = [...recentResources, ...teamResources]
        const permissions = await this.batchLoadPermissions(
            allResources, 
            context.userID
        )
        
        // Populate cache
        permissions.forEach(perm => {
            this.cache.set(`${perm.resourceID}:${context.userID}`, {
                result: perm,
                timestamp: Date.now()
            })
        })
    }
}

These optimizations ensure that permission checks don't become a user experience bottleneck while maintaining security guarantees.

Design Trade-offs

The implementation reveals several interesting trade-offs:

Simplicity vs. Flexibility: The three-tier model is simple to understand and implement but doesn't support fine-grained permissions like "share with specific users" or "read-only access." This is probably the right choice for a tool focused on individual developers and small teams.

Security vs. Convenience: URL-based sharing makes it easy to share threads (just send a link!) but means anyone with the URL can access public threads. The UUID randomness provides security, but it's still a capability-based model.

Consistency vs. Performance: The optimistic updates make the UI feel responsive, but they create a window where the local state might not match the server state. The implementation handles this gracefully with rollbacks, but it's added complexity.

Backward Compatibility vs. Clean Code: The fallback API mechanism adds code complexity but ensures smooth deployments and rollbacks. This is the kind of pragmatic decision that production systems require.

Implementation Principles

When building sharing systems for collaborative AI tools, consider these key principles:

1. Start Simple

The three-tier model (private/team/public) covers most use cases without complex ACL systems. You can always add complexity later if needed.

2. Make State Transitions Explicit

Using separate flags rather than enums makes permission changes more intentional and prevents accidental exposure.

3. Design for Failure

Implement optimistic updates with rollback, retry logic with backoff, and graceful degradation patterns.

4. Cache Strategically

Local caching prevents permission checks from blocking UI interactions while maintaining security.

5. Support Operational Needs

Plan for support workflows, debugging access, and administrative overrides from the beginning.

6. Optimize for Common Patterns

Most developers follow predictable sharing patterns:

  • Private work during development
  • Team sharing for code review
  • Public sharing for teaching or documentation

Design your system around these natural workflows rather than trying to support every possible permission combination.

7. Maintain Audit Trails

Track permission changes for debugging, compliance, and security analysis.

interface PermissionAuditEvent {
    timestamp: Date
    resourceID: string
    userID: string
    action: 'granted' | 'revoked' | 'modified'
    previousState?: PermissionState
    newState: PermissionState
    reason?: string
}

8. Consider Privacy by Design

Default to private sharing and require explicit action to increase visibility. Make the implications of each sharing level clear to users.

The most important insight is that effective permission systems align with human trust patterns and workflows. Technical complexity should serve user needs, not create barriers to collaboration.