# Polymorphic Record Sharing + Authorization System This document describes the implementation of a comprehensive authorization system using CASL, Objection.js, and NestJS. ## Overview The system supports: - **Global object policies** - Public/private access, default permissions per object type - **Role-based access** - Permissions assigned to roles, with CASL rule storage - **Per-record sharing** - Polymorphic sharing where owners can grant specific users access to individual records - **Field-level permissions** - Fine-grained control over which fields can be read/written ## Architecture ### Database Schema #### `object_definitions` (Enhanced) - `accessModel`: 'public' | 'owner' | 'mixed' - `publicRead/Create/Update/Delete`: Boolean flags for public access - `ownerField`: Field name storing record owner (default: 'ownerId') #### `field_definitions` (Enhanced) - `defaultReadable`: Boolean - Can this field be read by default - `defaultWritable`: Boolean - Can this field be written by default These permission flags are added directly to the existing `field_definitions` table, keeping all field metadata in one place. #### `role_rules` (New) - `roleId`: FK to roles - `rulesJson`: JSON array of CASL rules #### `record_shares` (New) Polymorphic sharing table: - `objectDefinitionId`: FK to object_definitions - `recordId`: String (supports UUID/int) - `granteeUserId`: User receiving access - `grantedByUserId`: User granting access - `actions`: JSON array of actions ["read", "update", etc.] - `fields`: Optional JSON array of field names - `expiresAt/revokedAt`: Optional expiry and revocation timestamps ### Backend Components #### AbilityFactory (`src/auth/ability.factory.ts`) Builds CASL abilities from three layers: 1. **Global rules** - From object_definitions and object_fields 2. **Role rules** - From role_rules.rulesJson 3. **Share rules** - From record_shares for the user ```typescript const ability = await abilityFactory.buildForUser(user, knex); if (ability.can('read', 'Post')) { // User can read posts } ``` #### Query Scoping (`src/auth/query-scope.util.ts`) SQL-level filtering for list queries: ```typescript import { applyReadScope } from '@/auth/query-scope.util'; const query = Post.query(knex); applyReadScope(query, user, objectDefinition, knex); // Query now only returns records user can access ``` Logic: 1. If `publicRead` is true → allow all 2. Else → owner OR valid share exists #### Guards & Decorators - `AbilitiesGuard` - Checks CASL abilities on routes - `@CheckAbility()` - Decorator to require specific permissions - `@CurrentUser()` - Inject current user - `@CurrentAbility()` - Inject CASL ability #### Controllers **ShareController** (`src/rbac/share.controller.ts`) - `POST /shares` - Create a share - `GET /shares/record/:objectDefinitionId/:recordId` - List shares for a record - `GET /shares/granted` - List shares granted by current user - `GET /shares/received` - List shares received by current user - `PATCH /shares/:id` - Update a share - `DELETE /shares/:id` - Revoke a share **RoleController** (`src/rbac/role.controller.ts`) - Standard CRUD for roles - `RoleRuleController` manages CASL rules per role **ObjectAccessController** (`src/object/object-access.controller.ts`) - `GET /setup/objects/:apiName/access` - Get access config - `PUT /setup/objects/:apiName/access` - Update access model - `POST /setup/objects/:apiName/fields/:fieldKey/permissions` - Set field permissions - `PUT /setup/objects/:apiName/field-permissions` - Bulk update field permissions ### Frontend Components #### ObjectAccessSettings (`components/ObjectAccessSettings.vue`) Integrated into object management page as "Access & Permissions" tab: - Configure access model (public/owner/mixed) - Set public CRUD permissions - Configure owner field - Set default read/write permissions per field #### RecordShareDialog (`components/RecordShareDialog.vue`) Dialog for sharing individual records: - List current shares - Add new share with user email - Select read/update permissions - Optional field-level scoping - Optional expiration date - Revoke shares #### Role Management (`pages/setup/roles.vue`) Complete role management interface: - List all roles - Create new roles - Delete roles - Edit role permissions #### RolePermissionsEditor (`components/RolePermissionsEditor.vue`) Granular permission editor: - Configure CRUD permissions per object type - Apply conditions (e.g., "ownerId = $userId") - Field-level restrictions (future) ## Usage Examples ### 1. Set Object to Owner-Only Access ```typescript await api.put('/setup/objects/Post/access', { accessModel: 'owner', publicRead: false, ownerField: 'ownerId' }); ``` ### 2. Share a Record ```typescript await api.post('/shares', { objectDefinitionId: 'abc-123', recordId: 'post-456', granteeUserId: 'user-789', actions: ['read', 'update'], fields: ['title', 'body'], // Optional field scoping expiresAt: '2025-12-31T23:59:59Z' // Optional expiry }); ``` ### 3. Create Role with Permissions ```typescript // Create role const role = await api.post('/roles', { name: 'Account Manager', description: 'Can manage accounts' }); // Set permissions await api.post('/role-rules', { roleId: role.id, rulesJson: [ { action: ['read', 'update'], subject: 'Account', conditions: { ownerId: '$userId' } // Only own accounts }, { action: ['read'], subject: 'Contact' // Can read all contacts } ] }); ``` ### 4. Query with Authorization ```typescript // In a controller async getRecords(user: User) { const objectDef = await ObjectDefinition.query(this.knex) .findOne({ apiName: 'Post' }); const query = Post.query(this.knex); applyReadScope(query, user, objectDef, this.knex); return query; // Only returns records user can read } ``` ### 5. Check Instance Permission ```typescript // With CASL const post = await Post.query().findById(id); if (ability.can('update', subject(post, 'Post'))) { // User can update this post } ``` ## Migration Guide 1. **Run Migration** ```bash npm run migrate:latest ``` 2. **Update Existing Objects** Set default access model for existing object types: ```sql UPDATE object_definitions SET access_model = 'owner', owner_field = 'ownerId' WHERE access_model IS NULL; ``` 3. **Update Controllers** Add query scoping to list endpoints: ```typescript import { applyReadScope } from '@/auth/query-scope.util'; // Before const records = await MyModel.query(); // After const records = await applyReadScope( MyModel.query(), user, objectDef, knex ); ``` 4. **Add Guards** Protect routes with ability checks: ```typescript @UseGuards(JwtAuthGuard, AbilitiesGuard) @CheckAbility({ action: 'read', subject: 'Post' }) async findAll() { // ... } ``` ## Security Considerations 1. **Always use SQL scoping for lists** - Don't rely on post-fetch filtering 2. **Validate share ownership** - Only grantor can update/revoke shares 3. **Check expiry and revocation** - Filter out invalid shares in queries 4. **Field-level filtering** - Strip unauthorized fields from request bodies 5. **Tenant isolation** - All queries should be scoped to current tenant (if multi-tenant) ## Testing ### Unit Tests Test ability building: ```typescript it('should allow owner to read their records', async () => { const ability = await abilityFactory.buildForUser(user, knex); const post = { id: '123', ownerId: user.id }; expect(ability.can('read', subject(post, 'Post'))).toBe(true); }); ``` ### Integration Tests Test query scoping: ```typescript it('should only return owned records', async () => { const query = Post.query(knex); applyReadScope(query, user, objectDef, knex); const records = await query; expect(records.every(r => r.ownerId === user.id)).toBe(true); }); ``` ## Future Enhancements - [ ] Group/team sharing (share with multiple users) - [ ] Public link sharing (token-based) - [ ] Audit log for shares - [ ] Share templates - [ ] Cascading shares (share related records) - [ ] Time-limited shares with auto-expiry - [ ] Share approval workflow - [ ] Delegation (share on behalf of another user) ## API Reference See individual controller files for detailed API documentation: - [ShareController](./backend/src/rbac/share.controller.ts) - [RoleController](./backend/src/rbac/role.controller.ts) - [ObjectAccessController](./backend/src/object/object-access.controller.ts)