Skip to content

Entity Groups: Grouped Permission UX

Problem

The permission model assigns access at the (role, scope) level — one role_permissions row per entity-scope pair. As entities grow (departments, grades, periods, curriculum, sections...), assigning permissions entity-by-entity becomes redundant. In practice, entities cluster into domain areas where access levels almost always match:

  • If a user can write departments, they almost certainly can write grades and periods too
  • If a user can read student anagraphic, they likely can read teacher and staff anagraphic too

The admin configuring roles shouldn't think entity-by-entity — they should think in domain groups.

Design: View-Layer Groups (No Schema Change)

Entity groups are a frontend/API convenience, not a database concept. The DB stays flat (individual role_permissions rows). Guards are unchanged. Groups exist as:

  1. Backend constants — a map of group → entity[]
  2. A grouped API response — wraps the flat permissions into group structure
  3. Frontend UI — group-based toggles that expand to individual entity-scope assignments

Why Not DB-Level Groups?

Concern View-layer groups DB-level groups
Schema migration None New table + FK
Fine-grained overrides Natural (flat rows) Awkward (group vs entity conflict)
Guard complexity Zero change Must resolve group → entities
Future flexibility Can upgrade to DB later Harder to downgrade
Scale (~15 entities) Sufficient Over-engineered

Entity Group Definitions

Group ID Label Entities Notes
people People students, teachers, staff Core anagraphic CRUD
academic-structure Academic Structure departments, grades, periods, academic_years Populated as these get permission entities
platform Platform users User management + platform config
teaching-schedule Teaching & Schedule curriculum, timetable, sections Future — not yet implemented

Groups mirror the setup wizard groups (src/setup/constants/setup-groups.ts) but are permission-specific.

Backend Implementation (Future Epic)

Constants

src/common/constants/entity-groups.ts:

export enum EntityGroupId {
  PEOPLE = 'people',
  ACADEMIC_STRUCTURE = 'academic-structure',
  PLATFORM = 'platform',
}

export interface EntityGroupDefinition {
  id: EntityGroupId;
  label: string;
  description: string;
  entities: EntityKeyValue[];
}

export const ENTITY_GROUPS: Record<EntityGroupId, EntityGroupDefinition> = { ... };

Plus a reverse lookup: getEntityGroup(entityKey): EntityGroupId | null.

Grouped Permissions Endpoint

GET /api/v1/permissions/grouped returns:

{
  "groups": [
    {
      "id": "people",
      "label": "People",
      "entities": {
        "students": {
          "scopes": { "anagraphic": "WRITE", "sensitive": "READ" },
          "actions": { "create": true, "delete": false }
        },
        "teachers": {
          "scopes": { "anagraphic": "WRITE" },
          "actions": { "create": true, "delete": true }
        },
        "staff": {
          "scopes": { "anagraphic": "WRITE" },
          "actions": { "create": true, "delete": true }
        }
      }
    },
    {
      "id": "platform",
      "label": "Platform",
      "entities": {
        "users": {
          "scopes": { "profile": "READ" },
          "actions": {}
        }
      }
    }
  ],
  "ungrouped": {}
}

The existing GET /api/v1/permissions stays unchanged (backward compatible).

Frontend Usage

Admin Role Editor

The role permission editor should present a two-level UI:

Level 1: Group Cards

┌─────────────────────────────────────┐
│ People                          [▼] │
│ ○ None  ○ Read  ● Write             │
│                                     │
│ Students: WRITE ✓                   │
│ Teachers: WRITE ✓                   │
│ Staff:    WRITE ✓                   │
└─────────────────────────────────────┘

┌─────────────────────────────────────┐
│ Academic Structure              [▼] │
│ ○ None  ● Read  ○ Write             │
│                                     │
│ Departments: READ ✓                 │
│ Grades:      READ ✓                 │
│ Periods:     READ ✓                 │
└─────────────────────────────────────┘

Behavior

  1. Group-level toggle (None/Read/Write): sets ALL entities in the group to that access level. This is a convenience — it writes individual role_permissions rows per entity-scope.

  2. Expand (▼): shows per-entity overrides. A user can set the group to WRITE then downgrade one entity to READ. The group-level indicator then shows "Mixed" or the lowest common access.

  3. Group access derivation: the group-level badge is computed from entity access:

  4. All entities same access → show that access
  5. Mixed → show "Mixed" with lowest access highlighted
  6. No access on any → show "None"

  7. Scope-level detail (optional expand within entity): for entities with multiple scopes (e.g., students has anagraphic + sensitive), show per-scope toggles. Most entities will have a single scope, so this level is rarely needed.

Actions Section

Below scopes, each entity's actions appear as checkboxes:

│ Students: WRITE                     │
│   ☑ Create  ☑ Delete               │
│ Teachers: WRITE                     │
│   ☑ Create  ☑ Delete               │

Actions are independent of groups — they're always per-entity. The group toggle only affects scope access.

Permission Display (Non-Admin View)

For non-admin users viewing their own permissions (GET /permissions/grouped), the frontend shows a read-only grouped view:

People
  Students: Can view basic info and sensitive data
  Teachers: Can view basic info
  Staff:    Can view basic info

Platform
  Users: No access

API Calls for Role Management (Future)

When the admin saves a role with group-level access:

POST /api/v1/roles/:id/permissions
{
  "groups": {
    "people": "WRITE",           // expands to all people entity scopes
    "academic-structure": "READ"  // expands to all academic structure entity scopes
  },
  "overrides": {
    "students.sensitive": "NONE"  // entity-scope override within a group
  }
}

The backend: 1. Iterates ENTITY_GROUPS[groupId].entities 2. For each entity, fetches all permission_scopes 3. Creates/updates role_permissions rows with the group access level 4. Applies any per-entity-scope overrides on top

Migration Path

If entity groups ever need to be DB-level (e.g., tenant-customizable groups):

  1. Add permission_entity_groups table with id, key, label, sortOrder
  2. Add groupId FK on permission_entities
  3. Seed from the constant definitions
  4. The constants become the seed source, guards still check per entity-scope
  5. The API can then serve tenant-specific group configurations

This is not needed now — constants suffice for system-defined groups.