fix(auth): resolve JWT InvalidSignatureError and improve frontend UX
Some checks failed
CI / lint-and-test (push) Has been cancelled
Some checks failed
CI / lint-and-test (push) Has been cancelled
- Fix auth by using idToken fallback when accessToken audience mismatches - Add PyJWT verification with audience-aware token selection in frontend - Source health: track last_attempt_time and error status per source - Frontend: fix modal outside x-data scope, add circular-safe JSON stringify - Frontend: support multi-select service filter with All/None toggles - Frontend: improve filter layout into organized rows - Frontend: fix text overflow and result pill colors (success/succeeded) - Intune: normalize application actors (auditActorType=Application) - Add cache-control middleware for HTML/API responses - Update tests for multi-service filtering and source health
This commit is contained in:
@@ -6,7 +6,10 @@ FETCH_INTERVAL_MINUTES=60
|
||||
AUTH_ENABLED=false
|
||||
AUTH_TENANT_ID=your-tenant-id
|
||||
AUTH_CLIENT_ID=your-api-client-id
|
||||
# Optional scope for SPA login (e.g., api://<client-id>/access_as_user)
|
||||
# API scope the SPA should request at login.
|
||||
# When set, the frontend acquires an access token for this scope (aud = AUTH_CLIENT_ID).
|
||||
# When empty, the frontend falls back to the idToken, which is also valid for the backend.
|
||||
# Example: api://cc31fd45-1eca-431f-a2c6-ba81cd4c5d50/.default
|
||||
AUTH_SCOPE=
|
||||
# Comma-separated lists (optional):
|
||||
AUTH_ALLOWED_ROLES=
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AOC Events</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<link rel="stylesheet" href="/style.css?v=7" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
@@ -29,8 +29,10 @@
|
||||
<template x-for="src in sourceHealth" :key="src.source">
|
||||
<div class="health-card">
|
||||
<strong x-text="src.source"></strong>
|
||||
<span class="pill" :class="src.status === 'healthy' ? 'pill--ok' : 'pill--warn'" x-text="src.status"></span>
|
||||
<small x-text="src.last_fetch_time ? new Date(src.last_fetch_time).toLocaleString() : 'Never'"></small>
|
||||
<span class="pill"
|
||||
:class="src.status === 'healthy' ? 'pill--ok' : (src.status === 'error' ? 'pill--err' : 'pill--warn')"
|
||||
x-text="src.status"></span>
|
||||
<small x-text="src.last_fetch_time ? new Date(src.last_fetch_time).toLocaleString() : (src.last_attempt_time ? new Date(src.last_attempt_time).toLocaleString() : 'Never')"></small>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -38,55 +40,73 @@
|
||||
|
||||
<section class="panel">
|
||||
<form id="filters" class="filters" @submit.prevent="resetPagination(); loadEvents()">
|
||||
<label>
|
||||
User (name/UPN)
|
||||
<input name="actor" type="text" placeholder="tomas@contoso.com" list="actorOptions" x-model="filters.actor" />
|
||||
<datalist id="actorOptions">
|
||||
<template x-for="opt in options.actors" :key="opt"><option :value="opt"></option></template>
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
App / Service
|
||||
<input name="service" type="text" placeholder="DirectoryManagement" list="serviceOptions" x-model="filters.service" />
|
||||
<datalist id="serviceOptions">
|
||||
<template x-for="opt in options.services" :key="opt"><option :value="opt"></option></template>
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
Search (raw/full-text)
|
||||
<input name="search" type="text" placeholder="Any text to search in raw/summary" x-model="filters.search" />
|
||||
</label>
|
||||
<label>
|
||||
Action (display name)
|
||||
<input name="operation" type="text" placeholder="Add group member" list="operationOptions" x-model="filters.operation" />
|
||||
<datalist id="operationOptions">
|
||||
<template x-for="opt in options.operations" :key="opt"><option :value="opt"></option></template>
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
Action type (result)
|
||||
<input name="result" type="text" placeholder="success / failure" list="resultOptions" x-model="filters.result" />
|
||||
<datalist id="resultOptions">
|
||||
<template x-for="opt in options.results" :key="opt"><option :value="opt"></option></template>
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
From
|
||||
<input name="start" type="datetime-local" x-model="filters.start" />
|
||||
</label>
|
||||
<label>
|
||||
To
|
||||
<input name="end" type="datetime-local" x-model="filters.end" />
|
||||
</label>
|
||||
<label>
|
||||
Limit
|
||||
<input name="limit" type="number" min="1" max="500" value="100" x-model.number="filters.limit" />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">Apply filters</button>
|
||||
<button type="button" id="clearBtn" class="ghost" @click="clearFilters()">Clear</button>
|
||||
<button type="button" class="ghost" @click="exportJSON()">Export JSON</button>
|
||||
<button type="button" class="ghost" @click="exportCSV()">Export CSV</button>
|
||||
<div class="filter-row">
|
||||
<label>
|
||||
User (name/UPN)
|
||||
<input name="actor" type="text" placeholder="tomas@contoso.com" list="actorOptions" x-model="filters.actor" />
|
||||
<datalist id="actorOptions">
|
||||
<template x-for="opt in options.actors" :key="opt"><option :value="opt"></option></template>
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
Action (display name)
|
||||
<input name="operation" type="text" placeholder="Add group member" list="operationOptions" x-model="filters.operation" />
|
||||
<datalist id="operationOptions">
|
||||
<template x-for="opt in options.operations" :key="opt"><option :value="opt"></option></template>
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
Action type (result)
|
||||
<input name="result" type="text" placeholder="success / failure" list="resultOptions" x-model="filters.result" />
|
||||
<datalist id="resultOptions">
|
||||
<template x-for="opt in options.results" :key="opt"><option :value="opt"></option></template>
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
Limit
|
||||
<input name="limit" type="number" min="1" max="500" value="100" x-model.number="filters.limit" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<label>
|
||||
From
|
||||
<input name="start" type="datetime-local" x-model="filters.start" />
|
||||
</label>
|
||||
<label>
|
||||
To
|
||||
<input name="end" type="datetime-local" x-model="filters.end" />
|
||||
</label>
|
||||
<label class="span-2">
|
||||
Search (raw/full-text)
|
||||
<input name="search" type="text" placeholder="Any text to search in raw/summary" x-model="filters.search" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-row filter-row--tall">
|
||||
<div class="filter-group span-2">
|
||||
<span>App / Service</span>
|
||||
<div class="multi-select">
|
||||
<div class="multi-select__actions">
|
||||
<button type="button" class="link" @click="filters.selectedServices = [...options.services]">All</button>
|
||||
<button type="button" class="link" @click="filters.selectedServices = []">None</button>
|
||||
</div>
|
||||
<div class="multi-select__options">
|
||||
<template x-for="opt in options.services" :key="opt">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" :value="opt" x-model="filters.selectedServices" />
|
||||
<span x-text="opt"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-row actions-row">
|
||||
<div class="actions">
|
||||
<button type="submit">Apply filters</button>
|
||||
<button type="button" id="clearBtn" class="ghost" @click="clearFilters()">Clear</button>
|
||||
<button type="button" class="ghost" @click="exportJSON()">Export JSON</button>
|
||||
<button type="button" class="ghost" @click="exportCSV()">Export CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -98,35 +118,35 @@
|
||||
</div>
|
||||
<div id="status" class="status" aria-live="polite" x-text="statusText"></div>
|
||||
<div id="events" class="events">
|
||||
<template x-for="(e, idx) in events" :key="e._id || e.id || idx">
|
||||
<template x-for="(evt, idx) in events" :key="evt._id || evt.id || idx">
|
||||
<article class="event">
|
||||
<div class="event__meta">
|
||||
<span class="pill" x-text="e.display_category || e.service || '—'"></span>
|
||||
<span class="pill" :class="(e.result || '').toLowerCase() === 'success' ? 'pill--ok' : 'pill--warn'" x-text="e.result || '—'"></span>
|
||||
<span class="pill" x-text="evt.display_category || evt.service || '—'"></span>
|
||||
<span class="pill" :class="['success','succeeded','ok','passed'].includes((evt.result || '').toLowerCase()) ? 'pill--ok' : 'pill--warn'" x-text="evt.result || '—'"></span>
|
||||
</div>
|
||||
<h3 x-text="e.operation || '—'"></h3>
|
||||
<p class="event__detail" x-show="e.display_summary"><strong>Summary:</strong> <span x-text="e.display_summary"></span></p>
|
||||
<p class="event__detail"><strong x-text="e.display_actor_label || 'User'"></strong>: <span x-text="displayActor(e)"></span></p>
|
||||
<p class="event__detail" x-show="e.actor_owner_names && e.actor_owner_names.length"><strong>App owners:</strong> <span x-text="(e.actor_owner_names || []).slice(0,3).join(', ')"></span></p>
|
||||
<p class="event__detail"><strong>Target:</strong> <span x-text="displayTargets(e)"></span></p>
|
||||
<p class="event__detail"><strong>When:</strong> <span x-text="e.timestamp ? new Date(e.timestamp).toLocaleString() : '—'"></span></p>
|
||||
<h3 x-text="evt.operation || '—'"></h3>
|
||||
<p class="event__detail" x-show="evt.display_summary"><strong>Summary:</strong> <span x-text="evt.display_summary"></span></p>
|
||||
<p class="event__detail"><strong x-text="evt.display_actor_label || 'User'"></strong>: <span x-text="displayActor(evt)"></span></p>
|
||||
<p class="event__detail" x-show="evt.actor_owner_names && evt.actor_owner_names.length"><strong>App owners:</strong> <span x-text="(evt.actor_owner_names || []).slice(0,3).join(', ')"></span></p>
|
||||
<p class="event__detail"><strong>Target:</strong> <span x-text="displayTargets(evt)"></span></p>
|
||||
<p class="event__detail"><strong>When:</strong> <span x-text="evt.timestamp ? new Date(evt.timestamp).toLocaleString() : '—'"></span></p>
|
||||
|
||||
<div class="event__tags" x-show="e.tags && e.tags.length">
|
||||
<template x-for="tag in (e.tags || [])" :key="tag">
|
||||
<div class="event__tags" x-show="evt.tags && evt.tags.length">
|
||||
<template x-for="tag in (evt.tags || [])" :key="tag">
|
||||
<span class="pill pill--tag" x-text="tag"></span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="event__comments" x-show="e.comments && e.comments.length">
|
||||
<template x-for="c in (e.comments || [])" :key="c.timestamp + c.text">
|
||||
<div class="event__comments" x-show="evt.comments && evt.comments.length">
|
||||
<template x-for="c in (evt.comments || [])" :key="c.timestamp + c.text">
|
||||
<p class="comment"><strong x-text="c.author"></strong>: <span x-text="c.text"></span> <small x-text="new Date(c.timestamp).toLocaleString()"></small></p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="event__actions">
|
||||
<button class="ghost" @click="openModal(e)">View raw event</button>
|
||||
<input type="text" placeholder="Add tag" @keydown.enter="addTag(e, $event.target.value); $event.target.value=''" />
|
||||
<input type="text" placeholder="Add comment" @keydown.enter="addComment(e, $event.target.value); $event.target.value=''" />
|
||||
<button type="button" class="ghost" @click="openModal(evt)">View raw event</button>
|
||||
<input type="text" placeholder="Add tag" @keydown.enter="addTag(evt, $event.target.value); $event.target.value=''" />
|
||||
<input type="text" placeholder="Add comment" @keydown.enter="addComment(evt, $event.target.value); $event.target.value=''" />
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -137,15 +157,15 @@
|
||||
<button type="button" id="nextPage" :disabled="!nextCursor" @click="goNext()">Next</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle" :class="{ 'hidden': !modalOpen }">
|
||||
<div class="modal__content">
|
||||
<div class="modal__header">
|
||||
<h3 id="modalTitle">Raw Event</h3>
|
||||
<button type="button" id="closeModal" class="ghost" @click="modalOpen = false">Close</button>
|
||||
<div id="modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle" :class="{ 'hidden': !modalOpen }">
|
||||
<div class="modal__content">
|
||||
<div class="modal__header">
|
||||
<h3 id="modalTitle">Raw Event</h3>
|
||||
<button type="button" id="closeModal" class="ghost" @click="modalOpen = false">Close</button>
|
||||
</div>
|
||||
<pre id="modalBody" x-text="modalBody"></pre>
|
||||
</div>
|
||||
<pre id="modalBody" x-text="modalBody"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -168,7 +188,7 @@
|
||||
accessToken: null,
|
||||
authScopes: [],
|
||||
filters: {
|
||||
actor: '', service: '', search: '', operation: '', result: '', start: '', end: '', limit: 100,
|
||||
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 100,
|
||||
},
|
||||
options: { actors: [], services: [], operations: [], results: [] },
|
||||
|
||||
@@ -186,7 +206,21 @@
|
||||
},
|
||||
|
||||
pickToken(res) {
|
||||
return res ? (res.accessToken || res.idToken || null) : null;
|
||||
if (!res) return null;
|
||||
const clientId = this.authConfig?.client_id;
|
||||
// If accessToken is present and its audience matches our API, use it.
|
||||
if (res.accessToken && clientId) {
|
||||
try {
|
||||
const base64 = res.accessToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '=');
|
||||
const payload = JSON.parse(atob(padded));
|
||||
if (payload.aud === clientId) {
|
||||
return res.accessToken;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// Fall back to idToken (always aud=clientId) or accessToken
|
||||
return res.idToken || res.accessToken || null;
|
||||
},
|
||||
|
||||
async initAuth() {
|
||||
@@ -279,10 +313,13 @@
|
||||
async loadEvents(cursor) {
|
||||
this.currentCursor = cursor || null;
|
||||
const params = new URLSearchParams();
|
||||
['actor', 'service', 'operation', 'result', 'search'].forEach((key) => {
|
||||
['actor', 'operation', 'result', 'search'].forEach((key) => {
|
||||
const val = this.filters[key];
|
||||
if (val) params.append(key, val);
|
||||
});
|
||||
if (this.filters.selectedServices && this.filters.selectedServices.length) {
|
||||
this.filters.selectedServices.forEach((s) => params.append('services', s));
|
||||
}
|
||||
if (this.filters.start) {
|
||||
const d = new Date(this.filters.start);
|
||||
if (!isNaN(d.getTime())) params.append('start', d.toISOString());
|
||||
@@ -345,6 +382,9 @@
|
||||
this.options.services = (opts.services || []).slice(0, 200);
|
||||
this.options.operations = (opts.operations || []).slice(0, 200);
|
||||
this.options.results = (opts.results || []).slice(0, 200);
|
||||
if (!this.filters.selectedServices.length && this.options.services.length) {
|
||||
this.filters.selectedServices = [...this.options.services];
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
@@ -377,12 +417,14 @@
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.filters = { actor: '', service: '', search: '', operation: '', result: '', start: '', end: '', limit: 100 };
|
||||
this.filters = { actor: '', selectedServices: [...this.options.services], search: '', operation: '', result: '', start: '', end: '', limit: 100 };
|
||||
this.resetPagination();
|
||||
this.loadEvents();
|
||||
},
|
||||
|
||||
displayActor(e) {
|
||||
const app = e.actor?.application || e.actor?.app;
|
||||
if (app?.displayName) return app.displayName;
|
||||
return e.actor_display ||
|
||||
(e.actor_resolved?.name) ||
|
||||
(e.actor?.user?.displayName && e.actor?.user?.userPrincipalName && e.actor?.user?.displayName !== e.actor?.user?.userPrincipalName
|
||||
@@ -399,7 +441,18 @@
|
||||
},
|
||||
|
||||
openModal(e) {
|
||||
this.modalBody = JSON.stringify(e.raw || e, null, 2);
|
||||
const seen = new WeakSet();
|
||||
try {
|
||||
this.modalBody = JSON.stringify(e.raw || e, (key, value) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) return '[Circular]';
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
}, 2);
|
||||
} catch (err) {
|
||||
this.modalBody = `Error serializing event:\n${err.message}\n\nEvent ID: ${e.id || 'N/A'}`;
|
||||
}
|
||||
this.modalOpen = true;
|
||||
},
|
||||
|
||||
|
||||
@@ -91,12 +91,51 @@ button.ghost {
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.filters label {
|
||||
.filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.filter-row .span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.filter-row--tall {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.filter-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.filter-row .span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.filter-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.filter-row .span-2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
.filters label,
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
@@ -159,7 +198,7 @@ input {
|
||||
|
||||
.events {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -168,6 +207,8 @@ input {
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.event__meta {
|
||||
@@ -175,16 +216,19 @@ input {
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 6px 10px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(125, 211, 252, 0.12);
|
||||
border: 1px solid rgba(125, 211, 252, 0.4);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
max-width: 100%;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.pill--ok {
|
||||
@@ -197,8 +241,17 @@ input {
|
||||
border-color: rgba(249, 115, 22, 0.5);
|
||||
}
|
||||
|
||||
.pill--err {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.event h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 17px;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.event__detail {
|
||||
@@ -211,6 +264,71 @@ input {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.multi-select {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.multi-select__actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.multi-select__actions .link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent-strong);
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.multi-select__actions .link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.multi-select__options {
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.checkbox-label:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.checkbox-label:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
accent-color: var(--accent-strong);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -69,6 +69,17 @@ async def prometheus_middleware(request: Request, call_next):
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def cache_control_middleware(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
# Prevent caching of HTML and API responses by default
|
||||
if request.url.path.startswith("/api/") or request.url.path in ("/", "/index.html"):
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def audit_middleware(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
|
||||
@@ -46,6 +46,7 @@ class FetchAuditLogsResponse(BaseModel):
|
||||
class SourceHealthResponse(BaseModel):
|
||||
source: str
|
||||
last_fetch_time: str | None = None
|
||||
last_attempt_time: str | None = None
|
||||
status: str
|
||||
|
||||
|
||||
|
||||
@@ -28,9 +28,11 @@ def _actor_display(actor: dict, resolved: dict = None, owners=None) -> str:
|
||||
user = actor.get("user", {}) or {}
|
||||
sp = actor.get("servicePrincipal", {}) or {}
|
||||
app = actor.get("app", {}) or {}
|
||||
application = actor.get("application", {}) or {}
|
||||
upn = user.get("userPrincipalName") or user.get("mail")
|
||||
display = user.get("displayName")
|
||||
app_display = app.get("displayName")
|
||||
app_display = app.get("displayName") or application.get("displayName")
|
||||
app_id = app.get("id") or application.get("id")
|
||||
|
||||
if display and upn and display != upn:
|
||||
return f"{display} ({upn})"
|
||||
@@ -41,6 +43,7 @@ def _actor_display(actor: dict, resolved: dict = None, owners=None) -> str:
|
||||
or app_display
|
||||
or sp.get("displayName")
|
||||
or sp.get("appId")
|
||||
or app_id
|
||||
or actor.get("ipAddress")
|
||||
or user.get("id")
|
||||
or sp.get("id")
|
||||
@@ -164,7 +167,8 @@ def normalize_event(e):
|
||||
|
||||
display_conf = display_mapping.get(category) or display_mapping.get("default", {})
|
||||
actor_field_pref = display_conf.get("actor_field", "actor_display")
|
||||
actor_label_text = display_conf.get("actor_label", "User")
|
||||
default_actor_label = "Application" if (actor.get("application") or actor.get("app")) else "User"
|
||||
actor_label_text = display_conf.get("actor_label", default_actor_label)
|
||||
|
||||
if actor_field_pref == "actor_upn" and actor_upn:
|
||||
display_actor_value = actor_upn
|
||||
|
||||
@@ -34,6 +34,7 @@ def _decode_cursor(cursor: str) -> tuple[str, str]:
|
||||
@router.get("/events", response_model=PaginatedEventResponse)
|
||||
def list_events(
|
||||
service: str | None = None,
|
||||
services: list[str] | None = Query(default=None),
|
||||
actor: str | None = None,
|
||||
operation: str | None = None,
|
||||
result: str | None = None,
|
||||
@@ -48,6 +49,8 @@ def list_events(
|
||||
|
||||
if service:
|
||||
filters.append({"service": service})
|
||||
if services:
|
||||
filters.append({"service": {"$in": services}})
|
||||
if actor:
|
||||
actor_safe = re.escape(actor)
|
||||
filters.append(
|
||||
|
||||
@@ -31,12 +31,13 @@ def run_fetch(hours: int = 168):
|
||||
try:
|
||||
since = get_watermark(source_key)
|
||||
result = fn(since=since) if since else fn(hours=window)
|
||||
set_watermark(source_key, now)
|
||||
set_watermark(source_key, now, status="healthy")
|
||||
track_fetch(source_key, len(result))
|
||||
return result
|
||||
except Exception as exc:
|
||||
errors.append(f"{label}: {exc}")
|
||||
track_fetch_error(source_key)
|
||||
set_watermark(source_key, now, status="error")
|
||||
return []
|
||||
finally:
|
||||
track_fetch_duration(source_key, time.time() - start_time)
|
||||
|
||||
@@ -15,16 +15,21 @@ def source_health():
|
||||
results = []
|
||||
for source in SOURCES:
|
||||
doc = watermarks_collection.find_one({"source": source})
|
||||
if doc and doc.get("last_fetch_time"):
|
||||
if doc:
|
||||
status = doc.get("status")
|
||||
if not status:
|
||||
status = "healthy" if doc.get("last_fetch_time") else "unknown"
|
||||
results.append({
|
||||
"source": source,
|
||||
"last_fetch_time": doc["last_fetch_time"],
|
||||
"status": "healthy",
|
||||
"last_fetch_time": doc.get("last_fetch_time"),
|
||||
"last_attempt_time": doc.get("last_attempt_time"),
|
||||
"status": status,
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
"source": source,
|
||||
"last_fetch_time": None,
|
||||
"last_attempt_time": None,
|
||||
"status": "unknown",
|
||||
})
|
||||
return results
|
||||
|
||||
@@ -45,20 +45,27 @@ def _normalize_intune(e: dict) -> dict:
|
||||
actor = e.get("actor", {}) or {}
|
||||
target = e.get("resources", [{}])[0] if e.get("resources") else {}
|
||||
|
||||
initiated_by: dict = {"user": {}}
|
||||
if actor.get("auditActorType") == "Application" or actor.get("applicationDisplayName"):
|
||||
initiated_by["application"] = {
|
||||
"id": actor.get("applicationId"),
|
||||
"displayName": actor.get("applicationDisplayName"),
|
||||
}
|
||||
else:
|
||||
initiated_by["user"] = {
|
||||
"id": actor.get("userId"),
|
||||
"userPrincipalName": actor.get("userPrincipalName"),
|
||||
"displayName": actor.get("userName"),
|
||||
"ipAddress": actor.get("ipAddress"),
|
||||
}
|
||||
|
||||
return {
|
||||
"id": e.get("id"),
|
||||
"activityDateTime": e.get("activityDateTime"),
|
||||
"category": e.get("category") or "Intune",
|
||||
"activityDisplayName": e.get("activity") or e.get("activityType"),
|
||||
"result": e.get("activityResult") or e.get("result"),
|
||||
"initiatedBy": {
|
||||
"user": {
|
||||
"id": actor.get("userId"),
|
||||
"userPrincipalName": actor.get("userPrincipalName"),
|
||||
"displayName": actor.get("userName"),
|
||||
"ipAddress": actor.get("ipAddress"),
|
||||
}
|
||||
},
|
||||
"initiatedBy": initiated_by,
|
||||
"targetResources": [
|
||||
{
|
||||
"id": target.get("id"),
|
||||
|
||||
@@ -73,6 +73,42 @@ def test_list_events_filter_by_service(client, mock_events_collection):
|
||||
assert data["items"][0]["service"] == "Exchange"
|
||||
|
||||
|
||||
def test_list_events_filter_by_services(client, mock_events_collection):
|
||||
mock_events_collection.insert_one({
|
||||
"id": "evt-1",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"service": "Exchange",
|
||||
"operation": "Update",
|
||||
"result": "success",
|
||||
"actor_display": "Alice",
|
||||
"raw_text": "",
|
||||
})
|
||||
mock_events_collection.insert_one({
|
||||
"id": "evt-2",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"service": "Directory",
|
||||
"operation": "Add",
|
||||
"result": "success",
|
||||
"actor_display": "Bob",
|
||||
"raw_text": "",
|
||||
})
|
||||
mock_events_collection.insert_one({
|
||||
"id": "evt-3",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"service": "Teams",
|
||||
"operation": "Delete",
|
||||
"result": "success",
|
||||
"actor_display": "Charlie",
|
||||
"raw_text": "",
|
||||
})
|
||||
response = client.get("/api/events?service=Exchange&service=Directory")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 2
|
||||
returned_services = {item["service"] for item in data["items"]}
|
||||
assert returned_services == {"Exchange", "Directory"}
|
||||
|
||||
|
||||
def test_list_events_page_size_validation(client):
|
||||
response = client.get("/api/events?page_size=0")
|
||||
assert response.status_code == 422
|
||||
|
||||
@@ -9,10 +9,15 @@ def get_watermark(source: str) -> str | None:
|
||||
return doc.get("last_fetch_time") if doc else None
|
||||
|
||||
|
||||
def set_watermark(source: str, timestamp: str):
|
||||
"""Persist the latest successful fetch timestamp for a source."""
|
||||
def set_watermark(source: str, timestamp: str, status: str | None = None):
|
||||
"""Persist the latest fetch attempt timestamp and optional status for a source."""
|
||||
doc: dict = {"last_attempt_time": timestamp}
|
||||
if status == "healthy":
|
||||
doc["last_fetch_time"] = timestamp
|
||||
if status:
|
||||
doc["status"] = status
|
||||
watermarks_collection.update_one(
|
||||
{"source": source},
|
||||
{"$set": {"last_fetch_time": timestamp}},
|
||||
{"$set": doc},
|
||||
upsert=True,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user