feat: implement Phase 4 enhancements
Some checks failed
CI / lint-and-test (push) Has been cancelled
Some checks failed
CI / lint-and-test (push) Has been cancelled
- Migrate frontend to Alpine.js for reactive state management
- Add source health dashboard in UI and /api/source-health endpoint
- Add event tagging (PATCH /api/events/{id}/tags) and commenting (POST /api/events/{id}/comments)
- Add CSV/JSON export from the UI
- Add rule-based alerting engine (rules.py) with CRUD endpoints (/api/rules)
- Add SIEM export via webhook (siem.py)
- Add AOC audit trail middleware logging all mutations to aoc_audit collection
- Update config with SIEM_ENABLED, SIEM_WEBHOOK_URL, ALERTS_ENABLED
- Add tests for rules engine, tags, comments, and source health
This commit is contained in:
@@ -23,3 +23,10 @@ RETENTION_DAYS=0
|
||||
|
||||
# Optional: comma-separated CORS origins (e.g., http://localhost:3000,https://app.example.com)
|
||||
CORS_ORIGINS=*
|
||||
|
||||
# Optional: SIEM export webhook (e.g., Splunk HEC, Sentinel, or generic syslog webhook)
|
||||
SIEM_ENABLED=false
|
||||
SIEM_WEBHOOK_URL=
|
||||
|
||||
# Optional: enable rule-based alerting during ingestion
|
||||
ALERTS_ENABLED=false
|
||||
|
||||
@@ -72,11 +72,20 @@ uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
- Intune audit logs (`/deviceManagement/auditEvents`)
|
||||
Dedupes on a stable key (source id or timestamp/category/operation/target). Returns count and per-source warnings.
|
||||
- **Incremental fetch**: each source remembers its last successful fetch time in MongoDB (`watermarks` collection). Subsequent calls fetch only new events since the watermark.
|
||||
- **Alerting**: if `ALERTS_ENABLED=true`, events are evaluated against stored rules during ingestion.
|
||||
- **SIEM export**: if `SIEM_ENABLED=true`, each ingested event is forwarded to `SIEM_WEBHOOK_URL`.
|
||||
- `GET /api/events` — list stored events with filters:
|
||||
- `service`, `actor`, `operation`, `result`, `start`, `end`, `search` (free text over raw/summary/actor/targets)
|
||||
- Pagination: `cursor`-based (`page_size` defaults to 50, max 500). Pass `cursor` from `next_cursor` to paginate forward.
|
||||
- `GET /api/filter-options` — best-effort distinct values for services, operations, results, actors (used by UI dropdowns).
|
||||
- `POST /api/webhooks/graph` — receive Microsoft Graph change notifications. Echoes `validationToken` when present.
|
||||
- `GET /api/source-health` — last fetch status for each ingestion source (`directory`, `unified`, `intune`).
|
||||
- `PATCH /api/events/{id}/tags` — update tags on an event (e.g., `investigating`, `false_positive`).
|
||||
- `POST /api/events/{id}/comments` — add a comment to an event.
|
||||
- `GET /api/rules` — list alert rules.
|
||||
- `POST /api/rules` — create an alert rule.
|
||||
- `PUT /api/rules/{id}` — update an alert rule.
|
||||
- `DELETE /api/rules/{id}` — delete an alert rule.
|
||||
|
||||
Stored document shape (collection `micro_soc.events`):
|
||||
```json
|
||||
|
||||
16
ROADMAP.md
16
ROADMAP.md
@@ -46,16 +46,16 @@ Goal: handle larger data volumes and support real-time ingestion.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Enhance
|
||||
## Phase 4: Enhance ✅
|
||||
Goal: evolve from a polling dashboard into a full security operations tool.
|
||||
|
||||
- [ ] Migrate frontend to a maintainable framework (Vue 3, React, or HTMX + Alpine.js)
|
||||
- [ ] Add rule-based alerting (e.g., alert on privileged operations, after-hours activity)
|
||||
- [ ] Add SIEM export (Splunk, Sentinel, syslog webhook)
|
||||
- [ ] Build an audit trail for AOC itself (who queried what, who triggered fetches)
|
||||
- [ ] Add event tagging and commenting (e.g., `investigating`, `false_positive`)
|
||||
- [ ] Add export functionality (CSV / JSON) from the UI
|
||||
- [ ] Add source health dashboard showing last fetch time and status per source
|
||||
- [x] Migrate frontend to Alpine.js for better state management and maintainability
|
||||
- [x] Add rule-based alerting (e.g., alert on privileged operations, after-hours activity)
|
||||
- [x] Add SIEM export (Splunk, Sentinel, syslog webhook)
|
||||
- [x] Build an audit trail for AOC itself (who queried what, who triggered fetches)
|
||||
- [x] Add event tagging and commenting (e.g., `investigating`, `false_positive`)
|
||||
- [x] Add export functionality (CSV / JSON) from the UI
|
||||
- [x] Add source health dashboard showing last fetch time and status per source
|
||||
|
||||
---
|
||||
|
||||
|
||||
22
backend/audit_trail.py
Normal file
22
backend/audit_trail.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from database import db
|
||||
|
||||
logger = structlog.get_logger("aoc.audit")
|
||||
audit_collection = db["aoc_audit"]
|
||||
|
||||
|
||||
def log_action(action: str, resource: str, details: dict | None = None, user: str | None = None):
|
||||
"""Log an action in the AOC audit trail."""
|
||||
doc = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": action,
|
||||
"resource": resource,
|
||||
"details": details or {},
|
||||
"user": user or "anonymous",
|
||||
}
|
||||
try:
|
||||
audit_collection.insert_one(doc)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to write audit trail", error=str(exc))
|
||||
@@ -35,6 +35,13 @@ class Settings(BaseSettings):
|
||||
# CORS
|
||||
CORS_ORIGINS: str = "*"
|
||||
|
||||
# SIEM export
|
||||
SIEM_ENABLED: bool = False
|
||||
SIEM_WEBHOOK_URL: str = ""
|
||||
|
||||
# Alerting
|
||||
ALERTS_ENABLED: bool = False
|
||||
|
||||
|
||||
_settings = Settings()
|
||||
|
||||
@@ -57,3 +64,7 @@ AUTH_ALLOWED_GROUPS = {g.strip() for g in _settings.AUTH_ALLOWED_GROUPS.split(",
|
||||
|
||||
RETENTION_DAYS = _settings.RETENTION_DAYS
|
||||
CORS_ORIGINS = [o.strip() for o in _settings.CORS_ORIGINS.split(",") if o.strip()]
|
||||
|
||||
SIEM_ENABLED = _settings.SIEM_ENABLED
|
||||
SIEM_WEBHOOK_URL = _settings.SIEM_WEBHOOK_URL
|
||||
ALERTS_ENABLED = _settings.ALERTS_ENABLED
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AOC Events</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<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>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="page" x-data="aocApp()" x-init="initApp()">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Admin Operations Center</p>
|
||||
@@ -16,53 +17,76 @@
|
||||
<p class="lede">Filter Microsoft Entra audit events by user, app, time, action, and action type.</p>
|
||||
</div>
|
||||
<div class="cta">
|
||||
<button id="authBtn" class="ghost" aria-label="Login">Login</button>
|
||||
<button id="fetchBtn" aria-label="Fetch latest audit logs">Fetch new</button>
|
||||
<button id="refreshBtn" aria-label="Refresh events">Refresh</button>
|
||||
<button id="authBtn" class="ghost" aria-label="Login" x-text="authBtnText" @click="toggleAuth()"></button>
|
||||
<button id="fetchBtn" aria-label="Fetch latest audit logs" @click="fetchLogs()">Fetch new</button>
|
||||
<button id="refreshBtn" aria-label="Refresh events" @click="loadEvents(currentCursor)">Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<form id="filters" class="filters">
|
||||
<h3>Source Health</h3>
|
||||
<div class="source-health">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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" />
|
||||
<datalist id="actorOptions"></datalist>
|
||||
<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" />
|
||||
<datalist id="serviceOptions"></datalist>
|
||||
<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" />
|
||||
<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" />
|
||||
<datalist id="operationOptions"></datalist>
|
||||
<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" />
|
||||
<datalist id="resultOptions"></datalist>
|
||||
<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" />
|
||||
<input name="start" type="datetime-local" x-model="filters.start" />
|
||||
</label>
|
||||
<label>
|
||||
To
|
||||
<input name="end" type="datetime-local" />
|
||||
<input name="end" type="datetime-local" x-model="filters.end" />
|
||||
</label>
|
||||
<label>
|
||||
Limit
|
||||
<input name="limit" type="number" min="1" max="500" value="100" />
|
||||
<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">Clear</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>
|
||||
</form>
|
||||
</section>
|
||||
@@ -70,367 +94,376 @@
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Events</h2>
|
||||
<span id="count"></span>
|
||||
<span id="count" x-text="countText"></span>
|
||||
</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">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<div class="event__tags" x-show="e.tags && e.tags.length">
|
||||
<template x-for="tag in (e.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">
|
||||
<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=''" />
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
<div id="pagination" class="pagination">
|
||||
<button type="button" id="prevPage" :disabled="cursorStack.length === 0" @click="goPrev()">Prev</button>
|
||||
<span x-text="`Page ${cursorStack.length + 1}`"></span>
|
||||
<button type="button" id="nextPage" :disabled="!nextCursor" @click="goNext()">Next</button>
|
||||
</div>
|
||||
<div id="status" class="status" aria-live="polite"></div>
|
||||
<div id="events" class="events"></div>
|
||||
<div id="pagination" class="pagination"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||
<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">Close</button>
|
||||
<button type="button" id="closeModal" class="ghost" @click="modalOpen = false">Close</button>
|
||||
</div>
|
||||
<pre id="modalBody"></pre>
|
||||
<pre id="modalBody" x-text="modalBody"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
const form = document.getElementById('filters');
|
||||
const eventsContainer = document.getElementById('events');
|
||||
const status = document.getElementById('status');
|
||||
const count = document.getElementById('count');
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
const fetchBtn = document.getElementById('fetchBtn');
|
||||
const clearBtn = document.getElementById('clearBtn');
|
||||
const authBtn = document.getElementById('authBtn');
|
||||
const modal = document.getElementById('modal');
|
||||
const modalBody = document.getElementById('modalBody');
|
||||
const closeModal = document.getElementById('closeModal');
|
||||
let currentEvents = [];
|
||||
let pageSize = 50;
|
||||
let cursorStack = [];
|
||||
let nextCursor = null;
|
||||
let currentCursor = null;
|
||||
let authConfig = null;
|
||||
let msalInstance = null;
|
||||
let account = null;
|
||||
let accessToken = null;
|
||||
let authScopes = [];
|
||||
const lists = {
|
||||
actor: document.getElementById('actorOptions'),
|
||||
service: document.getElementById('serviceOptions'),
|
||||
operation: document.getElementById('operationOptions'),
|
||||
result: document.getElementById('resultOptions'),
|
||||
};
|
||||
<script>
|
||||
function aocApp() {
|
||||
return {
|
||||
events: [],
|
||||
sourceHealth: [],
|
||||
statusText: '',
|
||||
countText: '',
|
||||
cursorStack: [],
|
||||
nextCursor: null,
|
||||
currentCursor: null,
|
||||
modalOpen: false,
|
||||
modalBody: '',
|
||||
authBtnText: 'Login',
|
||||
authConfig: null,
|
||||
msalInstance: null,
|
||||
account: null,
|
||||
accessToken: null,
|
||||
authScopes: [],
|
||||
filters: {
|
||||
actor: '', service: '', search: '', operation: '', result: '', start: '', end: '', limit: 100,
|
||||
},
|
||||
options: { actors: [], services: [], operations: [], results: [] },
|
||||
|
||||
const toIso = (value) => {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
return isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
};
|
||||
async initApp() {
|
||||
await this.initAuth();
|
||||
if (!this.authConfig?.auth_enabled || this.accessToken) {
|
||||
await this.loadFilterOptions();
|
||||
await this.loadSourceHealth();
|
||||
await this.loadEvents();
|
||||
}
|
||||
},
|
||||
|
||||
async function loadEvents(cursor) {
|
||||
currentCursor = cursor || null;
|
||||
authHeader() {
|
||||
return this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {};
|
||||
},
|
||||
|
||||
pickToken(res) {
|
||||
return res ? (res.accessToken || res.idToken || null) : null;
|
||||
},
|
||||
|
||||
async initAuth() {
|
||||
try {
|
||||
const res = await fetch('/api/config/auth');
|
||||
this.authConfig = await res.json();
|
||||
} catch {
|
||||
this.authConfig = { auth_enabled: false };
|
||||
}
|
||||
|
||||
if (!this.authConfig?.auth_enabled) {
|
||||
this.authBtnText = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof msal === 'undefined' || !msal.PublicClientApplication) {
|
||||
this.statusText = 'Login library failed to load. Please check network or CDN.';
|
||||
return;
|
||||
}
|
||||
|
||||
const tenantId = this.authConfig.tenant_id;
|
||||
const clientId = this.authConfig.client_id;
|
||||
const baseScope = this.authConfig.scope || "";
|
||||
this.authScopes = Array.from(new Set(['openid', 'profile', 'email', ...baseScope.split(/[ ,]+/).filter(Boolean)]));
|
||||
const authority = `https://login.microsoftonline.com/${tenantId}`;
|
||||
const redirectUri = window.location.origin;
|
||||
|
||||
this.msalInstance = new msal.PublicClientApplication({
|
||||
auth: { clientId, authority, redirectUri },
|
||||
cache: { cacheLocation: 'sessionStorage' },
|
||||
});
|
||||
|
||||
const redirectResult = await this.msalInstance.handleRedirectPromise().catch(() => null);
|
||||
if (redirectResult) {
|
||||
this.account = redirectResult.account;
|
||||
this.msalInstance.setActiveAccount(this.account);
|
||||
this.accessToken = this.pickToken(redirectResult);
|
||||
} else {
|
||||
const accounts = this.msalInstance.getAllAccounts();
|
||||
if (accounts.length) {
|
||||
this.account = accounts[0];
|
||||
this.msalInstance.setActiveAccount(this.account);
|
||||
this.accessToken = await this.acquireToken(this.authScopes);
|
||||
}
|
||||
}
|
||||
|
||||
this.updateAuthButtons();
|
||||
},
|
||||
|
||||
async acquireToken(scopes) {
|
||||
if (!this.msalInstance || !this.account) return null;
|
||||
const request = { scopes: scopes && scopes.length ? scopes : ['openid', 'profile', 'email'], account: this.account };
|
||||
try {
|
||||
const res = await this.msalInstance.acquireTokenSilent(request);
|
||||
return this.pickToken(res);
|
||||
} catch {
|
||||
const res = await this.msalInstance.acquireTokenPopup(request);
|
||||
return this.pickToken(res);
|
||||
}
|
||||
},
|
||||
|
||||
updateAuthButtons() {
|
||||
const loggedIn = !!this.account;
|
||||
if (this.authConfig?.auth_enabled) {
|
||||
this.authBtnText = loggedIn ? 'Logout' : 'Login';
|
||||
}
|
||||
if (loggedIn) {
|
||||
this.acquireToken(this.authScopes).then((t) => { if (t) this.accessToken = t; }).catch(() => {});
|
||||
this.statusText = '';
|
||||
} else if (this.authConfig?.auth_enabled) {
|
||||
this.statusText = 'Please log in to view events.';
|
||||
}
|
||||
},
|
||||
|
||||
async toggleAuth() {
|
||||
if (!this.authConfig?.auth_enabled || !this.msalInstance) return;
|
||||
if (this.account) {
|
||||
const acc = this.msalInstance.getActiveAccount();
|
||||
this.accessToken = null;
|
||||
this.account = null;
|
||||
this.updateAuthButtons();
|
||||
if (acc) await this.msalInstance.logoutPopup({ account: acc });
|
||||
return;
|
||||
}
|
||||
const scopes = this.authScopes && this.authScopes.length ? this.authScopes : ['openid', 'profile', 'email'];
|
||||
this.statusText = 'Redirecting to sign in...';
|
||||
this.msalInstance.loginRedirect({ scopes });
|
||||
},
|
||||
|
||||
async loadEvents(cursor) {
|
||||
this.currentCursor = cursor || null;
|
||||
const params = new URLSearchParams();
|
||||
const data = new FormData(form);
|
||||
['actor', 'service', 'operation', 'result', 'search'].forEach((key) => {
|
||||
const val = data.get(key)?.trim();
|
||||
const val = this.filters[key];
|
||||
if (val) params.append(key, val);
|
||||
});
|
||||
const startIso = toIso(data.get('start'));
|
||||
const endIso = toIso(data.get('end'));
|
||||
if (startIso) params.append('start', startIso);
|
||||
if (endIso) params.append('end', endIso);
|
||||
|
||||
const limit = data.get('limit');
|
||||
if (limit) {
|
||||
pageSize = Number(limit);
|
||||
params.append('page_size', limit);
|
||||
} else {
|
||||
params.append('page_size', pageSize);
|
||||
if (this.filters.start) {
|
||||
const d = new Date(this.filters.start);
|
||||
if (!isNaN(d.getTime())) params.append('start', d.toISOString());
|
||||
}
|
||||
if (cursor) {
|
||||
params.append('cursor', cursor);
|
||||
if (this.filters.end) {
|
||||
const d = new Date(this.filters.end);
|
||||
if (!isNaN(d.getTime())) params.append('end', d.toISOString());
|
||||
}
|
||||
params.append('page_size', String(this.filters.limit || 50));
|
||||
if (cursor) params.append('cursor', cursor);
|
||||
|
||||
status.textContent = 'Loading events…';
|
||||
eventsContainer.innerHTML = '';
|
||||
count.textContent = '';
|
||||
this.statusText = 'Loading events…';
|
||||
this.countText = '';
|
||||
|
||||
if (authConfig?.auth_enabled && !accessToken) {
|
||||
status.textContent = 'Please sign in to load events.';
|
||||
if (this.authConfig?.auth_enabled && !this.accessToken) {
|
||||
this.statusText = 'Please sign in to load events.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/events?${params.toString()}`, { headers: { Accept: 'application/json', ...authHeader() } });
|
||||
if (!res.ok) {
|
||||
const msg = await res.text();
|
||||
throw new Error(`Request failed: ${res.status} ${msg}`);
|
||||
}
|
||||
const res = await fetch(`/api/events?${params.toString()}`, { headers: { Accept: 'application/json', ...this.authHeader() } });
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status} ${await res.text()}`);
|
||||
const body = await res.json();
|
||||
const events = body.items || [];
|
||||
pageSize = body.page_size || pageSize;
|
||||
nextCursor = body.next_cursor || null;
|
||||
currentEvents = events;
|
||||
renderEvents(events, body.total);
|
||||
renderPagination();
|
||||
status.textContent = events.length ? '' : 'No events found for these filters.';
|
||||
this.events = body.items || [];
|
||||
this.nextCursor = body.next_cursor || null;
|
||||
this.countText = body.total >= 0 ? `${body.total} event${body.total === 1 ? '' : 's'}` : '';
|
||||
this.statusText = this.events.length ? '' : 'No events found for these filters.';
|
||||
} catch (err) {
|
||||
status.textContent = err.message || 'Failed to load events.';
|
||||
}
|
||||
this.statusText = err.message || 'Failed to load events.';
|
||||
}
|
||||
},
|
||||
|
||||
async function fetchLogs() {
|
||||
status.textContent = 'Fetching latest audit logs…';
|
||||
if (authConfig?.auth_enabled && !accessToken) {
|
||||
status.textContent = 'Please sign in first.';
|
||||
async fetchLogs() {
|
||||
this.statusText = 'Fetching latest audit logs…';
|
||||
if (this.authConfig?.auth_enabled && !this.accessToken) {
|
||||
this.statusText = 'Please sign in first.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/fetch-audit-logs', { headers: authHeader() });
|
||||
if (!res.ok) {
|
||||
const msg = await res.text();
|
||||
throw new Error(`Fetch failed: ${res.status} ${msg}`);
|
||||
}
|
||||
const res = await fetch('/api/fetch-audit-logs', { headers: this.authHeader() });
|
||||
if (!res.ok) throw new Error(`Fetch failed: ${res.status} ${await res.text()}`);
|
||||
const body = await res.json();
|
||||
const errs = Array.isArray(body.errors) && body.errors.length ? `Warnings: ${body.errors.join(' | ')}` : '';
|
||||
status.textContent = `Fetched and stored ${body.stored_events || 0} events.${errs ? ' ' + errs : ''} Refreshing list…`;
|
||||
resetPagination();
|
||||
await loadEvents();
|
||||
this.statusText = `Fetched and stored ${body.stored_events || 0} events.${errs ? ' ' + errs : ''} Refreshing list…`;
|
||||
this.resetPagination();
|
||||
await this.loadEvents();
|
||||
await this.loadSourceHealth();
|
||||
} catch (err) {
|
||||
status.textContent = err.message || 'Failed to fetch audit logs.';
|
||||
}
|
||||
this.statusText = err.message || 'Failed to fetch audit logs.';
|
||||
}
|
||||
},
|
||||
|
||||
async function loadFilterOptions() {
|
||||
if (authConfig?.auth_enabled && !accessToken) return;
|
||||
async loadFilterOptions() {
|
||||
if (this.authConfig?.auth_enabled && !this.accessToken) return;
|
||||
try {
|
||||
const res = await fetch('/api/filter-options', { headers: authHeader() });
|
||||
const res = await fetch('/api/filter-options', { headers: this.authHeader() });
|
||||
if (!res.ok) return;
|
||||
const opts = await res.json();
|
||||
const setOptions = (el, values) => {
|
||||
if (!el) return;
|
||||
el.innerHTML = (values || []).slice(0, 200).map((v) => `<option value="${String(v)}"></option>`).join('');
|
||||
};
|
||||
setOptions(lists.actor, opts.actors);
|
||||
setOptions(lists.service, opts.services);
|
||||
setOptions(lists.operation, opts.operations);
|
||||
setOptions(lists.result, opts.results);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
this.options.actors = (opts.actors || []).slice(0, 200);
|
||||
this.options.services = (opts.services || []).slice(0, 200);
|
||||
this.options.operations = (opts.operations || []).slice(0, 200);
|
||||
this.options.results = (opts.results || []).slice(0, 200);
|
||||
} catch {}
|
||||
},
|
||||
|
||||
function renderEvents(events, total) {
|
||||
const totalText = total >= 0 ? `${total} event${total === 1 ? '' : 's'}` : '';
|
||||
count.textContent = totalText;
|
||||
eventsContainer.innerHTML = events
|
||||
.map((e, idx) => {
|
||||
const actor =
|
||||
e.actor_display ||
|
||||
e.actor_resolved?.name ||
|
||||
async loadSourceHealth() {
|
||||
try {
|
||||
const res = await fetch('/api/source-health', { headers: this.authHeader() });
|
||||
if (!res.ok) return;
|
||||
this.sourceHealth = await res.json();
|
||||
} catch {}
|
||||
},
|
||||
|
||||
resetPagination() {
|
||||
this.cursorStack = [];
|
||||
this.nextCursor = null;
|
||||
this.currentCursor = null;
|
||||
},
|
||||
|
||||
goPrev() {
|
||||
if (this.cursorStack.length) {
|
||||
const prevCursor = this.cursorStack.pop();
|
||||
this.loadEvents(prevCursor);
|
||||
}
|
||||
},
|
||||
|
||||
goNext() {
|
||||
if (this.nextCursor) {
|
||||
this.cursorStack.push(this.currentCursor);
|
||||
this.loadEvents(this.nextCursor);
|
||||
}
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.filters = { actor: '', service: '', search: '', operation: '', result: '', start: '', end: '', limit: 100 };
|
||||
this.resetPagination();
|
||||
this.loadEvents();
|
||||
},
|
||||
|
||||
displayActor(e) {
|
||||
return e.actor_display ||
|
||||
(e.actor_resolved?.name) ||
|
||||
(e.actor?.user?.displayName && e.actor?.user?.userPrincipalName && e.actor?.user?.displayName !== e.actor?.user?.userPrincipalName
|
||||
? `${e.actor.user.displayName} (${e.actor.user.userPrincipalName})`
|
||||
: (e.actor?.user?.displayName || e.actor?.user?.userPrincipalName)) ||
|
||||
e.actor?.servicePrincipal?.displayName ||
|
||||
'Unknown actor';
|
||||
const owners = Array.isArray(e.actor_owner_names) && e.actor_owner_names.length
|
||||
? `Owners: ${e.actor_owner_names.slice(0, 3).join(', ')}`
|
||||
: '';
|
||||
const time = e.timestamp ? new Date(e.timestamp).toLocaleString() : '—';
|
||||
const service = e.service || '—';
|
||||
const operation = e.operation || '—';
|
||||
const result = e.result || '—';
|
||||
const category = e.display_category || service;
|
||||
const targets = Array.isArray(e.target_displays) && e.target_displays.length
|
||||
? e.target_displays.join(', ')
|
||||
: (Array.isArray(e.targets) && e.targets.length
|
||||
? (e.targets[0].displayName || e.targets[0].id || '—')
|
||||
: '—');
|
||||
const summary = e.display_summary || '';
|
||||
const actorLabel = e.display_actor_label || 'User';
|
||||
const actorValue = e.display_actor_value || actor;
|
||||
return `
|
||||
<article class="event">
|
||||
<div class="event__meta">
|
||||
<span class="pill">${category}</span>
|
||||
<span class="pill ${result.toLowerCase() === 'success' ? 'pill--ok' : 'pill--warn'}">${result}</span>
|
||||
</div>
|
||||
<h3>${operation}</h3>
|
||||
${summary ? `<p class="event__detail"><strong>Summary:</strong> ${summary}</p>` : ''}
|
||||
<p class="event__detail"><strong>${actorLabel}:</strong> ${actorValue}</p>
|
||||
${owners ? `<p class="event__detail"><strong>App owners:</strong> ${owners}</p>` : ''}
|
||||
<p class="event__detail"><strong>Target:</strong> ${targets}</p>
|
||||
<p class="event__detail"><strong>When:</strong> ${time}</p>
|
||||
<p class="event__actions"><button class="ghost view-raw" data-idx="${idx}" type="button">View raw event</button></p>
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
},
|
||||
|
||||
eventsContainer.querySelectorAll('.view-raw').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const idx = Number(btn.dataset.idx);
|
||||
const event = currentEvents[idx];
|
||||
const raw = event?.raw || event;
|
||||
modalBody.textContent = JSON.stringify(raw, null, 2);
|
||||
modal.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
}
|
||||
displayTargets(e) {
|
||||
if (Array.isArray(e.target_displays) && e.target_displays.length) return e.target_displays.join(', ');
|
||||
if (Array.isArray(e.targets) && e.targets.length) return e.targets[0].displayName || e.targets[0].id || '—';
|
||||
return '—';
|
||||
},
|
||||
|
||||
function renderPagination() {
|
||||
const pagination = document.getElementById('pagination');
|
||||
if (!pagination) return;
|
||||
const hasPrev = cursorStack.length > 0;
|
||||
const hasNext = !!nextCursor;
|
||||
const currentPageNum = cursorStack.length + 1;
|
||||
pagination.innerHTML = `
|
||||
<button type="button" id="prevPage" ${hasPrev ? '' : 'disabled'}>Prev</button>
|
||||
<span>Page ${currentPageNum}</span>
|
||||
<button type="button" id="nextPage" ${hasNext ? '' : 'disabled'}>Next</button>
|
||||
`;
|
||||
const prev = document.getElementById('prevPage');
|
||||
const next = document.getElementById('nextPage');
|
||||
if (prev) prev.addEventListener('click', () => {
|
||||
if (cursorStack.length) {
|
||||
const prevCursor = cursorStack.pop();
|
||||
loadEvents(prevCursor);
|
||||
}
|
||||
});
|
||||
if (next) next.addEventListener('click', () => {
|
||||
if (nextCursor) {
|
||||
cursorStack.push(currentCursor);
|
||||
loadEvents(nextCursor);
|
||||
}
|
||||
});
|
||||
}
|
||||
openModal(e) {
|
||||
this.modalBody = JSON.stringify(e.raw || e, null, 2);
|
||||
this.modalOpen = true;
|
||||
},
|
||||
|
||||
function resetPagination() {
|
||||
cursorStack = [];
|
||||
nextCursor = null;
|
||||
currentCursor = null;
|
||||
}
|
||||
|
||||
function authHeader() {
|
||||
return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
|
||||
}
|
||||
|
||||
const pickToken = (res) => (res ? (res.accessToken || res.idToken || null) : null);
|
||||
|
||||
async function initAuth() {
|
||||
async addTag(e, tag) {
|
||||
if (!tag.trim()) return;
|
||||
const tags = [...(e.tags || []), tag.trim()];
|
||||
try {
|
||||
const res = await fetch('/api/config/auth');
|
||||
authConfig = await res.json();
|
||||
} catch {
|
||||
authConfig = { auth_enabled: false };
|
||||
}
|
||||
|
||||
if (!authConfig?.auth_enabled) {
|
||||
authBtn.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof msal === 'undefined' || !msal.PublicClientApplication) {
|
||||
status.textContent = 'Login library failed to load. Please check network or CDN.';
|
||||
return;
|
||||
}
|
||||
|
||||
const tenantId = authConfig.tenant_id;
|
||||
const clientId = authConfig.client_id;
|
||||
const baseScope = authConfig.scope || "";
|
||||
authScopes = Array.from(
|
||||
new Set(
|
||||
['openid', 'profile', 'email', ...baseScope.split(/[ ,]+/).filter(Boolean)]
|
||||
)
|
||||
);
|
||||
const authority = `https://login.microsoftonline.com/${tenantId}`;
|
||||
const redirectUri = window.location.origin;
|
||||
|
||||
msalInstance = new msal.PublicClientApplication({
|
||||
auth: { clientId, authority, redirectUri },
|
||||
cache: { cacheLocation: 'sessionStorage' },
|
||||
const res = await fetch(`/api/events/${e.id}/tags`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
||||
body: JSON.stringify({ tags }),
|
||||
});
|
||||
if (res.ok) e.tags = tags;
|
||||
} catch {}
|
||||
},
|
||||
|
||||
const redirectResult = await msalInstance.handleRedirectPromise().catch(() => null);
|
||||
if (redirectResult) {
|
||||
account = redirectResult.account;
|
||||
msalInstance.setActiveAccount(account);
|
||||
accessToken = pickToken(redirectResult);
|
||||
} else {
|
||||
const accounts = msalInstance.getAllAccounts();
|
||||
if (accounts.length) {
|
||||
account = accounts[0];
|
||||
msalInstance.setActiveAccount(account);
|
||||
accessToken = await acquireToken(authScopes);
|
||||
}
|
||||
}
|
||||
|
||||
updateAuthButtons();
|
||||
if (accessToken) {
|
||||
await loadFilterOptions();
|
||||
await loadEvents();
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireToken(scopes) {
|
||||
if (!msalInstance || !account) return null;
|
||||
const request = { scopes: scopes && scopes.length ? scopes : ['openid', 'profile', 'email'], account };
|
||||
async addComment(e, text) {
|
||||
if (!text.trim()) return;
|
||||
try {
|
||||
const res = await msalInstance.acquireTokenSilent(request);
|
||||
return pickToken(res);
|
||||
} catch {
|
||||
const res = await msalInstance.acquireTokenPopup(request);
|
||||
return pickToken(res);
|
||||
}
|
||||
}
|
||||
|
||||
function updateAuthButtons() {
|
||||
const loggedIn = !!account;
|
||||
if (authConfig?.auth_enabled) {
|
||||
authBtn.textContent = loggedIn ? 'Logout' : 'Login';
|
||||
}
|
||||
if (loggedIn) {
|
||||
acquireToken(authScopes).then((t) => { if (t) accessToken = t; }).catch(() => {});
|
||||
status.textContent = '';
|
||||
} else if (authConfig?.auth_enabled) {
|
||||
status.textContent = 'Please log in to view events.';
|
||||
}
|
||||
}
|
||||
|
||||
authBtn.addEventListener('click', async () => {
|
||||
if (!authConfig?.auth_enabled || !msalInstance) return;
|
||||
if (account) {
|
||||
const acc = msalInstance.getActiveAccount();
|
||||
accessToken = null;
|
||||
account = null;
|
||||
updateAuthButtons();
|
||||
if (acc) {
|
||||
await msalInstance.logoutPopup({ account: acc });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const scopes = authScopes && authScopes.length ? authScopes : ['openid', 'profile', 'email'];
|
||||
status.textContent = 'Redirecting to sign in...';
|
||||
msalInstance.loginRedirect({ scopes });
|
||||
const res = await fetch(`/api/events/${e.id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
||||
body: JSON.stringify({ text: text.trim() }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const c = await res.json();
|
||||
e.comments = [...(e.comments || []), c];
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
closeModal.addEventListener('click', () => modal.classList.add('hidden'));
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) modal.classList.add('hidden');
|
||||
});
|
||||
exportJSON() {
|
||||
const blob = new Blob([JSON.stringify(this.events, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `aoc-events-${new Date().toISOString().slice(0,10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
resetPagination();
|
||||
loadEvents();
|
||||
});
|
||||
|
||||
fetchBtn.addEventListener('click', () => fetchLogs());
|
||||
refreshBtn.addEventListener('click', () => loadEvents(currentCursor));
|
||||
|
||||
clearBtn.addEventListener('click', () => {
|
||||
form.reset();
|
||||
resetPagination();
|
||||
loadEvents();
|
||||
});
|
||||
|
||||
initAuth();
|
||||
exportCSV() {
|
||||
if (!this.events.length) return;
|
||||
const headers = ['timestamp', 'service', 'operation', 'result', 'actor_display', 'target_displays', 'display_summary'];
|
||||
const rows = this.events.map((e) => [
|
||||
e.timestamp || '',
|
||||
e.service || '',
|
||||
e.operation || '',
|
||||
e.result || '',
|
||||
(e.actor_display || '').replace(/"/g, '""'),
|
||||
(Array.isArray(e.target_displays) ? e.target_displays.join('; ') : '').replace(/"/g, '""'),
|
||||
(e.display_summary || '').replace(/"/g, '""'),
|
||||
]);
|
||||
const csv = [headers.join(','), ...rows.map((r) => r.map((c) => `"${c}"`).join(','))].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `aoc-events-${new Date().toISOString().slice(0,10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,6 +5,7 @@ from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from audit_trail import log_action
|
||||
from config import CORS_ORIGINS, ENABLE_PERIODIC_FETCH, FETCH_INTERVAL_MINUTES
|
||||
from database import setup_indexes
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
@@ -17,6 +18,8 @@ from routes.config import router as config_router
|
||||
from routes.events import router as events_router
|
||||
from routes.fetch import router as fetch_router
|
||||
from routes.fetch import run_fetch
|
||||
from routes.health import router as health_router
|
||||
from routes.rules import router as rules_router
|
||||
from routes.webhooks import router as webhooks_router
|
||||
|
||||
|
||||
@@ -66,10 +69,37 @@ async def prometheus_middleware(request: Request, call_next):
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def audit_middleware(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
if request.url.path.startswith("/api/") and request.method in ("POST", "PATCH", "PUT", "DELETE"):
|
||||
from auth import AUTH_ENABLED
|
||||
user = "anonymous"
|
||||
if AUTH_ENABLED:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
try:
|
||||
from jose import jwt
|
||||
token = auth_header.split(" ", 1)[1]
|
||||
claims = jwt.get_unverified_claims(token)
|
||||
user = claims.get("sub", "unknown")
|
||||
except Exception:
|
||||
pass
|
||||
log_action(
|
||||
action=request.method.lower(),
|
||||
resource=request.url.path,
|
||||
details={"status_code": response.status_code},
|
||||
user=user,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
app.include_router(fetch_router, prefix="/api")
|
||||
app.include_router(events_router, prefix="/api")
|
||||
app.include_router(config_router, prefix="/api")
|
||||
app.include_router(webhooks_router, prefix="/api")
|
||||
app.include_router(health_router, prefix="/api")
|
||||
app.include_router(rules_router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
@@ -17,6 +16,8 @@ class EventItem(BaseModel):
|
||||
targets: list[dict] | None = None
|
||||
raw: dict | None = None
|
||||
raw_text: str | None = None
|
||||
tags: list[str] | None = None
|
||||
comments: list[dict] | None = None
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
@@ -40,3 +41,26 @@ class FilterOptionsResponse(BaseModel):
|
||||
class FetchAuditLogsResponse(BaseModel):
|
||||
stored_events: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
class SourceHealthResponse(BaseModel):
|
||||
source: str
|
||||
last_fetch_time: str | None = None
|
||||
status: str
|
||||
|
||||
|
||||
class TagsUpdateRequest(BaseModel):
|
||||
tags: list[str]
|
||||
|
||||
|
||||
class CommentAddRequest(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
class AlertRuleResponse(BaseModel):
|
||||
id: str | None = None
|
||||
name: str
|
||||
enabled: bool
|
||||
severity: str
|
||||
conditions: list[dict]
|
||||
message: str
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import base64
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from audit_trail import log_action
|
||||
from auth import require_auth
|
||||
from bson import ObjectId
|
||||
from database import events_collection
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from models.api import FilterOptionsResponse, PaginatedEventResponse
|
||||
from models.api import (
|
||||
CommentAddRequest,
|
||||
FilterOptionsResponse,
|
||||
PaginatedEventResponse,
|
||||
TagsUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_auth)])
|
||||
|
||||
@@ -34,6 +42,7 @@ def list_events(
|
||||
search: str | None = None,
|
||||
cursor: str | None = None,
|
||||
page_size: int = Query(default=50, ge=1, le=500),
|
||||
user: dict = Depends(require_auth),
|
||||
):
|
||||
filters = []
|
||||
|
||||
@@ -85,7 +94,7 @@ def list_events(
|
||||
{
|
||||
"$or": [
|
||||
{"timestamp": {"$lt": cursor_ts}},
|
||||
{"timestamp": cursor_ts, "_id": {"$lt": cursor_oid}},
|
||||
{"timestamp": cursor_ts, "_id": {"$lt": ObjectId(cursor_oid)}},
|
||||
]
|
||||
}
|
||||
)
|
||||
@@ -112,6 +121,12 @@ def list_events(
|
||||
|
||||
for e in events:
|
||||
e["_id"] = str(e["_id"])
|
||||
|
||||
log_action("list_events", "/api/events", {"filters": {k: v for k, v in {
|
||||
"service": service, "actor": actor, "operation": operation, "result": result,
|
||||
"start": start, "end": end, "search": search, "cursor": cursor, "page_size": page_size,
|
||||
}.items() if v is not None}}, user.get("sub", "anonymous"))
|
||||
|
||||
return {
|
||||
"items": events,
|
||||
"total": total,
|
||||
@@ -122,9 +137,6 @@ def list_events(
|
||||
|
||||
@router.get("/filter-options", response_model=FilterOptionsResponse)
|
||||
def filter_options(limit: int = Query(default=200, ge=1, le=1000)):
|
||||
"""
|
||||
Provide distinct values for UI filters (best-effort, capped).
|
||||
"""
|
||||
safe_limit = max(1, min(limit, 1000))
|
||||
try:
|
||||
services = sorted(events_collection.distinct("service"))[:safe_limit]
|
||||
@@ -144,3 +156,34 @@ def filter_options(limit: int = Query(default=200, ge=1, le=1000)):
|
||||
"actor_upns": actor_upns,
|
||||
"devices": devices,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/events/{event_id}/tags")
|
||||
def update_tags(
|
||||
event_id: str,
|
||||
body: TagsUpdateRequest,
|
||||
user: dict = Depends(require_auth),
|
||||
):
|
||||
result = events_collection.update_one({"id": event_id}, {"$set": {"tags": body.tags}})
|
||||
if result.matched_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
log_action("update_tags", f"/api/events/{event_id}/tags", {"tags": body.tags}, user.get("sub", "anonymous"))
|
||||
return {"tags": body.tags}
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/comments")
|
||||
def add_comment(
|
||||
event_id: str,
|
||||
body: CommentAddRequest,
|
||||
user: dict = Depends(require_auth),
|
||||
):
|
||||
comment = {
|
||||
"text": body.text,
|
||||
"author": user.get("sub", "anonymous"),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
result = events_collection.update_one({"id": event_id}, {"$push": {"comments": comment}})
|
||||
if result.matched_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
log_action("add_comment", f"/api/events/{event_id}/comments", {"text": body.text}, user.get("sub", "anonymous"))
|
||||
return comment
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import time
|
||||
|
||||
from audit_trail import log_action
|
||||
from auth import require_auth
|
||||
from config import ALERTS_ENABLED
|
||||
from database import events_collection
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from graph.audit_logs import fetch_audit_logs
|
||||
@@ -8,6 +10,7 @@ from metrics import track_fetch, track_fetch_duration, track_fetch_error
|
||||
from models.api import FetchAuditLogsResponse
|
||||
from models.event_model import normalize_event
|
||||
from pymongo import UpdateOne
|
||||
from siem import forward_event
|
||||
from sources.intune_audit import fetch_intune_audit
|
||||
from sources.unified_audit import fetch_unified_audit
|
||||
from watermark import get_watermark, set_watermark
|
||||
@@ -52,12 +55,26 @@ def run_fetch(hours: int = 168):
|
||||
else:
|
||||
ops.append(UpdateOne({"id": doc.get("id"), "timestamp": doc.get("timestamp")}, {"$set": doc}, upsert=True))
|
||||
events_collection.bulk_write(ops, ordered=False)
|
||||
|
||||
if ALERTS_ENABLED:
|
||||
from rules import evaluate_event
|
||||
for doc in normalized:
|
||||
evaluate_event(doc)
|
||||
|
||||
for doc in normalized:
|
||||
forward_event(doc)
|
||||
|
||||
return {"stored_events": len(normalized), "errors": errors}
|
||||
|
||||
|
||||
@router.get("/fetch-audit-logs", response_model=FetchAuditLogsResponse)
|
||||
def fetch_logs(hours: int = Query(default=168, ge=1, le=720)):
|
||||
def fetch_logs(
|
||||
hours: int = Query(default=168, ge=1, le=720),
|
||||
user: dict = Depends(require_auth),
|
||||
):
|
||||
try:
|
||||
return run_fetch(hours=hours)
|
||||
result = run_fetch(hours=hours)
|
||||
log_action("fetch_audit_logs", "/api/fetch-audit-logs", {"hours": hours, "stored": result["stored_events"]}, user.get("sub", "anonymous"))
|
||||
return result
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
30
backend/routes/health.py
Normal file
30
backend/routes/health.py
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
from auth import require_auth
|
||||
from fastapi import APIRouter, Depends
|
||||
from models.api import SourceHealthResponse
|
||||
from watermark import watermarks_collection
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_auth)])
|
||||
|
||||
SOURCES = ["directory", "unified", "intune"]
|
||||
|
||||
|
||||
@router.get("/source-health", response_model=list[SourceHealthResponse])
|
||||
def source_health():
|
||||
"""Return the last known fetch status for each ingestion source."""
|
||||
results = []
|
||||
for source in SOURCES:
|
||||
doc = watermarks_collection.find_one({"source": source})
|
||||
if doc and doc.get("last_fetch_time"):
|
||||
results.append({
|
||||
"source": source,
|
||||
"last_fetch_time": doc["last_fetch_time"],
|
||||
"status": "healthy",
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
"source": source,
|
||||
"last_fetch_time": None,
|
||||
"status": "unknown",
|
||||
})
|
||||
return results
|
||||
42
backend/routes/rules.py
Normal file
42
backend/routes/rules.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from auth import require_auth
|
||||
from bson import ObjectId
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from models.api import AlertRuleResponse
|
||||
from rules import rules_collection
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
def _serialize(doc: dict) -> dict:
|
||||
doc["id"] = str(doc.pop("_id"))
|
||||
return doc
|
||||
|
||||
|
||||
@router.get("/rules", response_model=list[AlertRuleResponse])
|
||||
def list_rules():
|
||||
return [_serialize(doc) for doc in rules_collection.find()]
|
||||
|
||||
|
||||
@router.post("/rules", response_model=AlertRuleResponse)
|
||||
def create_rule(rule: AlertRuleResponse):
|
||||
payload = rule.model_dump(exclude={"id"})
|
||||
result = rules_collection.insert_one(payload)
|
||||
payload["id"] = str(result.inserted_id)
|
||||
return payload
|
||||
|
||||
|
||||
@router.put("/rules/{rule_id}", response_model=AlertRuleResponse)
|
||||
def update_rule(rule_id: str, rule: AlertRuleResponse):
|
||||
payload = rule.model_dump(exclude={"id"})
|
||||
result = rules_collection.update_one({"_id": ObjectId(rule_id)}, {"$set": payload})
|
||||
if result.matched_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
return {**payload, "id": rule_id}
|
||||
|
||||
|
||||
@router.delete("/rules/{rule_id}")
|
||||
def delete_rule(rule_id: str):
|
||||
result = rules_collection.delete_one({"_id": ObjectId(rule_id)})
|
||||
if result.deleted_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
return {"deleted": True}
|
||||
81
backend/rules.py
Normal file
81
backend/rules.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from database import db
|
||||
|
||||
logger = structlog.get_logger("aoc.rules")
|
||||
rules_collection = db["alert_rules"]
|
||||
alerts_collection = db["alerts"]
|
||||
|
||||
|
||||
def load_rules() -> list[dict]:
|
||||
return list(rules_collection.find({"enabled": True}))
|
||||
|
||||
|
||||
def evaluate_event(event: dict) -> list[dict]:
|
||||
"""Evaluate a normalized event against stored alert rules."""
|
||||
triggered = []
|
||||
rules = load_rules()
|
||||
for rule in rules:
|
||||
if _matches(rule, event):
|
||||
triggered.append(rule)
|
||||
_create_alert(rule, event)
|
||||
return triggered
|
||||
|
||||
|
||||
def _matches(rule: dict, event: dict) -> bool:
|
||||
conditions = rule.get("conditions", [])
|
||||
if not conditions:
|
||||
return False
|
||||
|
||||
for cond in conditions:
|
||||
field = cond.get("field")
|
||||
op = cond.get("op", "eq")
|
||||
value = cond.get("value")
|
||||
event_value = _get_nested(event, field)
|
||||
|
||||
if op == "eq" and event_value != value:
|
||||
return False
|
||||
if op == "neq" and event_value == value:
|
||||
return False
|
||||
if op == "contains" and (not isinstance(event_value, str) or value not in event_value):
|
||||
return False
|
||||
if op == "in" and event_value not in (value if isinstance(value, list) else [value]):
|
||||
return False
|
||||
if op == "after_hours":
|
||||
try:
|
||||
ts = datetime.fromisoformat(event.get("timestamp", "").replace("Z", "+00:00"))
|
||||
hour = ts.hour
|
||||
if 9 <= hour < 17:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _get_nested(obj: dict, path: str):
|
||||
parts = path.split(".")
|
||||
val = obj
|
||||
for p in parts:
|
||||
if isinstance(val, dict):
|
||||
val = val.get(p)
|
||||
else:
|
||||
return None
|
||||
return val
|
||||
|
||||
|
||||
def _create_alert(rule: dict, event: dict):
|
||||
alert = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"rule_id": str(rule.get("_id")),
|
||||
"rule_name": rule.get("name", "Unnamed rule"),
|
||||
"severity": rule.get("severity", "medium"),
|
||||
"event_id": event.get("id"),
|
||||
"event_dedupe_key": event.get("dedupe_key"),
|
||||
"message": rule.get("message", f"Rule '{rule.get('name')}' triggered"),
|
||||
}
|
||||
try:
|
||||
alerts_collection.insert_one(alert)
|
||||
logger.info("Alert created", rule=rule.get("name"), event_id=event.get("id"))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to create alert", error=str(exc))
|
||||
17
backend/siem.py
Normal file
17
backend/siem.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import requests
|
||||
import structlog
|
||||
from config import SIEM_ENABLED, SIEM_WEBHOOK_URL
|
||||
|
||||
logger = structlog.get_logger("aoc.siem")
|
||||
|
||||
|
||||
def forward_event(event: dict):
|
||||
"""Forward a normalized event to the configured SIEM webhook."""
|
||||
if not SIEM_ENABLED or not SIEM_WEBHOOK_URL:
|
||||
return
|
||||
try:
|
||||
res = requests.post(SIEM_WEBHOOK_URL, json=event, timeout=10)
|
||||
res.raise_for_status()
|
||||
logger.debug("Event forwarded to SIEM", event_id=event.get("id"))
|
||||
except Exception as exc:
|
||||
logger.warning("SIEM forward failed", error=str(exc))
|
||||
@@ -25,11 +25,20 @@ def client(mock_events_collection, mock_watermarks_collection, monkeypatch):
|
||||
monkeypatch.setattr("routes.fetch.events_collection", mock_events_collection)
|
||||
monkeypatch.setattr("routes.events.events_collection", mock_events_collection)
|
||||
monkeypatch.setattr("watermark.watermarks_collection", mock_watermarks_collection)
|
||||
monkeypatch.setattr("routes.health.watermarks_collection", mock_watermarks_collection)
|
||||
monkeypatch.setattr("routes.fetch.get_watermark", lambda source: None)
|
||||
monkeypatch.setattr("routes.fetch.set_watermark", lambda source, ts: None)
|
||||
monkeypatch.setattr("auth.AUTH_ENABLED", False)
|
||||
monkeypatch.setattr("database.db.command", lambda cmd: {"ok": 1} if cmd == "ping" else {})
|
||||
|
||||
# Mock audit trail and rules collections so tests don't wait on real MongoDB
|
||||
audit_client = mongomock.MongoClient()
|
||||
audit_db = audit_client["micro_soc"]
|
||||
monkeypatch.setattr("audit_trail.audit_collection", audit_db["aoc_audit"])
|
||||
monkeypatch.setattr("rules.alerts_collection", audit_db["alerts"])
|
||||
monkeypatch.setattr("rules.rules_collection", audit_db["alert_rules"])
|
||||
monkeypatch.setattr("routes.rules.rules_collection", audit_db["alert_rules"])
|
||||
|
||||
from main import app
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
@@ -40,7 +40,6 @@ def test_list_events_cursor_pagination(client, mock_events_collection):
|
||||
assert len(data["items"]) == 2
|
||||
assert data["next_cursor"] is not None
|
||||
|
||||
# Follow cursor
|
||||
response2 = client.get(f"/api/events?page_size=2&cursor={data['next_cursor']}")
|
||||
assert response2.status_code == 200
|
||||
data2 = response2.json()
|
||||
@@ -130,3 +129,79 @@ def test_graph_webhook_notification(client):
|
||||
response = client.post("/api/webhooks/graph", json=payload)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "accepted"
|
||||
|
||||
|
||||
def test_update_tags(client, mock_events_collection):
|
||||
mock_events_collection.insert_one({
|
||||
"id": "evt-tags",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"service": "Directory",
|
||||
"operation": "Add user",
|
||||
"result": "success",
|
||||
"actor_display": "Alice",
|
||||
"raw_text": "",
|
||||
})
|
||||
response = client.patch("/api/events/evt-tags/tags", json={"tags": ["investigating", "urgent"]})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["tags"] == ["investigating", "urgent"]
|
||||
doc = mock_events_collection.find_one({"id": "evt-tags"})
|
||||
assert doc["tags"] == ["investigating", "urgent"]
|
||||
|
||||
|
||||
def test_add_comment(client, mock_events_collection):
|
||||
mock_events_collection.insert_one({
|
||||
"id": "evt-comment",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"service": "Directory",
|
||||
"operation": "Add user",
|
||||
"result": "success",
|
||||
"actor_display": "Alice",
|
||||
"raw_text": "",
|
||||
})
|
||||
response = client.post("/api/events/evt-comment/comments", json={"text": "Looks suspicious"})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["text"] == "Looks suspicious"
|
||||
doc = mock_events_collection.find_one({"id": "evt-comment"})
|
||||
assert len(doc["comments"]) == 1
|
||||
assert doc["comments"][0]["text"] == "Looks suspicious"
|
||||
|
||||
|
||||
def test_source_health(client, mock_watermarks_collection):
|
||||
mock_watermarks_collection.insert_one({"source": "directory", "last_fetch_time": "2024-01-01T00:00:00Z"})
|
||||
response = client.get("/api/source-health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
directory = next((x for x in data if x["source"] == "directory"), None)
|
||||
assert directory["status"] == "healthy"
|
||||
assert directory["last_fetch_time"] == "2024-01-01T00:00:00Z"
|
||||
|
||||
|
||||
def test_rules_crud(client):
|
||||
rule = {
|
||||
"name": "After-hours admin",
|
||||
"enabled": True,
|
||||
"severity": "high",
|
||||
"conditions": [{"field": "operation", "op": "eq", "value": "Add user"}],
|
||||
"message": "Admin action outside business hours",
|
||||
}
|
||||
res = client.post("/api/rules", json=rule)
|
||||
assert res.status_code == 200
|
||||
created = res.json()
|
||||
assert created["name"] == "After-hours admin"
|
||||
|
||||
res2 = client.get("/api/rules")
|
||||
assert res2.status_code == 200
|
||||
assert len(res2.json()) == 1
|
||||
|
||||
updated = {**rule, "name": "After-hours admin updated"}
|
||||
res3 = client.put(f"/api/rules/{created['id']}", json=updated)
|
||||
assert res3.status_code == 200
|
||||
assert res3.json()["name"] == "After-hours admin updated"
|
||||
|
||||
res4 = client.delete(f"/api/rules/{created['id']}")
|
||||
assert res4.status_code == 200
|
||||
|
||||
res5 = client.get("/api/rules")
|
||||
assert res5.status_code == 200
|
||||
assert len(res5.json()) == 0
|
||||
|
||||
49
backend/tests/test_rules.py
Normal file
49
backend/tests/test_rules.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from rules import _matches, evaluate_event
|
||||
|
||||
|
||||
def test_matches_equals():
|
||||
rule = {"conditions": [{"field": "operation", "op": "eq", "value": "Add user"}]}
|
||||
event = {"operation": "Add user", "timestamp": datetime.now(UTC).isoformat()}
|
||||
assert _matches(rule, event) is True
|
||||
|
||||
|
||||
def test_matches_not_equals():
|
||||
rule = {"conditions": [{"field": "operation", "op": "neq", "value": "Delete user"}]}
|
||||
event = {"operation": "Add user", "timestamp": datetime.now(UTC).isoformat()}
|
||||
assert _matches(rule, event) is True
|
||||
|
||||
|
||||
def test_matches_contains():
|
||||
rule = {"conditions": [{"field": "actor_display", "op": "contains", "value": "Admin"}]}
|
||||
event = {"actor_display": "Admin (admin@example.com)", "timestamp": datetime.now(UTC).isoformat()}
|
||||
assert _matches(rule, event) is True
|
||||
|
||||
|
||||
def test_matches_after_hours():
|
||||
rule = {"conditions": [{"field": "timestamp", "op": "after_hours", "value": None}]}
|
||||
event = {"timestamp": "2024-01-01T22:00:00Z"}
|
||||
assert _matches(rule, event) is True
|
||||
|
||||
event2 = {"timestamp": "2024-01-01T10:00:00Z"}
|
||||
assert _matches(rule, event2) is False
|
||||
|
||||
|
||||
def test_evaluate_event_creates_alert(monkeypatch):
|
||||
from rules import alerts_collection
|
||||
|
||||
monkeypatch.setattr("rules.load_rules", lambda: [
|
||||
{"_id": "r1", "name": "Test rule", "enabled": True, "severity": "high", "conditions": [{"field": "operation", "op": "eq", "value": "Add user"}], "message": "Alert!"}
|
||||
])
|
||||
|
||||
inserted = {}
|
||||
def mock_insert(doc):
|
||||
inserted["doc"] = doc
|
||||
|
||||
monkeypatch.setattr(alerts_collection, "insert_one", mock_insert)
|
||||
|
||||
event = {"id": "e1", "operation": "Add user", "timestamp": datetime.now(UTC).isoformat(), "dedupe_key": "dk1"}
|
||||
triggered = evaluate_event(event)
|
||||
assert len(triggered) == 1
|
||||
assert inserted["doc"]["rule_name"] == "Test rule"
|
||||
@@ -4,7 +4,7 @@ line-length = 120
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "SIM"]
|
||||
ignore = ["E501"]
|
||||
ignore = ["E501", "B008"]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
Reference in New Issue
Block a user