feat: Track S frontend - permission core + Audit & Security section
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
core/permissions (SessionPermissions/AuditEvent models, gateway/token, requiresScope() CanActivateFn) against docs/backend/ TRACK-S-SECURITY-RBAC-CONTRACT.md §1-3. PermissionLocalGateway grants PLATFORM_OWNER/'*' unconditionally - this matches TODAY'S REAL behavior (GAPS-AND-IMPROVEMENTS.md: admin role model is decorative, every authenticated admin has full access) rather than faking enforcement that doesn't exist. requiresScope() is correspondingly a no-op against the mock, by design - it must not create a false sense of security before a real backend exists. New features/admin/audit (Audit & Security nav section, missing from admin nav today) - facade + page, empty state until real audit events exist. Scope: deliberately NOT retrofitting requiresScope() onto the 14 existing live admin routes in this pass - a blanket guard rollout risks locking an admin out without warning and needs its own verified pass, not a bundled change alongside nine other phases. This is the single most serious security gap this session's audit found; closing it for real is Track S's own dedicated follow-up once a real backend exists to enforce against. This closes out the full "do all phases" push: 10 phases + 2 tracks, each with a real mock-gateway-backed swappable seam, several with genuinely new backoffice UI. Every core/* module here binds via the same DI-token pattern established for the 9 admin domains at the start of this session - a real backend is a token swap per module, not a rewrite. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -227,6 +227,15 @@ const coreRoutes: Routes = [
|
|||||||
breadcrumb: [{ labelKey: 'adminShell.nav.marketplaces' }]
|
breadcrumb: [{ labelKey: 'adminShell.nav.marketplaces' }]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'audit',
|
||||||
|
loadComponent: () => import('./features/admin/audit/pages/admin-audit-page.component').then(m => m.AdminAuditPageComponent),
|
||||||
|
data: {
|
||||||
|
titleKey: 'adminShell.nav.audit',
|
||||||
|
descriptionKey: 'adminShell.nav.audit',
|
||||||
|
breadcrumb: [{ labelKey: 'adminShell.nav.audit' }]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'moderation',
|
path: 'moderation',
|
||||||
loadComponent: () => import('./features/admin/moderation/pages/admin-reviews-list-page.component').then(m => m.AdminReviewsListPageComponent),
|
loadComponent: () => import('./features/admin/moderation/pages/admin-reviews-list-page.component').then(m => m.AdminReviewsListPageComponent),
|
||||||
|
|||||||
20
src/app/core/permissions/guards/permission.guard.ts
Normal file
20
src/app/core/permissions/guards/permission.guard.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { CanActivateFn } from '@angular/router';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
import { PERMISSION_GATEWAY } from '../services/permission-gateway.token';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route guard shape for docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §2.
|
||||||
|
* Against PermissionLocalGateway (grants everything - see that file's doc
|
||||||
|
* comment) this is currently a no-op, by design: it must not create a false
|
||||||
|
* sense of enforcement before a real backend exists. Attach via route data
|
||||||
|
* (`data: { requiredScope: '...' }`) once real enforcement is needed;
|
||||||
|
* wiring this onto the 14 existing live admin routes is deliberately not
|
||||||
|
* done in this pass - that needs its own verified rollout, not a blanket
|
||||||
|
* retrofit that could lock an admin out without warning.
|
||||||
|
*/
|
||||||
|
export function requiresScope(scope: string): CanActivateFn {
|
||||||
|
return () => inject(PERMISSION_GATEWAY).getSessionPermissions().pipe(
|
||||||
|
map(permissions => permissions.scopes.includes('*') || permissions.scopes.includes(scope))
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/app/core/permissions/models/permission.model.ts
Normal file
19
src/app/core/permissions/models/permission.model.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/** Per docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §1-2. */
|
||||||
|
export type PlatformRole = 'PLATFORM_OWNER' | 'TECH_ADMIN' | 'SECURITY_ADMIN' | 'DOMAIN_MANAGER' | 'VIEWER';
|
||||||
|
export type MarketplaceRole = 'MARKETPLACE_ADMIN' | 'CONTENT_MANAGER' | 'CATALOG_MANAGER' | 'ORDER_MANAGER' | 'FINANCE_MANAGER' | 'SUPPORT_MANAGER' | 'VIEWER';
|
||||||
|
|
||||||
|
export interface SessionPermissions {
|
||||||
|
role: PlatformRole | MarketplaceRole;
|
||||||
|
scopes: string[];
|
||||||
|
marketplaceIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditEvent {
|
||||||
|
id: string;
|
||||||
|
actor: string;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
reason?: string;
|
||||||
|
occurredAt: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AuditEvent, SessionPermissions } from '../models/permission.model';
|
||||||
|
|
||||||
|
/** Per docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §2-3. */
|
||||||
|
export interface PermissionGateway {
|
||||||
|
getSessionPermissions(): Observable<SessionPermissions>;
|
||||||
|
loadAuditLog(): Observable<AuditEvent[]>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { InjectionToken, inject } from '@angular/core';
|
||||||
|
import { PermissionGateway } from './permission-gateway.interface';
|
||||||
|
import { PermissionLocalGateway } from './permission-local.gateway';
|
||||||
|
|
||||||
|
/** Swap point for docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §2. */
|
||||||
|
export const PERMISSION_GATEWAY = new InjectionToken<PermissionGateway>('PERMISSION_GATEWAY', {
|
||||||
|
providedIn: 'root',
|
||||||
|
factory: () => inject(PermissionLocalGateway),
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { AuditEvent, SessionPermissions } from '../models/permission.model';
|
||||||
|
import { PermissionGateway } from './permission-gateway.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grants full PLATFORM_OWNER access unconditionally. This matches today's
|
||||||
|
* REAL behaviour (GAPS-AND-IMPROVEMENTS.md: "the admin role model is
|
||||||
|
* decorative - anyone who passes admin authentication has full access
|
||||||
|
* regardless of assigned role") rather than pretending enforcement exists
|
||||||
|
* when it doesn't. Swapping PERMISSION_GATEWAY for a real backend per
|
||||||
|
* docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md is what actually turns
|
||||||
|
* enforcement on - PermissionGuard below is inert against this mock by
|
||||||
|
* design, not a false sense of security.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class PermissionLocalGateway implements PermissionGateway {
|
||||||
|
getSessionPermissions(): Observable<SessionPermissions> {
|
||||||
|
return of({ role: 'PLATFORM_OWNER', scopes: ['*'], marketplaceIds: ['*'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAuditLog(): Observable<AuditEvent[]> {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/app/features/admin/audit/facade/admin-audit.facade.ts
Normal file
20
src/app/features/admin/audit/facade/admin-audit.facade.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { take } from 'rxjs/operators';
|
||||||
|
import { AuditEvent } from '../../../../core/permissions/models/permission.model';
|
||||||
|
import { PERMISSION_GATEWAY } from '../../../../core/permissions/services/permission-gateway.token';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AdminAuditFacade {
|
||||||
|
private readonly gateway = inject(PERMISSION_GATEWAY);
|
||||||
|
|
||||||
|
readonly events = signal<AuditEvent[]>([]);
|
||||||
|
readonly loading = signal(false);
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.gateway.loadAuditLog().pipe(take(1)).subscribe(items => {
|
||||||
|
this.events.set(items);
|
||||||
|
this.loading.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<div class="audit-page">
|
||||||
|
<header>
|
||||||
|
<h1>Audit & Security</h1>
|
||||||
|
<p>Role changes, sensitive actions, login/security events, exports. Real role model does not exist yet - see docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md. Today every authenticated admin has full access regardless of role.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (facade.events().length === 0 && !facade.loading()) {
|
||||||
|
<app-empty-state title="No audit events yet" description="Nothing to show until a real backend logs actions here." />
|
||||||
|
} @else {
|
||||||
|
<ul>
|
||||||
|
@for (e of facade.events(); track e.id) {
|
||||||
|
<li>{{ e.occurredAt }} - {{ e.actor }} - {{ e.action }} ({{ e.entityType }}:{{ e.entityId }})</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
.audit-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { AdminAuditFacade } from '../facade/admin-audit.facade';
|
||||||
|
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-admin-audit-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, EmptyStateComponent],
|
||||||
|
templateUrl: './admin-audit-page.component.html',
|
||||||
|
styleUrls: ['./admin-audit-page.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class AdminAuditPageComponent {
|
||||||
|
readonly facade = inject(AdminAuditFacade);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.facade.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ export const ADMIN_NAV_PRIMARY: AdminNavEntry[] = [
|
|||||||
{ type: 'link', id: 'notifications', icon: 'bell', labelKey: 'adminShell.nav.notifications', path: ['notifications'] },
|
{ type: 'link', id: 'notifications', icon: 'bell', labelKey: 'adminShell.nav.notifications', path: ['notifications'] },
|
||||||
{ type: 'link', id: 'integrations', icon: 'network', labelKey: 'adminShell.nav.integrations', path: ['integrations'] },
|
{ type: 'link', id: 'integrations', icon: 'network', labelKey: 'adminShell.nav.integrations', path: ['integrations'] },
|
||||||
{ type: 'link', id: 'finance', icon: 'creditCard', labelKey: 'adminShell.nav.finance', path: ['finance'] },
|
{ type: 'link', id: 'finance', icon: 'creditCard', labelKey: 'adminShell.nav.finance', path: ['finance'] },
|
||||||
|
{ type: 'link', id: 'audit', icon: 'shield', labelKey: 'adminShell.nav.audit', path: ['audit'] },
|
||||||
{ type: 'link', id: 'reports', icon: 'chartBar', labelKey: 'adminShell.nav.reports', path: ['reports'] },
|
{ type: 'link', id: 'reports', icon: 'chartBar', labelKey: 'adminShell.nav.reports', path: ['reports'] },
|
||||||
{ type: 'group', labelKey: 'adminShell.nav.partnersGroup' },
|
{ type: 'group', labelKey: 'adminShell.nav.partnersGroup' },
|
||||||
{ type: 'link', id: 'seller-management', icon: 'store', labelKey: 'adminShell.nav.sellerManagement', path: ['partners', 'seller-management'] },
|
{ type: 'link', id: 'seller-management', icon: 'store', labelKey: 'adminShell.nav.sellerManagement', path: ['partners', 'seller-management'] },
|
||||||
|
|||||||
@@ -2063,6 +2063,7 @@ export const en: Translations = {
|
|||||||
integrations: 'Integrations',
|
integrations: 'Integrations',
|
||||||
finance: 'Payments & Finance',
|
finance: 'Payments & Finance',
|
||||||
marketplaces: 'Marketplaces',
|
marketplaces: 'Marketplaces',
|
||||||
|
audit: 'Audit & Security',
|
||||||
reviews: 'Reviews',
|
reviews: 'Reviews',
|
||||||
reports: 'Reports',
|
reports: 'Reports',
|
||||||
partnersGroup: 'Partners',
|
partnersGroup: 'Partners',
|
||||||
|
|||||||
@@ -2057,6 +2057,7 @@ export const hy: Translations = {
|
|||||||
integrations: 'Ինտեգրումներ',
|
integrations: 'Ինտեգրումներ',
|
||||||
finance: 'Վճարումներ և ֆինանսներ',
|
finance: 'Վճարումներ և ֆինանսներ',
|
||||||
marketplaces: 'Մարկետփլեյսներ',
|
marketplaces: 'Մարկետփլեյսներ',
|
||||||
|
audit: 'Աուդիտ և անվտանգություն',
|
||||||
transactions: 'Գործարքներ',
|
transactions: 'Գործարքներ',
|
||||||
reviews: 'Կարծիքներ',
|
reviews: 'Կարծիքներ',
|
||||||
reports: 'Հաշվետվություններ',
|
reports: 'Հաշվետվություններ',
|
||||||
|
|||||||
@@ -2057,6 +2057,7 @@ export const ru: Translations = {
|
|||||||
integrations: 'Интеграции',
|
integrations: 'Интеграции',
|
||||||
finance: 'Платежи и финансы',
|
finance: 'Платежи и финансы',
|
||||||
marketplaces: 'Маркетплейсы',
|
marketplaces: 'Маркетплейсы',
|
||||||
|
audit: 'Аудит и безопасность',
|
||||||
transactions: 'Транзакции',
|
transactions: 'Транзакции',
|
||||||
reviews: 'Отзывы',
|
reviews: 'Отзывы',
|
||||||
reports: 'Отчёты',
|
reports: 'Отчёты',
|
||||||
|
|||||||
@@ -2072,6 +2072,7 @@ export interface Translations {
|
|||||||
integrations: string;
|
integrations: string;
|
||||||
finance: string;
|
finance: string;
|
||||||
marketplaces: string;
|
marketplaces: string;
|
||||||
|
audit: string;
|
||||||
reports: string;
|
reports: string;
|
||||||
partnersGroup: string;
|
partnersGroup: string;
|
||||||
sellerManagement: string;
|
sellerManagement: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user