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.