297 lines
8.4 KiB
Markdown
297 lines
8.4 KiB
Markdown
# 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)
|