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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { languageGuard } from './guards/language.guard';
|
import { languageGuard } from './guards/language.guard';
|
||||||
import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.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 { authRoutes } from './core/auth/auth.routes';
|
||||||
import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard';
|
import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard';
|
||||||
import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard';
|
import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard';
|
||||||
@@ -229,6 +229,7 @@ const coreRoutes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'users',
|
path: 'users',
|
||||||
|
canActivate: [requireAdminPermission('users.manage')],
|
||||||
loadComponent: () => import('./features/admin/users/pages/admin-users-page.component').then(m => m.AdminUsersPageComponent),
|
loadComponent: () => import('./features/admin/users/pages/admin-users-page.component').then(m => m.AdminUsersPageComponent),
|
||||||
data: {
|
data: {
|
||||||
titleKey: 'adminShell.pages.users.title',
|
titleKey: 'adminShell.pages.users.title',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { CanActivateFn } from '@angular/router';
|
import { CanActivateFn } from '@angular/router';
|
||||||
import { AdminAuthService } from './admin-auth.service';
|
import { AdminAuthService } from './admin-auth.service';
|
||||||
|
import { AdminPermissionsService } from './admin-permissions.service';
|
||||||
|
|
||||||
/** Guards `/admin/**` routes. Never shares state with the customer auth guard/service. */
|
/** Guards `/admin/**` routes. Never shares state with the customer auth guard/service. */
|
||||||
export const adminAuthGuard: CanActivateFn = () => {
|
export const adminAuthGuard: CanActivateFn = () => {
|
||||||
@@ -13,3 +14,22 @@ export const adminAuthGuard: CanActivateFn = () => {
|
|||||||
adminAuth.requestLogin();
|
adminAuth.requestLogin();
|
||||||
return false;
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
41
src/app/core/admin-auth/admin-permissions.service.ts
Normal file
41
src/app/core/admin-auth/admin-permissions.service.ts
Normal file
@@ -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<readonly string[]>(() => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service
|
|||||||
|
|
||||||
const BUILT_IN_ROLES: AdminRole[] = [
|
const BUILT_IN_ROLES: AdminRole[] = [
|
||||||
{ id: 'owner', name: 'Owner', permissions: ['*'], builtIn: true },
|
{ 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: 'editor', name: 'Editor', permissions: ['products.manage', 'categories.manage', 'media.manage'], builtIn: true },
|
||||||
{ id: 'viewer', name: 'Viewer', permissions: ['products.view', 'orders.view'], builtIn: true },
|
{ id: 'viewer', name: 'Viewer', permissions: ['products.view', 'orders.view'], builtIn: true },
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user