Files
neo/docs/AUTHORIZATION_SYSTEM.md
Francisco Gaona 88f656c3f5 WIP - permissions
2025-12-28 05:43:03 +01:00

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 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
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:

  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

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

  1. Run Migration

    npm run migrate:latest
    
  2. 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;
    
  3. 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
    );
    
  4. Add Guards Protect routes with ability checks:

    @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:

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: