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:
sdarbinyan
2026-08-13 07:22:01 +04:00
parent 0646d587eb
commit bac415d003
4 changed files with 64 additions and 2 deletions

View File

@@ -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);
};
}

View 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);
}
}