Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86966bb57f | |||
| 3761aa6d74 | |||
| 6d00d7cf32 | |||
| de9ea45e1e | |||
| bade860fd4 | |||
| 9f4601c4d9 |
22
.gitea/workflows/release.yml
Normal file
22
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login git.cqre.net -u ${{ gitea.actor }} --password-stdin
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: docker build ./backend --tag git.cqre.net/cqrenet/aoc-backend:${{ gitea.ref_name }}
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
run: docker push git.cqre.net/cqrenet/aoc-backend:${{ gitea.ref_name }}
|
||||||
55
.github/workflows/release.yml
vendored
55
.github/workflows/release.yml
vendored
@@ -1,55 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
IMAGE_NAME: ${{ github.repository }}-backend
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
attestations: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
id: push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: ./backend
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
|
|
||||||
- name: Generate artifact attestation
|
|
||||||
uses: actions/attest-build-provenance@v1
|
|
||||||
with:
|
|
||||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
subject-digest: ${{ steps.push.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AOC Events</title>
|
<title>AOC Events</title>
|
||||||
<link rel="stylesheet" href="/style.css?v=7" />
|
<link rel="stylesheet" href="/style.css?v=8" />
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<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>
|
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -76,12 +76,20 @@
|
|||||||
To
|
To
|
||||||
<input name="end" type="datetime-local" x-model="filters.end" />
|
<input name="end" type="datetime-local" x-model="filters.end" />
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Include tags
|
||||||
|
<input name="includeTags" type="text" placeholder="backup, critical" x-model="filters.includeTags" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Exclude tags
|
||||||
|
<input name="excludeTags" type="text" placeholder="noise, auto" x-model="filters.excludeTags" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="filter-row">
|
||||||
<label class="span-2">
|
<label class="span-2">
|
||||||
Search (raw/full-text)
|
Search (raw/full-text)
|
||||||
<input name="search" type="text" placeholder="Any text to search in raw/summary" x-model="filters.search" />
|
<input name="search" type="text" placeholder="Any text to search in raw/summary" x-model="filters.search" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
<div class="filter-row filter-row--tall">
|
|
||||||
<div class="filter-group span-2">
|
<div class="filter-group span-2">
|
||||||
<span>App / Service</span>
|
<span>App / Service</span>
|
||||||
<div class="multi-select">
|
<div class="multi-select">
|
||||||
@@ -104,6 +112,7 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit">Apply filters</button>
|
<button type="submit">Apply filters</button>
|
||||||
<button type="button" id="clearBtn" class="ghost" @click="clearFilters()">Clear</button>
|
<button type="button" id="clearBtn" class="ghost" @click="clearFilters()">Clear</button>
|
||||||
|
<button type="button" class="ghost" @click="bulkTagMatching()">Bulk tag matching</button>
|
||||||
<button type="button" class="ghost" @click="exportJSON()">Export JSON</button>
|
<button type="button" class="ghost" @click="exportJSON()">Export JSON</button>
|
||||||
<button type="button" class="ghost" @click="exportCSV()">Export CSV</button>
|
<button type="button" class="ghost" @click="exportCSV()">Export CSV</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,7 +197,7 @@
|
|||||||
accessToken: null,
|
accessToken: null,
|
||||||
authScopes: [],
|
authScopes: [],
|
||||||
filters: {
|
filters: {
|
||||||
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 100,
|
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 100, includeTags: '', excludeTags: '',
|
||||||
},
|
},
|
||||||
options: { actors: [], services: [], operations: [], results: [] },
|
options: { actors: [], services: [], operations: [], results: [] },
|
||||||
|
|
||||||
@@ -320,6 +329,12 @@
|
|||||||
if (this.filters.selectedServices && this.filters.selectedServices.length) {
|
if (this.filters.selectedServices && this.filters.selectedServices.length) {
|
||||||
this.filters.selectedServices.forEach((s) => params.append('services', s));
|
this.filters.selectedServices.forEach((s) => params.append('services', s));
|
||||||
}
|
}
|
||||||
|
if (this.filters.includeTags) {
|
||||||
|
this.filters.includeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('include_tags', t));
|
||||||
|
}
|
||||||
|
if (this.filters.excludeTags) {
|
||||||
|
this.filters.excludeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('exclude_tags', t));
|
||||||
|
}
|
||||||
if (this.filters.start) {
|
if (this.filters.start) {
|
||||||
const d = new Date(this.filters.start);
|
const d = new Date(this.filters.start);
|
||||||
if (!isNaN(d.getTime())) params.append('start', d.toISOString());
|
if (!isNaN(d.getTime())) params.append('start', d.toISOString());
|
||||||
@@ -417,11 +432,53 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearFilters() {
|
clearFilters() {
|
||||||
this.filters = { actor: '', selectedServices: [...this.options.services], search: '', operation: '', result: '', start: '', end: '', limit: 100 };
|
this.filters = { actor: '', selectedServices: [...this.options.services], search: '', operation: '', result: '', start: '', end: '', limit: 100, includeTags: '', excludeTags: '' };
|
||||||
this.resetPagination();
|
this.resetPagination();
|
||||||
this.loadEvents();
|
this.loadEvents();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async bulkTagMatching() {
|
||||||
|
const tag = prompt('Enter tag to apply to all matching events:');
|
||||||
|
if (!tag || !tag.trim()) return;
|
||||||
|
const mode = confirm('Click OK to REPLACE existing tags.\nClick Cancel to APPEND the new tag.') ? 'replace' : 'append';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
['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.includeTags) {
|
||||||
|
this.filters.includeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('include_tags', t));
|
||||||
|
}
|
||||||
|
if (this.filters.excludeTags) {
|
||||||
|
this.filters.excludeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('exclude_tags', t));
|
||||||
|
}
|
||||||
|
if (this.filters.start) {
|
||||||
|
const d = new Date(this.filters.start);
|
||||||
|
if (!isNaN(d.getTime())) params.append('start', d.toISOString());
|
||||||
|
}
|
||||||
|
if (this.filters.end) {
|
||||||
|
const d = new Date(this.filters.end);
|
||||||
|
if (!isNaN(d.getTime())) params.append('end', d.toISOString());
|
||||||
|
}
|
||||||
|
this.statusText = 'Applying bulk tag…';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/events/bulk-tags?${params.toString()}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
||||||
|
body: JSON.stringify({ tags: [tag.trim()], mode }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const body = await res.json();
|
||||||
|
this.statusText = `Tagged ${body.matched} events (${body.modified} modified).`;
|
||||||
|
await this.loadEvents();
|
||||||
|
} catch (err) {
|
||||||
|
this.statusText = err.message || 'Failed to apply bulk tag.';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
displayActor(e) {
|
displayActor(e) {
|
||||||
const app = e.actor?.application || e.actor?.app;
|
const app = e.actor?.application || e.actor?.app;
|
||||||
if (app?.displayName) return app.displayName;
|
if (app?.displayName) return app.displayName;
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ class TagsUpdateRequest(BaseModel):
|
|||||||
tags: list[str]
|
tags: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class BulkTagsRequest(BaseModel):
|
||||||
|
tags: list[str]
|
||||||
|
mode: str = "append" # "append" or "replace"
|
||||||
|
|
||||||
|
|
||||||
class CommentAddRequest(BaseModel):
|
class CommentAddRequest(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from bson import ObjectId
|
|||||||
from database import events_collection
|
from database import events_collection
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from models.api import (
|
from models.api import (
|
||||||
|
BulkTagsRequest,
|
||||||
CommentAddRequest,
|
CommentAddRequest,
|
||||||
FilterOptionsResponse,
|
FilterOptionsResponse,
|
||||||
PaginatedEventResponse,
|
PaginatedEventResponse,
|
||||||
@@ -31,10 +32,9 @@ def _decode_cursor(cursor: str) -> tuple[str, str]:
|
|||||||
raise HTTPException(status_code=400, detail="Invalid cursor") from exc
|
raise HTTPException(status_code=400, detail="Invalid cursor") from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get("/events", response_model=PaginatedEventResponse)
|
def _build_query(
|
||||||
def list_events(
|
|
||||||
service: str | None = None,
|
service: str | None = None,
|
||||||
services: list[str] | None = Query(default=None),
|
services: list[str] | None = None,
|
||||||
actor: str | None = None,
|
actor: str | None = None,
|
||||||
operation: str | None = None,
|
operation: str | None = None,
|
||||||
result: str | None = None,
|
result: str | None = None,
|
||||||
@@ -42,9 +42,9 @@ def list_events(
|
|||||||
end: str | None = None,
|
end: str | None = None,
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
page_size: int = Query(default=50, ge=1, le=500),
|
include_tags: list[str] | None = None,
|
||||||
user: dict = Depends(require_auth),
|
exclude_tags: list[str] | None = None,
|
||||||
):
|
) -> dict:
|
||||||
filters = []
|
filters = []
|
||||||
|
|
||||||
if service:
|
if service:
|
||||||
@@ -87,6 +87,10 @@ def list_events(
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if include_tags:
|
||||||
|
filters.append({"tags": {"$all": include_tags}})
|
||||||
|
if exclude_tags:
|
||||||
|
filters.append({"tags": {"$not": {"$all": exclude_tags}}})
|
||||||
|
|
||||||
if cursor:
|
if cursor:
|
||||||
try:
|
try:
|
||||||
@@ -102,7 +106,38 @@ def list_events(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
query = {"$and": filters} if filters else {}
|
return {"$and": filters} if filters else {}
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
start: str | None = None,
|
||||||
|
end: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
page_size: int = Query(default=50, ge=1, le=500),
|
||||||
|
include_tags: list[str] | None = Query(default=None),
|
||||||
|
exclude_tags: list[str] | None = Query(default=None),
|
||||||
|
user: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
query = _build_query(
|
||||||
|
service=service,
|
||||||
|
services=services,
|
||||||
|
actor=actor,
|
||||||
|
operation=operation,
|
||||||
|
result=result,
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
search=search,
|
||||||
|
cursor=cursor,
|
||||||
|
include_tags=include_tags,
|
||||||
|
exclude_tags=exclude_tags,
|
||||||
|
)
|
||||||
|
|
||||||
safe_page_size = max(1, min(page_size, 500))
|
safe_page_size = max(1, min(page_size, 500))
|
||||||
|
|
||||||
@@ -138,6 +173,51 @@ def list_events(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events/bulk-tags")
|
||||||
|
def bulk_tags(
|
||||||
|
body: BulkTagsRequest,
|
||||||
|
service: str | None = None,
|
||||||
|
services: list[str] | None = Query(default=None),
|
||||||
|
actor: str | None = None,
|
||||||
|
operation: str | None = None,
|
||||||
|
result: str | None = None,
|
||||||
|
start: str | None = None,
|
||||||
|
end: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
include_tags: list[str] | None = Query(default=None),
|
||||||
|
exclude_tags: list[str] | None = Query(default=None),
|
||||||
|
user: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
query = _build_query(
|
||||||
|
service=service,
|
||||||
|
services=services,
|
||||||
|
actor=actor,
|
||||||
|
operation=operation,
|
||||||
|
result=result,
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
search=search,
|
||||||
|
include_tags=include_tags,
|
||||||
|
exclude_tags=exclude_tags,
|
||||||
|
)
|
||||||
|
tags = [t.strip() for t in body.tags if t.strip()]
|
||||||
|
if not tags:
|
||||||
|
raise HTTPException(status_code=400, detail="No tags provided")
|
||||||
|
|
||||||
|
if body.mode == "replace":
|
||||||
|
update = {"$set": {"tags": tags}}
|
||||||
|
else:
|
||||||
|
update = {"$addToSet": {"tags": {"$each": tags}}}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result_obj = events_collection.update_many(query, update)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to update tags: {exc}") from exc
|
||||||
|
|
||||||
|
log_action("bulk_tags", "/api/events/bulk-tags", {"tags": tags, "mode": body.mode, "matched": result_obj.matched_count}, user.get("sub", "anonymous"))
|
||||||
|
return {"matched": result_obj.matched_count, "modified": result_obj.modified_count}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/filter-options", response_model=FilterOptionsResponse)
|
@router.get("/filter-options", response_model=FilterOptionsResponse)
|
||||||
def filter_options(limit: int = Query(default=200, ge=1, le=1000)):
|
def filter_options(limit: int = Query(default=200, ge=1, le=1000)):
|
||||||
safe_limit = max(1, min(limit, 1000))
|
safe_limit = max(1, min(limit, 1000))
|
||||||
|
|||||||
@@ -241,3 +241,68 @@ def test_rules_crud(client):
|
|||||||
res5 = client.get("/api/rules")
|
res5 = client.get("/api/rules")
|
||||||
assert res5.status_code == 200
|
assert res5.status_code == 200
|
||||||
assert len(res5.json()) == 0
|
assert len(res5.json()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_events_filter_by_include_tags(client, mock_events_collection):
|
||||||
|
mock_events_collection.insert_one({
|
||||||
|
"id": "evt-tagged",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Directory",
|
||||||
|
"operation": "Add user",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Alice",
|
||||||
|
"raw_text": "",
|
||||||
|
"tags": ["backup", "auto"],
|
||||||
|
})
|
||||||
|
mock_events_collection.insert_one({
|
||||||
|
"id": "evt-untagged",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Directory",
|
||||||
|
"operation": "Remove user",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Bob",
|
||||||
|
"raw_text": "",
|
||||||
|
"tags": [],
|
||||||
|
})
|
||||||
|
response = client.get("/api/events?include_tags=backup")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["items"]) == 1
|
||||||
|
assert data["items"][0]["id"] == "evt-tagged"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_tags_append(client, mock_events_collection):
|
||||||
|
mock_events_collection.insert_one({
|
||||||
|
"id": "evt-bulk",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Exchange",
|
||||||
|
"operation": "Update",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Alice",
|
||||||
|
"raw_text": "",
|
||||||
|
"tags": ["existing"],
|
||||||
|
})
|
||||||
|
response = client.post("/api/events/bulk-tags?service=Exchange", json={"tags": ["backup"], "mode": "append"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["matched"] == 1
|
||||||
|
doc = mock_events_collection.find_one({"id": "evt-bulk"})
|
||||||
|
assert "backup" in doc["tags"]
|
||||||
|
assert "existing" in doc["tags"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_tags_replace(client, mock_events_collection):
|
||||||
|
mock_events_collection.insert_one({
|
||||||
|
"id": "evt-bulk2",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Exchange",
|
||||||
|
"operation": "Update",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Alice",
|
||||||
|
"raw_text": "",
|
||||||
|
"tags": ["old"],
|
||||||
|
})
|
||||||
|
response = client.post("/api/events/bulk-tags?service=Exchange", json={"tags": ["backup"], "mode": "replace"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
doc = mock_events_collection.find_one({"id": "evt-bulk2"})
|
||||||
|
assert doc["tags"] == ["backup"]
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
|
|
||||||
backend:
|
backend:
|
||||||
# For local development you can switch back to: build: ./backend
|
# For local development you can switch back to: build: ./backend
|
||||||
image: ghcr.io/cqrenet/aoc-backend:v1.0.1
|
image: git.cqre.net/cqrenet/aoc-backend:v1.0.3
|
||||||
container_name: aoc-backend
|
container_name: aoc-backend
|
||||||
restart: always
|
restart: always
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
Reference in New Issue
Block a user