WIP - permissions
This commit is contained in:
296
docs/AUTHORIZATION_SYSTEM.md
Normal file
296
docs/AUTHORIZATION_SYSTEM.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user