Add audit maintenance, verified recovery and reproducible verification
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e6f1-443f-7783-9920-a16b2ffc467f
This commit is contained in:
parent
820f0ff7b5
commit
3166da1d2e
13 changed files with 413 additions and 5 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -10,3 +10,6 @@ __pycache__/
|
||||||
*.db-journal
|
*.db-journal
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
|
||||||
|
.venv-verify/
|
||||||
|
.cache/
|
||||||
|
|
|
||||||
13
README.md
13
README.md
|
|
@ -30,7 +30,15 @@ carries and surfaces their output.
|
||||||
Quality dimension, Q2 Observability, as recorded in INTENT/SCOPE. Monitoring
|
Quality dimension, Q2 Observability, as recorded in INTENT/SCOPE. Monitoring
|
||||||
workload packaging remains outside this repo.
|
workload packaging remains outside this repo.
|
||||||
|
|
||||||
Run `python3 -m unittest discover -s tests -v` (Python 3.11+, no dependencies).
|
Run `python3 -m unittest discover -s tests -v` for the dependency-free core
|
||||||
|
suite (native receiver check is optional there). For the full core/runtime and
|
||||||
|
native owner-contract suite, run **`bash scripts/verify.sh`** with Python 3.11+,
|
||||||
|
uv, Go and sibling `audit-core` / `flex-auth` checkouts. The script installs the
|
||||||
|
hash-locked runtime dependencies in `.venv-verify`, builds Flex Auth into
|
||||||
|
`.cache/verify`, and fails if either native source is absent. Override checkout
|
||||||
|
locations with `RTEL_AUDIT_CORE_SOURCE` and `RTEL_FLEX_AUTH_SOURCE`. It reports
|
||||||
|
the tested owner revisions; those source checkouts are not fetched or changed.
|
||||||
|
This command tests local contracts, not live infrastructure or Prometheus rules.
|
||||||
Live acceptance is tracked by RTEL-WP-0002-T04; local receipts are not external
|
Live acceptance is tracked by RTEL-WP-0002-T04; local receipts are not external
|
||||||
operator delivery proof.
|
operator delivery proof.
|
||||||
|
|
||||||
|
|
@ -51,3 +59,6 @@ not activated. Identity/policy adapters are implemented; native package admissio
|
||||||
webhook/audit custody and audited alert confirmation remain under T04. SMTP
|
webhook/audit custody and audited alert confirmation remain under T04. SMTP
|
||||||
custody and delivery are verified, including Bernd’s confirmed inbox receipt
|
custody and delivery are verified, including Bernd’s confirmed inbox receipt
|
||||||
of the transport-test email.
|
of the transport-test email.
|
||||||
|
|
||||||
|
[Maintenance and recovery](docs/acknowledgment-operations.md) covers private audit
|
||||||
|
metrics, explicit event requeue and verified backup/restore to a new database.
|
||||||
|
|
|
||||||
71
docs/acknowledgment-operations.md
Normal file
71
docs/acknowledgment-operations.md
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# Acknowledgment maintenance and recovery
|
||||||
|
|
||||||
|
These operations belong to RTEL-WP-0002-T04. Run with the database owner's OS
|
||||||
|
identity, against a private owned directory (0700) and database (0600). No
|
||||||
|
credential or browser login is needed for filesystem administration. This is
|
||||||
|
not a public HTTP administration interface. Database access confers access to
|
||||||
|
acknowledgment evidence; restrict it accordingly.
|
||||||
|
|
||||||
|
## Inspect and retry audit delivery
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/alert_admin.py --database /state/private/ack.sqlite status
|
||||||
|
python3 scripts/alert_admin.py --database /state/private/ack.sqlite list-blocked
|
||||||
|
python3 scripts/alert_admin.py --database /state/private/ack.sqlite requeue --event-id EVENT_UUID
|
||||||
|
```
|
||||||
|
|
||||||
|
Status reports pending, blocked and delivered counts. Listing returns at most
|
||||||
|
100 blocked event IDs (override with `--limit`, maximum 1000); it never prints
|
||||||
|
actor identities, credentials or event payloads. Repair the receiver schema,
|
||||||
|
authorization or custody fault before explicitly requeuing a particular event.
|
||||||
|
Requeue only changes blocked → pending. The running worker retries within its
|
||||||
|
next pass using the original event ID and exact payload. It cannot rewrite an
|
||||||
|
acknowledgment or declare delivery successful. Exit 2 means the selected event
|
||||||
|
was not blocked (including unknown or already delivered); exit 1 is a refusal.
|
||||||
|
|
||||||
|
Private GET `/metrics` exports:
|
||||||
|
|
||||||
|
- `railiance_ack_audit_events{status="pending|blocked|delivered"}` (all three
|
||||||
|
series, including zero; the label takes one of the three individual values).
|
||||||
|
- `railiance_ack_audit_last_drain_timestamp_seconds` (zero before the first pass).
|
||||||
|
|
||||||
|
No actor, alert or event IDs are metric labels. A completed local drain does not
|
||||||
|
prove an idle audit receiver is reachable. Keep this endpoint on the private
|
||||||
|
Service: the public Ingress remains `/ack/` only. Package integration must admit
|
||||||
|
the Prometheus scraper through network policy and configure its scrape and
|
||||||
|
alerts. Alert on blocked debt, sustained pending debt and a stale worker;
|
||||||
|
independent receiver readback/reconciliation remains an activation gate.
|
||||||
|
|
||||||
|
## Backup and restore
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/alert_admin.py --database /state/private/ack.sqlite backup --destination /backup/private/ack-snapshot.sqlite
|
||||||
|
python3 scripts/alert_admin.py --database /backup/private/ack-snapshot.sqlite restore --destination /state/private/ack-restored.sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
Create destination directories beforehand with mode 0700 and the service's
|
||||||
|
owner. Backup uses SQLite's online backup API, so it captures committed
|
||||||
|
acknowledgments and their outbox atomically even while the service is running.
|
||||||
|
A successful command validates SQLite integrity, foreign keys, required
|
||||||
|
immutability triggers and acknowledgment/outbox correspondence, then fsyncs the
|
||||||
|
snapshot. Existing destinations, symlinks and unsafe permissions are refused.
|
||||||
|
Treat interrupted commands as unverified; retain and inspect their destination
|
||||||
|
before choosing a new path. These checks detect structural problems, not
|
||||||
|
malicious alteration by an administrator.
|
||||||
|
|
||||||
|
For recovery, stop the runtime and retain the current database first. Restore
|
||||||
|
to a **new path**, change the package configuration to that path, then restart.
|
||||||
|
Do not run original and restored databases as competing writers. Credentials
|
||||||
|
and sessions are not in this database; users sign in again. Acknowledgments made
|
||||||
|
after the selected backup are outside its recovery point: reconcile with
|
||||||
|
independent audit evidence before opening the recovered service. A snapshot
|
||||||
|
alone cannot reconstruct those later acknowledgments.
|
||||||
|
|
||||||
|
The restored store preserves pending, blocked and delivered states, original
|
||||||
|
actors/decisions and event IDs. Already delivered events are not resent; an
|
||||||
|
event accepted by audit-core whose reply was lost is replayed with its same ID
|
||||||
|
and deduplicated by the receiver. Repeated human confirmation preserves the
|
||||||
|
first acknowledgment. Tests exercise both cases, including real audit-core.
|
||||||
|
|
||||||
|
Off-host copying, retention, encryption, backup scheduling and a native restore
|
||||||
|
drill remain package/platform responsibilities under the existing task.
|
||||||
|
|
@ -159,9 +159,14 @@ email does not establish those remaining facts.
|
||||||
Runtime validation (hash-pinned dependencies in requirements-runtime.lock):
|
Runtime validation (hash-pinned dependencies in requirements-runtime.lock):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
RTEL_FLEX_AUTH_BINARY=/tmp/rtel-flex-auth /tmp/rtel-ack-venv/bin/python -m unittest discover -s tests_runtime -v
|
bash scripts/verify.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The candidate workload, container recipe and runtime settings are owned by
|
The candidate workload, container recipe and runtime settings are owned by
|
||||||
`rapp-telemetry/acknowledgment`. The policy source and exact client registration
|
`rapp-telemetry/acknowledgment`. The policy source and exact client registration
|
||||||
are in `integration/`; they are not active registrations.
|
are in `integration/`; they are not active registrations.
|
||||||
|
|
||||||
|
See [maintenance and recovery](acknowledgment-operations.md) for aggregate
|
||||||
|
metrics, explicit blocked-event retries, and backup/restore. The full verification
|
||||||
|
command requires the documented sibling owner checkouts; it never silently skips
|
||||||
|
the native contract tests.
|
||||||
|
|
|
||||||
23
history/2026-09-28-acknowledgment-operations.md
Normal file
23
history/2026-09-28-acknowledgment-operations.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Acknowledgment operations closure — 2026-09-28
|
||||||
|
|
||||||
|
Existing RTEL-WP-0002-T04, no new task/workplan. Added private Prometheus metrics
|
||||||
|
for pending/blocked/delivered outbox counts and last completed drain. No event
|
||||||
|
or actor labels. Database failures in the drain are retried instead of killing
|
||||||
|
the worker. Idle receiver health is explicitly not inferred from a drain.
|
||||||
|
|
||||||
|
Local admin CLI provides counts, bounded blocked-event IDs, explicit requeue and
|
||||||
|
verified SQLite backup/restore to a new path. No overwrite or public admin API.
|
||||||
|
Existing acknowledgment/event bytes are preserved by retry and restore.
|
||||||
|
|
||||||
|
Full verification command: bash scripts/verify.sh. Hash-locked runtime packages
|
||||||
|
installed into .venv-verify, native Flex Auth built in .cache/verify. Native owner
|
||||||
|
checkouts required; missing dependencies fail rather than silently skip tests.
|
||||||
|
37 core + 8 runtime tests pass. Actual audit-core receiver accepted an event,
|
||||||
|
lost its response, then deduplicated replay after backup and restore. Snapshot
|
||||||
|
tests retain all delivery states, repeated confirmation identity and immutable
|
||||||
|
triggers; unsafe paths and overwrite are refused.
|
||||||
|
|
||||||
|
Tested audit-core 3e42ca8ffb2ead14fd52f9a6f9f42800f1dc2771 and Flex Auth
|
||||||
|
2dfee5782ec9010758af9ba5a6f72d9fa0fe6234. No new live acknowledgment activation;
|
||||||
|
remaining native identity/PDP/audit, scrape, off-host and recipient gates stay
|
||||||
|
in the existing blocked workplan. Package image updated separately.
|
||||||
|
|
@ -214,6 +214,19 @@ class Store:
|
||||||
return {row['status']: row['n'] for row in db.execute(
|
return {row['status']: row['n'] for row in db.execute(
|
||||||
"SELECT status,COUNT(*) AS n FROM outbox WHERE status!='delivered' GROUP BY status")}
|
"SELECT status,COUNT(*) AS n FROM outbox WHERE status!='delivered' GROUP BY status")}
|
||||||
|
|
||||||
|
def audit_metrics(self, last_drain):
|
||||||
|
"""Private aggregate metrics: no event, actor, or alert labels."""
|
||||||
|
with self.connect() as db:
|
||||||
|
counts = dict(db.execute('SELECT status,COUNT(*) FROM outbox GROUP BY status'))
|
||||||
|
lines = ['# HELP railiance_ack_audit_events Stored audit events by delivery state.',
|
||||||
|
'# TYPE railiance_ack_audit_events gauge']
|
||||||
|
for status in ('pending', 'blocked', 'delivered'):
|
||||||
|
lines.append(f'railiance_ack_audit_events{{status="{status}"}} {counts.get(status, 0)}')
|
||||||
|
lines += ['# HELP railiance_ack_audit_last_drain_timestamp_seconds Last completed local outbox pass; not receiver reachability.',
|
||||||
|
'# TYPE railiance_ack_audit_last_drain_timestamp_seconds gauge',
|
||||||
|
f'railiance_ack_audit_last_drain_timestamp_seconds {float(last_drain)}']
|
||||||
|
return '\n'.join(lines) + '\n'
|
||||||
|
|
||||||
|
|
||||||
class Application:
|
class Application:
|
||||||
"""WSGI adapter. authenticate and authorize are required, never default-allow.
|
"""WSGI adapter. authenticate and authorize are required, never default-allow.
|
||||||
|
|
|
||||||
130
scripts/alert_admin.py
Normal file
130
scripts/alert_admin.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Local maintenance of the private acknowledgment store; no HTTP admin route."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
import stat
|
||||||
|
|
||||||
|
from alert_ack import Store
|
||||||
|
|
||||||
|
TRIGGERS = {'ack_immutable_update', 'ack_immutable_delete', 'outbox_body_immutable',
|
||||||
|
'outbox_immutable_delete', 'alert_immutable_update', 'alert_immutable_delete'}
|
||||||
|
|
||||||
|
|
||||||
|
def private_path(path, existing=True):
|
||||||
|
path = Path(path).absolute()
|
||||||
|
parent = path.parent.lstat()
|
||||||
|
if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.getuid() or parent.st_mode & 0o077:
|
||||||
|
raise ValueError('private owned directory required')
|
||||||
|
if existing:
|
||||||
|
info = path.lstat()
|
||||||
|
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
|
||||||
|
or info.st_nlink != 1 or info.st_mode & 0o077):
|
||||||
|
raise ValueError('private owned database required')
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def readonly(path):
|
||||||
|
return sqlite3.connect(private_path(path).as_uri() + '?mode=ro', uri=True)
|
||||||
|
|
||||||
|
|
||||||
|
def validate(db):
|
||||||
|
if db.execute('PRAGMA integrity_check').fetchall() != [('ok',)] or db.execute('PRAGMA foreign_key_check').fetchall():
|
||||||
|
raise ValueError('database integrity check failed')
|
||||||
|
triggers = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='trigger'")}
|
||||||
|
if not TRIGGERS <= triggers:
|
||||||
|
raise ValueError('acknowledgment schema required')
|
||||||
|
# Every durable acknowledgment has exactly its original outbox event.
|
||||||
|
if db.execute('''SELECT COUNT(*) FROM acknowledgments a LEFT JOIN outbox o
|
||||||
|
ON a.event_id=o.event_id WHERE o.event_id IS NULL''').fetchone()[0]:
|
||||||
|
raise ValueError('incomplete audit outbox')
|
||||||
|
if db.execute('''SELECT COUNT(*) FROM outbox o LEFT JOIN acknowledgments a
|
||||||
|
ON a.event_id=o.event_id WHERE a.event_id IS NULL
|
||||||
|
OR o.status NOT IN ('pending','blocked','delivered')''').fetchone()[0]:
|
||||||
|
raise ValueError('invalid audit outbox')
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(source, destination):
|
||||||
|
"""SQLite online backup, never overwrite; restore uses the same fresh-target path.
|
||||||
|
|
||||||
|
Source may be live for backup. Restore to a NEW path, then switch the stopped
|
||||||
|
service to it. Neither operation rewinds or replaces an existing database.
|
||||||
|
"""
|
||||||
|
target = private_path(destination, existing=False)
|
||||||
|
source_db = readonly(source)
|
||||||
|
created = False
|
||||||
|
try:
|
||||||
|
fd = os.open(target, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
|
||||||
|
os.close(fd)
|
||||||
|
created = True
|
||||||
|
target_db = sqlite3.connect(target)
|
||||||
|
try:
|
||||||
|
source_db.backup(target_db)
|
||||||
|
validate(target_db)
|
||||||
|
finally:
|
||||||
|
target_db.close()
|
||||||
|
with target.open('rb') as saved:
|
||||||
|
os.fsync(saved.fileno())
|
||||||
|
directory = os.open(target.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
os.fsync(directory)
|
||||||
|
finally:
|
||||||
|
os.close(directory)
|
||||||
|
except Exception:
|
||||||
|
if created:
|
||||||
|
target.unlink()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
source_db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--database', required=True, type=Path)
|
||||||
|
commands = parser.add_subparsers(dest='action', required=True)
|
||||||
|
commands.add_parser('status')
|
||||||
|
listing = commands.add_parser('list-blocked')
|
||||||
|
listing.add_argument('--limit', type=int, default=100)
|
||||||
|
retry = commands.add_parser('requeue')
|
||||||
|
retry.add_argument('--event-id', required=True)
|
||||||
|
for action in ('backup', 'restore'):
|
||||||
|
command = commands.add_parser(action)
|
||||||
|
command.add_argument('--destination', type=Path, required=True)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
if args.action in ('backup', 'restore'):
|
||||||
|
snapshot(args.database, args.destination)
|
||||||
|
result = {'status': 'verified', 'action': args.action}
|
||||||
|
else:
|
||||||
|
db = readonly(args.database)
|
||||||
|
try:
|
||||||
|
validate(db)
|
||||||
|
counts = dict(db.execute('SELECT status,COUNT(*) FROM outbox GROUP BY status'))
|
||||||
|
if args.action == 'status':
|
||||||
|
result = {'outbox': {s: counts.get(s, 0) for s in ('pending','blocked','delivered')}}
|
||||||
|
elif args.action == 'list-blocked':
|
||||||
|
if not 1 <= args.limit <= 1000:
|
||||||
|
raise ValueError('limit must be 1..1000')
|
||||||
|
result = {'event_ids': [r[0] for r in db.execute(
|
||||||
|
"SELECT event_id FROM outbox WHERE status='blocked' ORDER BY event_id LIMIT ?", (args.limit,))]}
|
||||||
|
else:
|
||||||
|
# Requeue only, never manufacture delivery or change evidence.
|
||||||
|
store = Store(args.database)
|
||||||
|
changed = store.requeue(args.event_id)
|
||||||
|
result = {'event_id': args.event_id, 'requeued': changed}
|
||||||
|
if not changed:
|
||||||
|
print(json.dumps(result))
|
||||||
|
return 2
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
print(json.dumps(result, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
except (OSError, ValueError, sqlite3.Error):
|
||||||
|
print(json.dumps({'status': 'refused', 'reason': 'maintenance failed; check paths, permissions and database integrity'}))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -7,6 +7,7 @@ import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import secrets
|
import secrets
|
||||||
import signal
|
import signal
|
||||||
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
|
|
@ -61,6 +62,11 @@ class Router:
|
||||||
return ('Set-Cookie', f'{name}={value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={age}')
|
return ('Set-Cookie', f'{name}={value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={age}')
|
||||||
path, method = env.get('PATH_INFO'), env.get('REQUEST_METHOD')
|
path, method = env.get('PATH_INFO'), env.get('REQUEST_METHOD')
|
||||||
try:
|
try:
|
||||||
|
if method == 'GET' and path == '/metrics':
|
||||||
|
body = self.store.audit_metrics(self.last_drain)
|
||||||
|
start('200 OK', [('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'),
|
||||||
|
('Cache-Control', 'no-store')])
|
||||||
|
return [body.encode()]
|
||||||
if method == 'GET' and path in ('/healthz', '/readyz'):
|
if method == 'GET' and path in ('/healthz', '/readyz'):
|
||||||
ready = path == '/healthz' or time.time() - self.last_drain < 90 and not self.store.audit_debt()
|
ready = path == '/healthz' or time.time() - self.last_drain < 90 and not self.store.audit_debt()
|
||||||
return reply('200 OK' if ready else '503 Service Unavailable', 'ready' if ready else 'audit delivery pending')
|
return reply('200 OK' if ready else '503 Service Unavailable', 'ready' if ready else 'audit delivery pending')
|
||||||
|
|
@ -87,7 +93,7 @@ class Router:
|
||||||
if path == '/webhook':
|
if path == '/webhook':
|
||||||
self.application.webhook_token = credential(self.config['webhook_token_file'])
|
self.application.webhook_token = credential(self.config['webhook_token_file'])
|
||||||
return self.application(env, start)
|
return self.application(env, start)
|
||||||
except (ValueError, OSError, KeyError, TypeError):
|
except (ValueError, OSError, KeyError, TypeError, sqlite3.Error):
|
||||||
return reply('503 Service Unavailable', 'Identity or service unavailable. Retry later.')
|
return reply('503 Service Unavailable', 'Identity or service unavailable. Retry later.')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -109,7 +115,7 @@ def main():
|
||||||
try:
|
try:
|
||||||
store.drain(transport)
|
store.drain(transport)
|
||||||
router.last_drain = time.time()
|
router.last_drain = time.time()
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError, sqlite3.Error):
|
||||||
router.last_drain = 0
|
router.last_drain = 0
|
||||||
stop.wait(30)
|
stop.wait(30)
|
||||||
worker = threading.Thread(target=drain, daemon=True)
|
worker = threading.Thread(target=drain, daemon=True)
|
||||||
|
|
|
||||||
26
scripts/verify.sh
Normal file
26
scripts/verify.sh
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Full Python and native owner-contract suite; fail if native dependencies are absent.
|
||||||
|
set -euo pipefail
|
||||||
|
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
|
||||||
|
repo_root="$PWD"
|
||||||
|
audit_source="${RTEL_AUDIT_CORE_SOURCE:-$repo_root/../audit-core}"
|
||||||
|
policy_source="${RTEL_FLEX_AUTH_SOURCE:-$repo_root/../flex-auth}"
|
||||||
|
if [[ ! -f "$audit_source/audit_core/ingestion.py" || ! -f "$policy_source/go.mod" ]]; then
|
||||||
|
echo 'Required: sibling audit-core and flex-auth checkouts, or RTEL_AUDIT_CORE_SOURCE / RTEL_FLEX_AUTH_SOURCE.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p .cache/verify
|
||||||
|
export UV_CACHE_DIR="$repo_root/.cache/verify/uv"
|
||||||
|
export GOCACHE="$repo_root/.cache/verify/go-build"
|
||||||
|
if [[ ! -x .venv-verify/bin/python ]]; then
|
||||||
|
uv venv --python python3 .venv-verify
|
||||||
|
fi
|
||||||
|
uv pip sync --python .venv-verify/bin/python --require-hashes requirements-runtime.lock
|
||||||
|
go -C "$policy_source" build -mod=readonly -o "$repo_root/.cache/verify/flex-auth" ./cmd/flex-auth
|
||||||
|
export RTEL_AUDIT_CORE_SOURCE="$audit_source"
|
||||||
|
export RTEL_FLEX_AUTH_BINARY="$repo_root/.cache/verify/flex-auth"
|
||||||
|
echo 'Native owner source revisions:'
|
||||||
|
git -C "$audit_source" rev-parse HEAD
|
||||||
|
git -C "$policy_source" rev-parse HEAD
|
||||||
|
.venv-verify/bin/python -m unittest discover -s tests -v
|
||||||
|
.venv-verify/bin/python -m unittest discover -s tests_runtime -v
|
||||||
92
tests/test_alert_admin.py
Normal file
92
tests/test_alert_admin.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from test_alert_ack import NOW, START
|
||||||
|
from alert_ack import Actor, Store
|
||||||
|
from alert_admin import main, snapshot
|
||||||
|
|
||||||
|
|
||||||
|
class MaintenanceTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
self.path = self.root / 'source.db'
|
||||||
|
self.store = Store(self.path)
|
||||||
|
self.actor = Actor('https://fixture.example', 'private-human', 'tenant:platform',
|
||||||
|
('railiance-admin',), NOW+900, 'c'*32)
|
||||||
|
self.ids = []
|
||||||
|
self.events = []
|
||||||
|
for i in range(3):
|
||||||
|
identity = self.store.receive({'version':'4', 'receiver':'railiance-admin-email', 'alerts':[
|
||||||
|
{'status':'firing','fingerprint':f'{i:016x}','startsAt':START,
|
||||||
|
'labels':{'alertname':'Fixture','owner':'railiance-telemetry'}}]},NOW)[0]
|
||||||
|
self.ids.append(identity)
|
||||||
|
self.events.append(self.store.acknowledge(identity,self.actor,'decision',NOW))
|
||||||
|
def send(event):
|
||||||
|
if event['id'] == self.events[0]: return 202, {'status':'accepted','reference':'audit:'+event['id']}
|
||||||
|
if event['id'] == self.events[1]: return 403, {}
|
||||||
|
raise TimeoutError()
|
||||||
|
self.store.drain(send)
|
||||||
|
|
||||||
|
def cli(self,*args):
|
||||||
|
output=io.StringIO()
|
||||||
|
with redirect_stdout(output): code=main(['--database',str(self.path),*args])
|
||||||
|
return code,json.loads(output.getvalue())
|
||||||
|
|
||||||
|
def test_status_and_explicit_requeue_preserve_evidence(self):
|
||||||
|
self.assertEqual(self.cli('status'),(0,{'outbox':{'pending':1,'blocked':1,'delivered':1}}))
|
||||||
|
self.assertEqual(self.cli('list-blocked')[1]['event_ids'],[self.events[1]])
|
||||||
|
with self.store.connect() as db: before=db.execute('SELECT event_id,body FROM outbox ORDER BY event_id').fetchall()
|
||||||
|
self.assertTrue(self.cli('requeue','--event-id',self.events[1])[1]['requeued'])
|
||||||
|
self.assertEqual(self.cli('requeue','--event-id',self.events[0])[0],2)
|
||||||
|
with self.store.connect() as db: after=db.execute('SELECT event_id,body FROM outbox ORDER BY event_id').fetchall()
|
||||||
|
self.assertEqual([tuple(r) for r in before],[tuple(r) for r in after])
|
||||||
|
|
||||||
|
def test_backup_restore_preserves_all_states_identity_and_immutability(self):
|
||||||
|
backup=self.root/'snapshot.db'; restored=self.root/'restored.db'
|
||||||
|
snapshot(self.path,backup)
|
||||||
|
snapshot(backup,restored)
|
||||||
|
store=Store(restored)
|
||||||
|
for identity,event in zip(self.ids,self.events):
|
||||||
|
self.assertEqual(store.acknowledge(identity,self.actor,'new-decision',NOW+5),event)
|
||||||
|
self.assertEqual([store.get(i)['audit_status'] for i in self.ids],['delivered','blocked','pending'])
|
||||||
|
with store.connect() as db:
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError):db.execute('DELETE FROM acknowledgments')
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError):db.execute("UPDATE outbox SET body='{}'")
|
||||||
|
self.assertEqual(restored.stat().st_mode & 0o777,0o600)
|
||||||
|
self.assertTrue(store.requeue(self.events[1]))
|
||||||
|
sent=[]
|
||||||
|
def duplicate(event):
|
||||||
|
sent.append(event['id']);return 200,{'status':'duplicate','reference':'audit:'+event['id']}
|
||||||
|
store.drain(duplicate)
|
||||||
|
self.assertEqual(set(sent),set(self.events[1:]))
|
||||||
|
self.assertFalse(store.audit_debt())
|
||||||
|
self.assertEqual(self.store.audit_debt(),{'blocked':1,'pending':1})
|
||||||
|
|
||||||
|
def test_refuse_overwrite_missing_input_bad_schema_and_symlink(self):
|
||||||
|
with self.assertRaises(FileExistsError):snapshot(self.path,self.path)
|
||||||
|
missing=self.root/'missing.db'
|
||||||
|
with redirect_stdout(io.StringIO()):self.assertEqual(main(['--database',str(missing),'status']),1)
|
||||||
|
self.assertFalse(missing.exists())
|
||||||
|
invalid=self.root/'invalid.db';invalid.write_bytes(b'bad')
|
||||||
|
invalid.chmod(0o600)
|
||||||
|
with self.assertRaises(sqlite3.DatabaseError):snapshot(invalid,self.root/'bad-backup.db')
|
||||||
|
self.assertFalse((self.root/'bad-backup.db').exists())
|
||||||
|
link=self.root/'link.db';link.symlink_to(self.path)
|
||||||
|
with self.assertRaises(ValueError):snapshot(link,self.root/'link-backup.db')
|
||||||
|
|
||||||
|
def test_metrics_are_aggregate_and_show_empty_states(self):
|
||||||
|
metrics=self.store.audit_metrics(123)
|
||||||
|
for state in ('pending','blocked','delivered'):
|
||||||
|
self.assertIn(f'{{status="{state}"}} 1',metrics)
|
||||||
|
self.assertIn('timestamp_seconds 123.0',metrics)
|
||||||
|
self.assertNotIn(self.actor.subject,metrics)
|
||||||
|
for event in self.events:self.assertNotIn(event,metrics)
|
||||||
|
empty=Store(self.root/'empty.db').audit_metrics(0)
|
||||||
|
self.assertIn('{status="pending"} 0',empty)
|
||||||
|
|
@ -9,6 +9,7 @@ import unittest
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
|
||||||
from alert_ack import Actor, Store
|
from alert_ack import Actor, Store
|
||||||
|
from alert_admin import snapshot
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipUnless(os.environ.get('RTEL_AUDIT_CORE_SOURCE'), 'set RTEL_AUDIT_CORE_SOURCE for real receiver check')
|
@unittest.skipUnless(os.environ.get('RTEL_AUDIT_CORE_SOURCE'), 'set RTEL_AUDIT_CORE_SOURCE for real receiver check')
|
||||||
|
|
@ -42,7 +43,9 @@ class NativeAuditTests(unittest.TestCase):
|
||||||
raise TimeoutError('simulated lost response')
|
raise TimeoutError('simulated lost response')
|
||||||
return response['status'], json.loads(body)
|
return response['status'], json.loads(body)
|
||||||
store.drain(send)
|
store.drain(send)
|
||||||
store = Store(Path(tmp) / 'ack.db')
|
snapshot(store.path, Path(tmp) / 'backup.db')
|
||||||
|
snapshot(Path(tmp) / 'backup.db', Path(tmp) / 'restored.db')
|
||||||
|
store = Store(Path(tmp) / 'restored.db')
|
||||||
store.drain(send)
|
store.drain(send)
|
||||||
self.assertEqual(calls, [202, 200])
|
self.assertEqual(calls, [202, 200])
|
||||||
self.assertEqual(store.get(identity)['audit_status'], 'delivered')
|
self.assertEqual(store.get(identity)['audit_status'], 'delivered')
|
||||||
|
|
|
||||||
|
|
@ -55,3 +55,18 @@ class ServiceTests(unittest.TestCase):
|
||||||
response, _ = call('/ack/logout', 'POST', session_cookie, query='', body=form)
|
response, _ = call('/ack/logout', 'POST', session_cookie, query='', body=form)
|
||||||
self.assertEqual(response['status'], '200 OK')
|
self.assertEqual(response['status'], '200 OK')
|
||||||
self.assertIsNone(login.session(session_cookie.split('=', 1)[1]))
|
self.assertIsNone(login.session(session_cookie.split('=', 1)[1]))
|
||||||
|
|
||||||
|
def test_private_metrics_route_without_identity_or_event_labels(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
token=Path(tmp)/'token';token.write_text('w'*32)
|
||||||
|
store=Store(Path(tmp)/'state.db')
|
||||||
|
router=Router({'origin':'https://telemetry.example','webhook_token_file':str(token)},
|
||||||
|
store,login=object(),policy=lambda *args: None)
|
||||||
|
router.last_drain=123
|
||||||
|
result={}
|
||||||
|
output=b''.join(router({'PATH_INFO':'/metrics','REQUEST_METHOD':'GET'},
|
||||||
|
lambda status,headers:result.update(status=status,headers=dict(headers))))
|
||||||
|
self.assertEqual(result['status'],'200 OK')
|
||||||
|
self.assertIn('version=0.0.4',result['headers']['Content-Type'])
|
||||||
|
self.assertIn(b'{status="blocked"} 0',output)
|
||||||
|
self.assertIn(b'timestamp_seconds 123.0',output)
|
||||||
|
|
|
||||||
|
|
@ -154,3 +154,13 @@ evidence, along with browser client/PDP/audit admission and other T04 gates.
|
||||||
Bernd Worsch confirmed the transport-test email arrived in his inbox. SMTP
|
Bernd Worsch confirmed the transport-test email arrived in his inbox. SMTP
|
||||||
credential custody, projection, authentication and recipient delivery now pass.
|
credential custody, projection, authentication and recipient delivery now pass.
|
||||||
This is a transport-test receipt, not an audit-core alert acknowledgment.
|
This is a transport-test receipt, not an audit-core alert acknowledgment.
|
||||||
|
|
||||||
|
September 28 local operations closure: private aggregate audit metrics and local
|
||||||
|
status/list-blocked/requeue commands implemented. SQLite online backup and
|
||||||
|
restore-to-new-path preserve original receipts, immutable event bytes and all
|
||||||
|
delivery states; native audit-core deduplicates replay after lost reply and
|
||||||
|
restore. `bash scripts/verify.sh` installs locked dependencies and builds the
|
||||||
|
native policy test dependency from declared checkouts; all 45 tests pass without
|
||||||
|
skips. See docs/acknowledgment-operations.md. No new task/workplan. T04 remains
|
||||||
|
wait and workplan blocked for live identity/PDP/audit admission, scrape binding,
|
||||||
|
recipient alert confirmation/readback, off-host watchdog and scheduled backups.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue