From bac415d003bfde4210db161d7f3b9a7beb7d03ba Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Thu, 13 Aug 2026 07:22:01 +0400 Subject: [PATCH] feat: UI-only permission gate for admin routes (cosmetic pending backend) adminAuthGuard only checked isAuthenticated() - any signed-in admin could reach any route. The live Telegram/QR auth (Mechanism A) carries no role claim, so a real gate needs a backend change (tracked in BACKEND-API-REFERENCE.md). Added AdminPermissionsService + requireAdminPermission() guard factory that derive a permission set locally by matching the Telegram username against the mock Users domain's roleId - the same local-only stand-in already used for the rest of that domain. Wired onto /backoffice/users requiring 'users.manage'. Explicitly cosmetic: backend must independently authorize every mutation regardless of what this guard decides. Co-Authored-By: Claude Sonnet 5 --- src/app/app.routes.ts | 3 +- src/app/core/admin-auth/admin-auth.guard.ts | 20 +++++++++ .../admin-auth/admin-permissions.service.ts | 41 +++++++++++++++++++ .../services/admin-users-local.gateway.ts | 2 +- 4 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 src/app/core/admin-auth/admin-permissions.service.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 1d5ba92..cc229b7 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,7 +1,7 @@ import { Routes } from '@angular/router'; import { languageGuard } from './guards/language.guard'; import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard'; -import { adminAuthGuard } from './core/admin-auth/admin-auth.guard'; +import { adminAuthGuard, requireAdminPermission } from './core/admin-auth/admin-auth.guard'; import { authRoutes } from './core/auth/auth.routes'; import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard'; import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard'; @@ -229,6 +229,7 @@ const coreRoutes: Routes = [ }, { path: 'users', + canActivate: [requireAdminPermission('users.manage')], loadComponent: () => import('./features/admin/users/pages/admin-users-page.component').then(m => m.AdminUsersPageComponent), data: { titleKey: 'adminShell.pages.users.title', diff --git a/src/app/core/admin-auth/admin-auth.guard.ts b/src/app/core/admin-auth/admin-auth.guard.ts index d4ec66a..e7a8185 100644 --- a/src/app/core/admin-auth/admin-auth.guard.ts +++ b/src/app/core/admin-auth/admin-auth.guard.ts @@ -1,6 +1,7 @@ import { inject } from '@angular/core'; import { CanActivateFn } from '@angular/router'; import { AdminAuthService } from './admin-auth.service'; +import { AdminPermissionsService } from './admin-permissions.service'; /** Guards `/admin/**` routes. Never shares state with the customer auth guard/service. */ export const adminAuthGuard: CanActivateFn = () => { @@ -13,3 +14,22 @@ export const adminAuthGuard: CanActivateFn = () => { adminAuth.requestLogin(); return false; }; + +/** + * UI-only gate for a specific permission, on top of adminAuthGuard's + * authentication check. See AdminPermissionsService for why this is + * cosmetic until the backend ships real admin-role enforcement. + */ +export function requireAdminPermission(permission: string): CanActivateFn { + return () => { + const adminAuth = inject(AdminAuthService); + const permissions = inject(AdminPermissionsService); + + if (!adminAuth.isAuthenticated()) { + adminAuth.requestLogin(); + return false; + } + + return permissions.has(permission); + }; +} diff --git a/src/app/core/admin-auth/admin-permissions.service.ts b/src/app/core/admin-auth/admin-permissions.service.ts new file mode 100644 index 0000000..975916a --- /dev/null +++ b/src/app/core/admin-auth/admin-permissions.service.ts @@ -0,0 +1,41 @@ +import { Injectable, computed, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { AdminAuthService } from './admin-auth.service'; +import { AdminUsersLocalGateway } from '../../features/admin/users/services/admin-users-local.gateway'; + +/** + * UI-only permission gate for the live Telegram/QR admin auth (Mechanism A), + * which carries no role claim of its own (see admin-auth.service.ts). This + * derives a permission set by matching the signed-in Telegram username + * against the mock Users domain's roleId - the same local-only stand-in the + * rest of the Users admin domain already uses (see BACKEND-API-REFERENCE.md + * ยง8 "Users - MOCK-ONLY, no seam"). It is cosmetic until a real backend + * ships either an admin-role claim on the session, or Mechanism B + * (Ed25519 JWT + PermissionService) goes live. + */ +@Injectable({ providedIn: 'root' }) +export class AdminPermissionsService { + private readonly adminAuth = inject(AdminAuthService); + private readonly usersGateway = inject(AdminUsersLocalGateway); + + private readonly users = toSignal(this.usersGateway.loadUsers(), { initialValue: [] }); + private readonly roles = toSignal(this.usersGateway.loadRoles(), { initialValue: [] }); + + readonly permissions = computed(() => { + const session = this.adminAuth.session(); + if (!session) { + return []; + } + const username = session.username?.replace(/^@/, ''); + const matchedUser = this.users().find(user => user.telegramUsername.replace(/^@/, '') === username); + if (!matchedUser) { + return []; + } + return this.roles().find(role => role.id === matchedUser.roleId)?.permissions ?? []; + }); + + has(permission: string): boolean { + const permissions = this.permissions(); + return permissions.includes('*') || permissions.includes(permission); + } +} diff --git a/src/app/features/admin/users/services/admin-users-local.gateway.ts b/src/app/features/admin/users/services/admin-users-local.gateway.ts index 817296b..dcb8b52 100644 --- a/src/app/features/admin/users/services/admin-users-local.gateway.ts +++ b/src/app/features/admin/users/services/admin-users-local.gateway.ts @@ -7,7 +7,7 @@ import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service const BUILT_IN_ROLES: AdminRole[] = [ { id: 'owner', name: 'Owner', permissions: ['*'], builtIn: true }, - { id: 'admin', name: 'Admin', permissions: ['products.manage', 'categories.manage', 'orders.manage', 'media.manage'], builtIn: true }, + { id: 'admin', name: 'Admin', permissions: ['products.manage', 'categories.manage', 'orders.manage', 'media.manage', 'users.manage'], builtIn: true }, { id: 'editor', name: 'Editor', permissions: ['products.manage', 'categories.manage', 'media.manage'], builtIn: true }, { id: 'viewer', name: 'Viewer', permissions: ['products.view', 'orders.view'], builtIn: true }, ];