Skip to content

Latest commit

 

History

History
173 lines (126 loc) · 7.65 KB

frontend-permissions.md

File metadata and controls

173 lines (126 loc) · 7.65 KB

User Roles & Permissions

Related: Frontend Architecture · Frontend State · Auth Tokens

Two Permission Systems

Localess has two separate permission systems that should not be confused:

System Used by Stored in Enforced by
User permissions CMS users (editors, admins) Firebase custom claims Route guards + UI
API token permissions Programmatic API consumers Firestore tokens collection CDN middleware

This document covers user permissions. See Auth Tokens for API token permissions.


User Roles

Two roles exist, set via Firebase custom claim role:

Role Access
admin Full access to everything — bypasses all permission checks
custom Granular access — only what's listed in the permissions claim

A lock: true claim does not block login. Its only consumer is UserStore.isLocked, which me.component.html uses to hide the Update Profile / Email / Password actions on the user's own profile page. It is UI-only — nothing server-side enforces it. (Blocking sign-in is the separate Firebase Auth disabled flag.)


User Permissions (granular, custom role only)

enum UserPermission {
  // Administration
  USER_MANAGEMENT        // Manage users (invite, edit, delete)
  SPACE_MANAGEMENT       // Create/edit/delete spaces and their settings
  SETTINGS_MANAGEMENT    // Edit global app settings

  // Translations
  TRANSLATION_READ       // View translations
  TRANSLATION_CREATE     // Add new translation keys
  TRANSLATION_UPDATE     // Edit translation values
  TRANSLATION_DELETE     // Delete translation keys
  TRANSLATION_PUBLISH    // Publish translations to CDN
  TRANSLATION_EXPORT     // Export translations as file
  TRANSLATION_IMPORT     // Import translations from file

  // Schemas
  SCHEMA_READ            // View schemas
  SCHEMA_CREATE          // Create schema types/fields
  SCHEMA_UPDATE          // Edit schema types/fields
  SCHEMA_DELETE          // Delete schemas
  SCHEMA_EXPORT          // Export schema definitions
  SCHEMA_IMPORT          // Import schema definitions

  // Content
  CONTENT_READ           // View content documents
  CONTENT_CREATE         // Create content documents
  CONTENT_UPDATE         // Edit content documents
  CONTENT_DELETE         // Delete content documents
  CONTENT_PUBLISH        // Publish content to CDN
  CONTENT_EXPORT         // Export content
  CONTENT_IMPORT         // Import content

  // Assets
  ASSET_READ             // View assets
  ASSET_CREATE           // Upload assets
  ASSET_UPDATE           // Edit asset metadata
  ASSET_DELETE           // Delete assets
  ASSET_EXPORT           // Export assets
  ASSET_IMPORT           // Import assets

  // Dev
  DEV_OPEN_API           // Access the Developers → Open API section
  DEV_WEBHOOK            // Access the Developers → Webhooks section
}

Note: Unlike every other permission above, DEV_OPEN_API and DEV_WEBHOOK are not enforced by a route guard — the spaces/:spaceId/developers route (which hosts both Open API and Webhooks) has no canActivate in features-routing.module.ts. They only control sidebar link visibility, checked client-side via the DEV_OPEN_API / DEV_WEBHOOK permission entries on the Developers sidebar items in src/app/features/features.component.ts. A user who guesses the URL can still reach /features/spaces/:spaceId/developers/... without holding either permission.


How Guards Work

Route-level guards use AngularFire's AuthGuard + custom authGuardPipe functions defined in features-routing.module.ts:

// a child of the `spaces/:spaceId` parent route (see frontend-state.md → SpaceStore)
{
  path: 'translations',
  canActivate: [AuthGuard],
  data: {
    authGuardPipe: hasPermissionTranslationRead  // reads Firebase customClaims
  }
}

Each pipe reads customClaims from the Firebase ID token:

// admin → always true
// custom → check permissions array includes the required permission
claims['role'] === 'admin' || claims['permissions']?.includes(UserPermission.TRANSLATION_READ)

How UI Conditionally Renders

canUserPerform pipe (templates)

The main template gating mechanism is CanUserPerformPipe (src/app/shared/pipes/can-user-perform.pipe.ts). It takes a single permission or an array (any-of match) and returns an Observable<boolean>, so pair it with async:

@if ('SPACE_MANAGEMENT' | canUserPerform | async) { ... }
@if (item.permission | canUserPerform | async) { ... }   <!-- string | string[] | undefined -->

admin → always true; custom → permissions includes the value (or any of the array); undefined permission → true; no role → false.

UserStore signals (component code)

In component classes, use UserStore signals:

readonly userStore = inject(UserStore);

// Check role
this.userStore.isRoleAdmin()          // boolean signal

// Check specific permission
this.userStore.permissions()?.includes(UserPermission.CONTENT_PUBLISH)

Claims Lifecycle

  1. Admin invites a user → Firebase Auth account created with initial role + permissions via Admin SDK
  2. User logs in → ID token issued with claims baked in
  3. Admin changes user's role/permissions → claims updated via Admin SDK
  4. Claims only refresh on next ID token refresh (every ~1 hour, or on next sign-in)
  5. UserStore reads claims from getIdTokenResult() on every app init

Who Can Manage Users

Writing a users/{userId} document grants access: the user.onUpdate trigger copies its role, permissions and lock into custom claims. So USER_MANAGEMENT alone is not enough to manage every user.

Caller May manage
admin Any user, including admins and themselves. May grant any role and permission.
custom with USER_MANAGEMENT Only users they fully outrank: not themselves, not an admin, and not anyone holding a permission they lack. They may set the role only to custom or none, grant only permissions they hold, and change only role, permissions and lock.

Two managers holding the same permissions can manage each other. A manager can never make anyone, including themselves, an admin. Only admins can.

These limits are enforced in three places:

  • firestore.rules (users/{userId}): enforces update and delete. create is admin-only; profiles are created by the Auth trigger and user.sync.
  • user.invite callable: canGrant() in functions/src/utils/user-grant.ts enforces the same limits, because the callable writes custom claims directly.
  • UI: features/admin/users/user-management.ts mirrors the rule. The users list disables actions on users the caller can't manage. The edit and invite dialogs hide the Admin role and disable permissions the caller can't grant.

Implementation Files

  • src/app/shared/models/user.model.ts — User, UserRole, UserPermission types
  • src/app/shared/stores/user.store.ts — reads claims, exposes isRoleAdmin, isLocked
  • src/app/features/admin/users/user-management.ts — who may manage which user (mirrors firestore.rules)
  • functions/src/utils/user-grant.ts — canGrant() for the user.invite callable
  • src/app/shared/pipes/can-user-perform.pipe.ts — canUserPerform template pipe
  • src/app/features/features-routing.module.ts — all route guards with permission pipes
  • src/app/app-routing.ts — root authGuard (authentication only, not authorization)
  • src/app/shared/services/user.service.ts — Firestore user CRUD (admin operations)