Related: Frontend Architecture · Frontend State · Auth Tokens
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.
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.)
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_APIandDEV_WEBHOOKare not enforced by a route guard — thespaces/:spaceId/developersroute (which hosts both Open API and Webhooks) has nocanActivateinfeatures-routing.module.ts. They only control sidebar link visibility, checked client-side via theDEV_OPEN_API/DEV_WEBHOOKpermissionentries on the Developers sidebar items insrc/app/features/features.component.ts. A user who guesses the URL can still reach/features/spaces/:spaceId/developers/...without holding either permission.
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)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.
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)- Admin invites a user → Firebase Auth account created with initial
role+permissionsvia Admin SDK - User logs in → ID token issued with claims baked in
- Admin changes user's role/permissions → claims updated via Admin SDK
- Claims only refresh on next ID token refresh (every ~1 hour, or on next sign-in)
UserStorereads claims fromgetIdTokenResult()on every app init
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.createis admin-only; profiles are created by the Auth trigger anduser.sync.user.invitecallable:canGrant()infunctions/src/utils/user-grant.tsenforces the same limits, because the callable writes custom claims directly.- UI:
features/admin/users/user-management.tsmirrors 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.
src/app/shared/models/user.model.ts—User,UserRole,UserPermissiontypessrc/app/shared/stores/user.store.ts— reads claims, exposesisRoleAdmin,isLockedsrc/app/features/admin/users/user-management.ts— who may manage which user (mirrorsfirestore.rules)functions/src/utils/user-grant.ts—canGrant()for theuser.invitecallablesrc/app/shared/pipes/can-user-perform.pipe.ts—canUserPerformtemplate pipesrc/app/features/features-routing.module.ts— all route guards with permission pipessrc/app/app-routing.ts— rootauthGuard(authentication only, not authorization)src/app/shared/services/user.service.ts— Firestore user CRUD (admin operations)