8.4 KiB
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 accessownerField: Field name storing record owner (default: 'ownerId')
field_definitions (Enhanced)
defaultReadable: Boolean - Can this field be read by defaultdefaultWritable: 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 rolesrulesJson: JSON array of CASL rules
record_shares (New)
Polymorphic sharing table:
objectDefinitionId: FK to object_definitionsrecordId: String (supports UUID/int)granteeUserId: User receiving accessgrantedByUserId: User granting accessactions: JSON array of actions ["read", "update", etc.]fields: Optional JSON array of field namesexpiresAt/revokedAt: Optional expiry and revocation timestamps
Backend Components
AbilityFactory (src/auth/ability.factory.ts)
Builds CASL abilities from three layers:
- Global rules - From object_definitions and object_fields
- Role rules - From role_rules.rulesJson
- Share rules - From record_shares for the user
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:
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:
- If
publicReadis true → allow all - 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 shareGET /shares/record/:objectDefinitionId/:recordId- List shares for a recordGET /shares/granted- List shares granted by current userGET /shares/received- List shares received by current userPATCH /shares/:id- Update a shareDELETE /shares/:id- Revoke a share
RoleController (src/rbac/role.controller.ts)
- Standard CRUD for roles
RoleRuleControllermanages CASL rules per role
ObjectAccessController (src/object/object-access.controller.ts)
GET /setup/objects/:apiName/access- Get access configPUT /setup/objects/:apiName/access- Update access modelPOST /setup/objects/:apiName/fields/:fieldKey/permissions- Set field permissionsPUT /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
await api.put('/setup/objects/Post/access', {
accessModel: 'owner',
publicRead: false,
ownerField: 'ownerId'
});
2. Share a Record
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
// 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
// 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
// With CASL
const post = await Post.query().findById(id);
if (ability.can('update', subject(post, 'Post'))) {
// User can update this post
}
Migration Guide
-
Run Migration
npm run migrate:latest -
Update Existing Objects Set default access model for existing object types:
UPDATE object_definitions SET access_model = 'owner', owner_field = 'ownerId' WHERE access_model IS NULL; -
Update Controllers Add query scoping to list endpoints:
import { applyReadScope } from '@/auth/query-scope.util'; // Before const records = await MyModel.query(); // After const records = await applyReadScope( MyModel.query(), user, objectDef, knex ); -
Add Guards Protect routes with ability checks:
@UseGuards(JwtAuthGuard, AbilitiesGuard) @CheckAbility({ action: 'read', subject: 'Post' }) async findAll() { // ... }
Security Considerations
- Always use SQL scoping for lists - Don't rely on post-fetch filtering
- Validate share ownership - Only grantor can update/revoke shares
- Check expiry and revocation - Filter out invalid shares in queries
- Field-level filtering - Strip unauthorized fields from request bodies
- Tenant isolation - All queries should be scoped to current tenant (if multi-tenant)
Testing
Unit Tests
Test ability building:
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:
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: