Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: granular role-based document permissions (#51)' (#133) from feat/granular-permissions into main

scott 88982f01 9f570d0b

+397
+210
src/lib/permissions.ts
··· 1 + /** 2 + * Granular Permissions — role-based access control for documents. 3 + * 4 + * Pure logic module: permission definitions, role checks, sharing. 5 + * Storage and enforcement handled by the application layer. 6 + */ 7 + 8 + export type Permission = 'view' | 'comment' | 'edit' | 'admin'; 9 + 10 + export type Role = 'viewer' | 'commenter' | 'editor' | 'owner'; 11 + 12 + export interface DocumentPermission { 13 + userId: string; 14 + userName: string; 15 + role: Role; 16 + grantedAt: number; 17 + grantedBy: string; 18 + } 19 + 20 + export interface ShareLink { 21 + id: string; 22 + docId: string; 23 + role: Role; 24 + createdAt: number; 25 + createdBy: string; 26 + expiresAt: number | null; 27 + maxUses: number | null; 28 + useCount: number; 29 + } 30 + 31 + /** Permissions each role grants (cumulative) */ 32 + const ROLE_PERMISSIONS: Record<Role, Permission[]> = { 33 + viewer: ['view'], 34 + commenter: ['view', 'comment'], 35 + editor: ['view', 'comment', 'edit'], 36 + owner: ['view', 'comment', 'edit', 'admin'], 37 + }; 38 + 39 + /** Role hierarchy (higher index = more permissions) */ 40 + const ROLE_HIERARCHY: Role[] = ['viewer', 'commenter', 'editor', 'owner']; 41 + 42 + /** 43 + * Check if a role has a specific permission. 44 + */ 45 + export function hasPermission(role: Role, permission: Permission): boolean { 46 + return ROLE_PERMISSIONS[role]?.includes(permission) ?? false; 47 + } 48 + 49 + /** 50 + * Get all permissions for a role. 51 + */ 52 + export function getPermissions(role: Role): Permission[] { 53 + return [...(ROLE_PERMISSIONS[role] || [])]; 54 + } 55 + 56 + /** 57 + * Compare two roles. Returns negative if a < b, 0 if equal, positive if a > b. 58 + */ 59 + export function compareRoles(a: Role, b: Role): number { 60 + return ROLE_HIERARCHY.indexOf(a) - ROLE_HIERARCHY.indexOf(b); 61 + } 62 + 63 + /** 64 + * Check if roleA is at least as privileged as roleB. 65 + */ 66 + export function isAtLeast(roleA: Role, roleB: Role): boolean { 67 + return compareRoles(roleA, roleB) >= 0; 68 + } 69 + 70 + /** 71 + * Grant a permission to a user on a document. 72 + */ 73 + export function grantPermission( 74 + permissions: DocumentPermission[], 75 + userId: string, 76 + userName: string, 77 + role: Role, 78 + grantedBy: string, 79 + now = Date.now(), 80 + ): DocumentPermission[] { 81 + // Update existing or add new 82 + const existing = permissions.findIndex(p => p.userId === userId); 83 + const entry: DocumentPermission = { 84 + userId, 85 + userName, 86 + role, 87 + grantedAt: now, 88 + grantedBy, 89 + }; 90 + 91 + if (existing !== -1) { 92 + const updated = [...permissions]; 93 + updated[existing] = entry; 94 + return updated; 95 + } 96 + 97 + return [...permissions, entry]; 98 + } 99 + 100 + /** 101 + * Revoke a user's permission on a document. 102 + * Cannot revoke the owner's permission. 103 + */ 104 + export function revokePermission( 105 + permissions: DocumentPermission[], 106 + userId: string, 107 + ): DocumentPermission[] { 108 + const target = permissions.find(p => p.userId === userId); 109 + if (!target || target.role === 'owner') return permissions; 110 + return permissions.filter(p => p.userId !== userId); 111 + } 112 + 113 + /** 114 + * Get a user's role on a document, or null if not authorized. 115 + */ 116 + export function getUserRole( 117 + permissions: DocumentPermission[], 118 + userId: string, 119 + ): Role | null { 120 + const perm = permissions.find(p => p.userId === userId); 121 + return perm ? perm.role : null; 122 + } 123 + 124 + /** 125 + * Check if a user can perform a specific action. 126 + */ 127 + export function canPerform( 128 + permissions: DocumentPermission[], 129 + userId: string, 130 + permission: Permission, 131 + ): boolean { 132 + const role = getUserRole(permissions, userId); 133 + if (!role) return false; 134 + return hasPermission(role, permission); 135 + } 136 + 137 + /** 138 + * Check if a granter can assign a specific role. 139 + * Users can only assign roles equal to or below their own. 140 + */ 141 + export function canGrantRole( 142 + granterRole: Role, 143 + targetRole: Role, 144 + ): boolean { 145 + return isAtLeast(granterRole, targetRole); 146 + } 147 + 148 + /** 149 + * Create a share link. 150 + */ 151 + export function createShareLink( 152 + docId: string, 153 + role: Role, 154 + createdBy: string, 155 + options?: { expiresAt?: number; maxUses?: number }, 156 + now = Date.now(), 157 + ): ShareLink { 158 + return { 159 + id: `share-${now}-${Math.random().toString(36).slice(2, 8)}`, 160 + docId, 161 + role, 162 + createdAt: now, 163 + createdBy, 164 + expiresAt: options?.expiresAt ?? null, 165 + maxUses: options?.maxUses ?? null, 166 + useCount: 0, 167 + }; 168 + } 169 + 170 + /** 171 + * Check if a share link is still valid. 172 + */ 173 + export function isShareLinkValid( 174 + link: ShareLink, 175 + now = Date.now(), 176 + ): boolean { 177 + if (link.expiresAt !== null && now > link.expiresAt) return false; 178 + if (link.maxUses !== null && link.useCount >= link.maxUses) return false; 179 + return true; 180 + } 181 + 182 + /** 183 + * Increment the use count of a share link. 184 + */ 185 + export function useShareLink(link: ShareLink): ShareLink { 186 + return { ...link, useCount: link.useCount + 1 }; 187 + } 188 + 189 + /** 190 + * List all users with a specific role. 191 + */ 192 + export function getUsersByRole( 193 + permissions: DocumentPermission[], 194 + role: Role, 195 + ): DocumentPermission[] { 196 + return permissions.filter(p => p.role === role); 197 + } 198 + 199 + /** 200 + * Count users by role. 201 + */ 202 + export function countByRole( 203 + permissions: DocumentPermission[], 204 + ): Record<Role, number> { 205 + const counts: Record<Role, number> = { viewer: 0, commenter: 0, editor: 0, owner: 0 }; 206 + for (const p of permissions) { 207 + counts[p.role]++; 208 + } 209 + return counts; 210 + }
+187
tests/permissions.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + hasPermission, 4 + getPermissions, 5 + compareRoles, 6 + isAtLeast, 7 + grantPermission, 8 + revokePermission, 9 + getUserRole, 10 + canPerform, 11 + canGrantRole, 12 + createShareLink, 13 + isShareLinkValid, 14 + useShareLink, 15 + getUsersByRole, 16 + countByRole, 17 + type DocumentPermission, 18 + } from '../src/lib/permissions.js'; 19 + 20 + const PERMS: DocumentPermission[] = [ 21 + { userId: 'alice', userName: 'Alice', role: 'owner', grantedAt: 1000, grantedBy: 'system' }, 22 + { userId: 'bob', userName: 'Bob', role: 'editor', grantedAt: 2000, grantedBy: 'alice' }, 23 + { userId: 'carol', userName: 'Carol', role: 'viewer', grantedAt: 3000, grantedBy: 'alice' }, 24 + ]; 25 + 26 + describe('Permissions', () => { 27 + describe('hasPermission', () => { 28 + it('viewer can view but not edit', () => { 29 + expect(hasPermission('viewer', 'view')).toBe(true); 30 + expect(hasPermission('viewer', 'edit')).toBe(false); 31 + expect(hasPermission('viewer', 'comment')).toBe(false); 32 + }); 33 + 34 + it('editor can view, comment, and edit', () => { 35 + expect(hasPermission('editor', 'view')).toBe(true); 36 + expect(hasPermission('editor', 'comment')).toBe(true); 37 + expect(hasPermission('editor', 'edit')).toBe(true); 38 + expect(hasPermission('editor', 'admin')).toBe(false); 39 + }); 40 + 41 + it('owner has all permissions', () => { 42 + expect(hasPermission('owner', 'view')).toBe(true); 43 + expect(hasPermission('owner', 'comment')).toBe(true); 44 + expect(hasPermission('owner', 'edit')).toBe(true); 45 + expect(hasPermission('owner', 'admin')).toBe(true); 46 + }); 47 + }); 48 + 49 + describe('getPermissions', () => { 50 + it('returns all permissions for a role', () => { 51 + expect(getPermissions('commenter')).toEqual(['view', 'comment']); 52 + }); 53 + }); 54 + 55 + describe('compareRoles / isAtLeast', () => { 56 + it('compares role hierarchy', () => { 57 + expect(compareRoles('owner', 'viewer')).toBeGreaterThan(0); 58 + expect(compareRoles('viewer', 'editor')).toBeLessThan(0); 59 + expect(compareRoles('editor', 'editor')).toBe(0); 60 + }); 61 + 62 + it('checks minimum role level', () => { 63 + expect(isAtLeast('owner', 'editor')).toBe(true); 64 + expect(isAtLeast('viewer', 'editor')).toBe(false); 65 + expect(isAtLeast('editor', 'editor')).toBe(true); 66 + }); 67 + }); 68 + 69 + describe('grantPermission', () => { 70 + it('adds new user permission', () => { 71 + const updated = grantPermission(PERMS, 'dave', 'Dave', 'commenter', 'alice', 5000); 72 + expect(updated).toHaveLength(4); 73 + expect(updated[3].userId).toBe('dave'); 74 + expect(updated[3].role).toBe('commenter'); 75 + }); 76 + 77 + it('updates existing user role', () => { 78 + const updated = grantPermission(PERMS, 'carol', 'Carol', 'editor', 'alice', 5000); 79 + expect(updated).toHaveLength(3); 80 + const carol = updated.find(p => p.userId === 'carol')!; 81 + expect(carol.role).toBe('editor'); 82 + }); 83 + }); 84 + 85 + describe('revokePermission', () => { 86 + it('removes user permission', () => { 87 + const updated = revokePermission(PERMS, 'carol'); 88 + expect(updated).toHaveLength(2); 89 + expect(updated.find(p => p.userId === 'carol')).toBeUndefined(); 90 + }); 91 + 92 + it('cannot revoke owner permission', () => { 93 + const updated = revokePermission(PERMS, 'alice'); 94 + expect(updated).toHaveLength(3); // unchanged 95 + }); 96 + 97 + it('ignores unknown user', () => { 98 + expect(revokePermission(PERMS, 'unknown')).toHaveLength(3); 99 + }); 100 + }); 101 + 102 + describe('getUserRole', () => { 103 + it('returns role for known user', () => { 104 + expect(getUserRole(PERMS, 'bob')).toBe('editor'); 105 + }); 106 + 107 + it('returns null for unknown user', () => { 108 + expect(getUserRole(PERMS, 'unknown')).toBeNull(); 109 + }); 110 + }); 111 + 112 + describe('canPerform', () => { 113 + it('owner can admin', () => { 114 + expect(canPerform(PERMS, 'alice', 'admin')).toBe(true); 115 + }); 116 + 117 + it('editor can edit but not admin', () => { 118 + expect(canPerform(PERMS, 'bob', 'edit')).toBe(true); 119 + expect(canPerform(PERMS, 'bob', 'admin')).toBe(false); 120 + }); 121 + 122 + it('viewer can only view', () => { 123 + expect(canPerform(PERMS, 'carol', 'view')).toBe(true); 124 + expect(canPerform(PERMS, 'carol', 'edit')).toBe(false); 125 + }); 126 + 127 + it('unknown user cannot do anything', () => { 128 + expect(canPerform(PERMS, 'unknown', 'view')).toBe(false); 129 + }); 130 + }); 131 + 132 + describe('canGrantRole', () => { 133 + it('owner can grant any role', () => { 134 + expect(canGrantRole('owner', 'editor')).toBe(true); 135 + expect(canGrantRole('owner', 'owner')).toBe(true); 136 + }); 137 + 138 + it('editor cannot grant owner', () => { 139 + expect(canGrantRole('editor', 'owner')).toBe(false); 140 + expect(canGrantRole('editor', 'editor')).toBe(true); 141 + expect(canGrantRole('editor', 'viewer')).toBe(true); 142 + }); 143 + }); 144 + 145 + describe('share links', () => { 146 + it('creates a valid share link', () => { 147 + const link = createShareLink('doc-1', 'viewer', 'alice', {}, 5000); 148 + expect(link.docId).toBe('doc-1'); 149 + expect(link.role).toBe('viewer'); 150 + expect(link.useCount).toBe(0); 151 + expect(isShareLinkValid(link, 6000)).toBe(true); 152 + }); 153 + 154 + it('detects expired link', () => { 155 + const link = createShareLink('doc-1', 'viewer', 'alice', { expiresAt: 5000 }, 1000); 156 + expect(isShareLinkValid(link, 6000)).toBe(false); 157 + }); 158 + 159 + it('detects max uses reached', () => { 160 + let link = createShareLink('doc-1', 'viewer', 'alice', { maxUses: 2 }, 1000); 161 + link = useShareLink(link); 162 + link = useShareLink(link); 163 + expect(isShareLinkValid(link)).toBe(false); 164 + }); 165 + 166 + it('increments use count', () => { 167 + const link = createShareLink('doc-1', 'viewer', 'alice'); 168 + const used = useShareLink(link); 169 + expect(used.useCount).toBe(1); 170 + }); 171 + }); 172 + 173 + describe('getUsersByRole / countByRole', () => { 174 + it('filters by role', () => { 175 + expect(getUsersByRole(PERMS, 'editor')).toHaveLength(1); 176 + expect(getUsersByRole(PERMS, 'viewer')).toHaveLength(1); 177 + }); 178 + 179 + it('counts by role', () => { 180 + const counts = countByRole(PERMS); 181 + expect(counts.owner).toBe(1); 182 + expect(counts.editor).toBe(1); 183 + expect(counts.viewer).toBe(1); 184 + expect(counts.commenter).toBe(0); 185 + }); 186 + }); 187 + });