Fix critical route redirect race + scope mismatches + UX polish
Critical fixes: - Replace router.navigateByUrl() with Location.replaceState() in PlatformContextUrlSyncService to prevent re-evaluating canMatch guards during query param sync. This was causing random page redirects across all routes when auth session signals hadn't settled yet. - Fix exception scope mismatch: Authority issues 'exceptions:read' (plural) but guards checked 'exception:read' (singular). Aligned to plural form. - Fix admin scope bypass: guards checked 'admin' scope but token has 'ui.admin'. Now both are accepted as superuser bypass. - Remove duplicate scope entries in description map. UX polish (from fix agents): - Integration detail: formatActor() truncates raw user ID hashes to "User 9a2d0730..." instead of showing full 32-char hex string. - Dashboard feed status: show "Not checked yet" instead of "0 healthy" when no advisory source health checks have run. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,8 +52,9 @@ export function requireScopesGuard(
|
||||
const session = auth.session();
|
||||
const userScopes = session?.scopes ?? [];
|
||||
|
||||
// Admin scope grants access to everything
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) {
|
||||
// Admin scope grants access to everything.
|
||||
// Authority may issue 'admin' or 'ui.admin' depending on client configuration.
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN) || userScopes.includes(StellaOpsScopes.UI_ADMIN)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -96,7 +97,7 @@ export function requireAnyScopeGuard(
|
||||
const userScopes = session?.scopes ?? [];
|
||||
|
||||
// Admin scope grants access to everything
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) {
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN) || userScopes.includes(StellaOpsScopes.UI_ADMIN)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -128,7 +129,7 @@ export const requirePolicyReviewOrApproveGuard: CanMatchFn = () => {
|
||||
}
|
||||
|
||||
const scopes = auth.session()?.scopes ?? [];
|
||||
if (scopes.includes(StellaOpsScopes.ADMIN)) return true;
|
||||
if (scopes.includes(StellaOpsScopes.ADMIN) || scopes.includes(StellaOpsScopes.UI_ADMIN)) return true;
|
||||
|
||||
const hasRead = scopes.includes(StellaOpsScopes.POLICY_READ);
|
||||
const hasReviewOrApprove =
|
||||
|
||||
@@ -48,10 +48,10 @@ export const StellaOpsScopes = {
|
||||
POLICY_PROMOTE: 'policy:promote', // Requires interactive auth
|
||||
POLICY_AUDIT: 'policy:audit',
|
||||
|
||||
// Exception scopes
|
||||
EXCEPTION_READ: 'exception:read',
|
||||
EXCEPTION_WRITE: 'exception:write',
|
||||
EXCEPTION_APPROVE: 'exception:approve',
|
||||
// Exception scopes (Authority issues 'exceptions:read' with plural 's')
|
||||
EXCEPTION_READ: 'exceptions:read',
|
||||
EXCEPTION_WRITE: 'exceptions:write',
|
||||
EXCEPTION_APPROVE: 'exceptions:approve',
|
||||
|
||||
// Advisory scopes
|
||||
ADVISORY_READ: 'advisory:read',
|
||||
@@ -292,9 +292,9 @@ export const ScopeLabels: Record<StellaOpsScope, string> = {
|
||||
'policy:publish': 'Publish Policy Versions',
|
||||
'policy:promote': 'Promote Between Environments',
|
||||
'policy:audit': 'Audit Policy Activity',
|
||||
'exception:read': 'View Exceptions',
|
||||
'exception:write': 'Create Exceptions',
|
||||
'exception:approve': 'Approve Exceptions',
|
||||
'exceptions:read': 'View Exceptions',
|
||||
'exceptions:write': 'Create Exceptions',
|
||||
'exceptions:approve': 'Approve Exceptions',
|
||||
'advisory:read': 'View Advisories',
|
||||
'vex:read': 'View VEX Evidence',
|
||||
'vex:export': 'Export VEX Evidence',
|
||||
@@ -346,9 +346,6 @@ export const ScopeLabels: Record<StellaOpsScope, string> = {
|
||||
'zastava:read': 'View Zastava State',
|
||||
'zastava:trigger': 'Trigger Zastava Processing',
|
||||
'zastava:admin': 'Administer Zastava',
|
||||
// Exception scope labels
|
||||
'exceptions:read': 'View Exceptions',
|
||||
'exceptions:write': 'Create Exceptions',
|
||||
// Findings scope label
|
||||
'findings:read': 'View Policy Findings',
|
||||
// Notify scope labels
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DestroyRef, Injectable, Injector, effect, inject } from '@angular/core';
|
||||
import { Location } from '@angular/common';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
@@ -8,6 +9,7 @@ import { PlatformContextStore } from './platform-context.store';
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlatformContextUrlSyncService {
|
||||
private readonly router = inject(Router);
|
||||
private readonly location = inject(Location);
|
||||
private readonly context = inject(PlatformContextStore);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly injector = inject(Injector);
|
||||
@@ -54,15 +56,18 @@ export class PlatformContextUrlSyncService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the updated URL with new query params but do NOT use
|
||||
// router.navigateByUrl() -- that re-evaluates canMatch guards on
|
||||
// every route, which causes random redirects when the auth session
|
||||
// signal hasn't settled yet. Location.replaceState() updates the
|
||||
// browser URL without triggering Angular route navigation.
|
||||
const nextTree = this.router.parseUrl(currentUrl);
|
||||
nextTree.queryParams = nextQuery;
|
||||
const serializedUrl = this.router.serializeUrl(nextTree);
|
||||
|
||||
this.syncingToUrl = true;
|
||||
void this.router.navigateByUrl(nextTree, {
|
||||
replaceUrl: true,
|
||||
}).finally(() => {
|
||||
this.syncingToUrl = false;
|
||||
});
|
||||
this.location.replaceState(serializedUrl);
|
||||
this.syncingToUrl = false;
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
|
||||
@@ -212,15 +212,21 @@ interface AdvisoryFeedSummary {
|
||||
<span class="feed-stat-value">{{ feedSummary().enabledSources }}</span>
|
||||
<span class="feed-stat-label">active</span>
|
||||
</div>
|
||||
<div class="feed-stat">
|
||||
<span class="feed-stat-value healthy">{{ feedSummary().healthySources }}</span>
|
||||
<span class="feed-stat-label">healthy</span>
|
||||
</div>
|
||||
@if (feedSummary().failedSources > 0) {
|
||||
@if (feedSummary().healthySources === 0 && feedSummary().failedSources === 0) {
|
||||
<div class="feed-stat">
|
||||
<span class="feed-stat-value danger">{{ feedSummary().failedSources }}</span>
|
||||
<span class="feed-stat-label">failed</span>
|
||||
<span class="feed-stat-value muted">Not checked yet</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="feed-stat">
|
||||
<span class="feed-stat-value healthy">{{ feedSummary().healthySources }}</span>
|
||||
<span class="feed-stat-label">healthy</span>
|
||||
</div>
|
||||
@if (feedSummary().failedSources > 0) {
|
||||
<div class="feed-stat">
|
||||
<span class="feed-stat-value danger">{{ feedSummary().failedSources }}</span>
|
||||
<span class="feed-stat-label">failed</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<a routerLink="/setup/integrations/advisory-vex-sources" queryParamsHandling="merge" class="posture-link">
|
||||
@@ -756,6 +762,12 @@ interface AdvisoryFeedSummary {
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.feed-stat-value.muted {
|
||||
font-size: 0.85rem;
|
||||
font-weight: var(--font-weight-normal, 400);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.feed-stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
@@ -95,4 +95,24 @@ describe('IntegrationDetailComponent', () => {
|
||||
expect(component.lastHealthResult?.message).toContain('ENOTFOUND');
|
||||
expect(component.lastHealthResult?.checkedAt).toBe('2026-03-14T10:06:00Z');
|
||||
});
|
||||
|
||||
describe('formatActor', () => {
|
||||
it('returns system for null or undefined', () => {
|
||||
expect(component.formatActor(null)).toBe('system');
|
||||
expect(component.formatActor(undefined)).toBe('system');
|
||||
});
|
||||
|
||||
it('passes through short readable names', () => {
|
||||
expect(component.formatActor('admin')).toBe('admin');
|
||||
expect(component.formatActor('demo-user')).toBe('demo-user');
|
||||
});
|
||||
|
||||
it('passes through email addresses regardless of length', () => {
|
||||
expect(component.formatActor('very-long-admin-user@example.org')).toBe('very-long-admin-user@example.org');
|
||||
});
|
||||
|
||||
it('truncates raw hex user ID hashes', () => {
|
||||
expect(component.formatActor('9a2d07300a014c26ba215595bb282128')).toBe('User 9a2d0730...');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,9 +89,9 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
|
||||
<dt>Has Auth</dt>
|
||||
<dd>{{ integration.hasAuth ? 'Configured via AuthRef' : 'Not configured' }}</dd>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ integration.createdAt | date:'medium' }} by {{ integration.createdBy || 'system' }}</dd>
|
||||
<dd>{{ integration.createdAt | date:'medium' }} by {{ formatActor(integration.createdBy) }}</dd>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ integration.updatedAt ? (integration.updatedAt | date:'medium') : 'Never' }} by {{ integration.updatedBy || 'system' }}</dd>
|
||||
<dd>{{ integration.updatedAt ? (integration.updatedAt | date:'medium') : 'Never' }} by {{ formatActor(integration.updatedBy) }}</dd>
|
||||
</dl>
|
||||
<h3>Tags</h3>
|
||||
@if (integration.tags.length > 0) {
|
||||
@@ -605,6 +605,16 @@ export class IntegrationDetailComponent implements OnInit {
|
||||
return getProviderLabel(provider);
|
||||
}
|
||||
|
||||
formatActor(actorId: string | null | undefined): string {
|
||||
if (!actorId) return 'system';
|
||||
// Short readable IDs or well-known names pass through
|
||||
if (actorId.length <= 20 || actorId.includes('@') || actorId.includes(' ')) {
|
||||
return actorId;
|
||||
}
|
||||
// Raw hex hashes get truncated with a user indicator
|
||||
return `User ${actorId.slice(0, 8)}...`;
|
||||
}
|
||||
|
||||
isRegistryType(): boolean {
|
||||
return this.integration?.type === IntegrationType.Registry;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user