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:
master
2026-03-16 21:20:38 +02:00
parent f4eb64fefc
commit 378b52a5cb
7 changed files with 78 additions and 30 deletions

View File

@@ -25,7 +25,7 @@ Completion criteria:
- [x] Angular build succeeds - [x] Angular build succeeds
### J20-T02 - Integration detail: show username instead of raw user ID ### J20-T02 - Integration detail: show username instead of raw user ID
Status: TODO Status: DONE
Dependency: none Dependency: none
Owners: Developer Owners: Developer
Task description: Task description:
@@ -33,7 +33,7 @@ Task description:
- Should show `admin` or truncated form like "User 9a2d0730...". - Should show `admin` or truncated form like "User 9a2d0730...".
### J20-T03 - Advisory sources: auto-check on first visit ### J20-T03 - Advisory sources: auto-check on first visit
Status: TODO Status: DONE
Dependency: none Dependency: none
Owners: Developer Owners: Developer
Task description: Task description:
@@ -65,6 +65,9 @@ Task description:
10. Evidence Overview: search router, 1842 evidence packs, operator/auditor toggle 10. Evidence Overview: search router, 1842 evidence packs, operator/auditor toggle
11. Security Reports: CSV/PDF export, VEX guidance text 11. Security Reports: CSV/PDF export, VEX guidance text
12. Security Posture: real finding counts (6 findings), CTAs working 12. Security Posture: real finding counts (6 findings), CTAs working
13. Operations Hub: 3 blocking, 5 degraded, 12 sub-nav tabs, pending operator actions list
14. Releases Deployments: 5 deployments visible (1 RUNNING, 3 SUCCESS, 1 FAILED)
15. Identity & Access: Users table (admin active), 5 tabs (Users, Roles, OAuth, Tokens, Tenants)
**Next journey iteration should go deeper into:** **Next journey iteration should go deeper into:**
- Complete scan → view results in triage → make VEX decision → see in reports - Complete scan → view results in triage → make VEX decision → see in reports

View File

@@ -52,8 +52,9 @@ export function requireScopesGuard(
const session = auth.session(); const session = auth.session();
const userScopes = session?.scopes ?? []; const userScopes = session?.scopes ?? [];
// Admin scope grants access to everything // Admin scope grants access to everything.
if (userScopes.includes(StellaOpsScopes.ADMIN)) { // Authority may issue 'admin' or 'ui.admin' depending on client configuration.
if (userScopes.includes(StellaOpsScopes.ADMIN) || userScopes.includes(StellaOpsScopes.UI_ADMIN)) {
return true; return true;
} }
@@ -96,7 +97,7 @@ export function requireAnyScopeGuard(
const userScopes = session?.scopes ?? []; const userScopes = session?.scopes ?? [];
// Admin scope grants access to everything // Admin scope grants access to everything
if (userScopes.includes(StellaOpsScopes.ADMIN)) { if (userScopes.includes(StellaOpsScopes.ADMIN) || userScopes.includes(StellaOpsScopes.UI_ADMIN)) {
return true; return true;
} }
@@ -128,7 +129,7 @@ export const requirePolicyReviewOrApproveGuard: CanMatchFn = () => {
} }
const scopes = auth.session()?.scopes ?? []; 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 hasRead = scopes.includes(StellaOpsScopes.POLICY_READ);
const hasReviewOrApprove = const hasReviewOrApprove =

View File

@@ -48,10 +48,10 @@ export const StellaOpsScopes = {
POLICY_PROMOTE: 'policy:promote', // Requires interactive auth POLICY_PROMOTE: 'policy:promote', // Requires interactive auth
POLICY_AUDIT: 'policy:audit', POLICY_AUDIT: 'policy:audit',
// Exception scopes // Exception scopes (Authority issues 'exceptions:read' with plural 's')
EXCEPTION_READ: 'exception:read', EXCEPTION_READ: 'exceptions:read',
EXCEPTION_WRITE: 'exception:write', EXCEPTION_WRITE: 'exceptions:write',
EXCEPTION_APPROVE: 'exception:approve', EXCEPTION_APPROVE: 'exceptions:approve',
// Advisory scopes // Advisory scopes
ADVISORY_READ: 'advisory:read', ADVISORY_READ: 'advisory:read',
@@ -292,9 +292,9 @@ export const ScopeLabels: Record<StellaOpsScope, string> = {
'policy:publish': 'Publish Policy Versions', 'policy:publish': 'Publish Policy Versions',
'policy:promote': 'Promote Between Environments', 'policy:promote': 'Promote Between Environments',
'policy:audit': 'Audit Policy Activity', 'policy:audit': 'Audit Policy Activity',
'exception:read': 'View Exceptions', 'exceptions:read': 'View Exceptions',
'exception:write': 'Create Exceptions', 'exceptions:write': 'Create Exceptions',
'exception:approve': 'Approve Exceptions', 'exceptions:approve': 'Approve Exceptions',
'advisory:read': 'View Advisories', 'advisory:read': 'View Advisories',
'vex:read': 'View VEX Evidence', 'vex:read': 'View VEX Evidence',
'vex:export': 'Export VEX Evidence', 'vex:export': 'Export VEX Evidence',
@@ -346,9 +346,6 @@ export const ScopeLabels: Record<StellaOpsScope, string> = {
'zastava:read': 'View Zastava State', 'zastava:read': 'View Zastava State',
'zastava:trigger': 'Trigger Zastava Processing', 'zastava:trigger': 'Trigger Zastava Processing',
'zastava:admin': 'Administer Zastava', 'zastava:admin': 'Administer Zastava',
// Exception scope labels
'exceptions:read': 'View Exceptions',
'exceptions:write': 'Create Exceptions',
// Findings scope label // Findings scope label
'findings:read': 'View Policy Findings', 'findings:read': 'View Policy Findings',
// Notify scope labels // Notify scope labels

View File

@@ -1,4 +1,5 @@
import { DestroyRef, Injectable, Injector, effect, inject } from '@angular/core'; import { DestroyRef, Injectable, Injector, effect, inject } from '@angular/core';
import { Location } from '@angular/common';
import { NavigationEnd, Router } from '@angular/router'; import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs/operators'; import { filter } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -8,6 +9,7 @@ import { PlatformContextStore } from './platform-context.store';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PlatformContextUrlSyncService { export class PlatformContextUrlSyncService {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly location = inject(Location);
private readonly context = inject(PlatformContextStore); private readonly context = inject(PlatformContextStore);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly injector = inject(Injector); private readonly injector = inject(Injector);
@@ -54,15 +56,18 @@ export class PlatformContextUrlSyncService {
return; 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); const nextTree = this.router.parseUrl(currentUrl);
nextTree.queryParams = nextQuery; nextTree.queryParams = nextQuery;
const serializedUrl = this.router.serializeUrl(nextTree);
this.syncingToUrl = true; this.syncingToUrl = true;
void this.router.navigateByUrl(nextTree, { this.location.replaceState(serializedUrl);
replaceUrl: true, this.syncingToUrl = false;
}).finally(() => {
this.syncingToUrl = false;
});
}, },
{ injector: this.injector }, { injector: this.injector },
); );

View File

@@ -212,15 +212,21 @@ interface AdvisoryFeedSummary {
<span class="feed-stat-value">{{ feedSummary().enabledSources }}</span> <span class="feed-stat-value">{{ feedSummary().enabledSources }}</span>
<span class="feed-stat-label">active</span> <span class="feed-stat-label">active</span>
</div> </div>
<div class="feed-stat"> @if (feedSummary().healthySources === 0 && feedSummary().failedSources === 0) {
<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"> <div class="feed-stat">
<span class="feed-stat-value danger">{{ feedSummary().failedSources }}</span> <span class="feed-stat-value muted">Not checked yet</span>
<span class="feed-stat-label">failed</span>
</div> </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> </div>
<a routerLink="/setup/integrations/advisory-vex-sources" queryParamsHandling="merge" class="posture-link"> <a routerLink="/setup/integrations/advisory-vex-sources" queryParamsHandling="merge" class="posture-link">
@@ -756,6 +762,12 @@ interface AdvisoryFeedSummary {
color: var(--color-status-error); 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 { .feed-stat-label {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);

View File

@@ -95,4 +95,24 @@ describe('IntegrationDetailComponent', () => {
expect(component.lastHealthResult?.message).toContain('ENOTFOUND'); expect(component.lastHealthResult?.message).toContain('ENOTFOUND');
expect(component.lastHealthResult?.checkedAt).toBe('2026-03-14T10:06:00Z'); 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...');
});
});
}); });

View File

@@ -89,9 +89,9 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
<dt>Has Auth</dt> <dt>Has Auth</dt>
<dd>{{ integration.hasAuth ? 'Configured via AuthRef' : 'Not configured' }}</dd> <dd>{{ integration.hasAuth ? 'Configured via AuthRef' : 'Not configured' }}</dd>
<dt>Created</dt> <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> <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> </dl>
<h3>Tags</h3> <h3>Tags</h3>
@if (integration.tags.length > 0) { @if (integration.tags.length > 0) {
@@ -605,6 +605,16 @@ export class IntegrationDetailComponent implements OnInit {
return getProviderLabel(provider); 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 { isRegistryType(): boolean {
return this.integration?.type === IntegrationType.Registry; return this.integration?.type === IntegrationType.Registry;
} }