feat: service-level role gating for privacy-sensitive services (Option A)
All checks were successful
CI / lint-and-test (push) Successful in 25s
All checks were successful
CI / lint-and-test (push) Successful in 25s
- Add PRIVACY_SERVICES and PRIVACY_SERVICE_ROLES config variables
- Add user_can_access_privacy_services(claims) helper in auth.py
- /api/events filters out privacy services for users without required roles
- /api/filter-options excludes privacy services from dropdown options
- /api/ask excludes privacy services from NLQ queries
- /api/events/{id}/explain returns 403 for privacy events if unauthorized
- Teams added to default noisy service exclusion (frontend + backend)
- Update .env.example with privacy config documentation
- Add tests for event filtering, filter-options exclusion, and explain 403
This commit is contained in:
@@ -49,3 +49,9 @@ LLM_MODEL=gpt-4o-mini
|
|||||||
LLM_MAX_EVENTS=200
|
LLM_MAX_EVENTS=200
|
||||||
LLM_TIMEOUT_SECONDS=30
|
LLM_TIMEOUT_SECONDS=30
|
||||||
LLM_API_VERSION=
|
LLM_API_VERSION=
|
||||||
|
|
||||||
|
# Optional: privacy / service-level access control
|
||||||
|
# Comma-separated list of services considered privacy-sensitive (hidden from users without PRIVACY_SERVICE_ROLES)
|
||||||
|
# PRIVACY_SERVICES=Exchange,Teams
|
||||||
|
# Comma-separated list of Entra roles that can access privacy-sensitive services
|
||||||
|
# PRIVACY_SERVICE_ROLES=SecurityAdministrator,ComplianceAdministrator
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from config import (
|
|||||||
AUTH_CLIENT_ID,
|
AUTH_CLIENT_ID,
|
||||||
AUTH_ENABLED,
|
AUTH_ENABLED,
|
||||||
AUTH_TENANT_ID,
|
AUTH_TENANT_ID,
|
||||||
|
PRIVACY_SERVICE_ROLES,
|
||||||
|
PRIVACY_SERVICES,
|
||||||
)
|
)
|
||||||
from fastapi import Header, HTTPException
|
from fastapi import Header, HTTPException
|
||||||
from jwt import ExpiredSignatureError, InvalidTokenError, decode
|
from jwt import ExpiredSignatureError, InvalidTokenError, decode
|
||||||
@@ -82,6 +84,14 @@ def _decode_token(token: str, jwks):
|
|||||||
raise HTTPException(status_code=401, detail=f"Invalid token ({type(exc).__name__})") from None
|
raise HTTPException(status_code=401, detail=f"Invalid token ({type(exc).__name__})") from None
|
||||||
|
|
||||||
|
|
||||||
|
def user_can_access_privacy_services(claims: dict) -> bool:
|
||||||
|
"""Check if the user has roles that grant access to privacy-sensitive services."""
|
||||||
|
if not PRIVACY_SERVICES or not PRIVACY_SERVICE_ROLES:
|
||||||
|
return True
|
||||||
|
user_roles = set(claims.get("roles", []) or claims.get("role", []) or [])
|
||||||
|
return bool(user_roles.intersection(PRIVACY_SERVICE_ROLES))
|
||||||
|
|
||||||
|
|
||||||
def require_auth(authorization: str | None = Header(None)):
|
def require_auth(authorization: str | None = Header(None)):
|
||||||
if not AUTH_ENABLED:
|
if not AUTH_ENABLED:
|
||||||
return {"sub": "anonymous"}
|
return {"sub": "anonymous"}
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ class Settings(BaseSettings):
|
|||||||
LLM_TIMEOUT_SECONDS: int = 30
|
LLM_TIMEOUT_SECONDS: int = 30
|
||||||
LLM_API_VERSION: str = "" # e.g. 2025-01-01-preview for Azure OpenAI
|
LLM_API_VERSION: str = "" # e.g. 2025-01-01-preview for Azure OpenAI
|
||||||
|
|
||||||
|
# Privacy / Service-level access control
|
||||||
|
# Services listed here are hidden from users who don't have PRIVACY_SERVICE_ROLES
|
||||||
|
PRIVACY_SERVICES: str = "" # comma-separated, e.g. "Exchange,Teams"
|
||||||
|
PRIVACY_SERVICE_ROLES: str = "" # comma-separated, e.g. "SecurityAdministrator,ComplianceAdministrator"
|
||||||
|
|
||||||
|
|
||||||
_settings = Settings()
|
_settings = Settings()
|
||||||
|
|
||||||
@@ -85,3 +90,6 @@ LLM_MODEL = _settings.LLM_MODEL
|
|||||||
LLM_MAX_EVENTS = _settings.LLM_MAX_EVENTS
|
LLM_MAX_EVENTS = _settings.LLM_MAX_EVENTS
|
||||||
LLM_TIMEOUT_SECONDS = _settings.LLM_TIMEOUT_SECONDS
|
LLM_TIMEOUT_SECONDS = _settings.LLM_TIMEOUT_SECONDS
|
||||||
LLM_API_VERSION = _settings.LLM_API_VERSION
|
LLM_API_VERSION = _settings.LLM_API_VERSION
|
||||||
|
|
||||||
|
PRIVACY_SERVICES = {s.strip() for s in _settings.PRIVACY_SERVICES.split(",") if s.strip()}
|
||||||
|
PRIVACY_SERVICE_ROLES = {r.strip() for r in _settings.PRIVACY_SERVICE_ROLES.split(",") if r.strip()}
|
||||||
|
|||||||
@@ -5,8 +5,16 @@ from datetime import UTC, datetime, timedelta
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import structlog
|
import structlog
|
||||||
from auth import require_auth
|
from auth import require_auth, user_can_access_privacy_services
|
||||||
from config import LLM_API_KEY, LLM_API_VERSION, LLM_BASE_URL, LLM_MAX_EVENTS, LLM_MODEL, LLM_TIMEOUT_SECONDS
|
from config import (
|
||||||
|
LLM_API_KEY,
|
||||||
|
LLM_API_VERSION,
|
||||||
|
LLM_BASE_URL,
|
||||||
|
LLM_MAX_EVENTS,
|
||||||
|
LLM_MODEL,
|
||||||
|
LLM_TIMEOUT_SECONDS,
|
||||||
|
PRIVACY_SERVICES,
|
||||||
|
)
|
||||||
from database import events_collection
|
from database import events_collection
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from models.api import AskRequest, AskResponse
|
from models.api import AskRequest, AskResponse
|
||||||
@@ -588,6 +596,9 @@ async def explain_event(event_id: str, user: dict = Depends(require_auth)):
|
|||||||
if not event:
|
if not event:
|
||||||
raise HTTPException(status_code=404, detail="Event not found")
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
|
||||||
|
if event.get("service") in PRIVACY_SERVICES and not user_can_access_privacy_services(user):
|
||||||
|
raise HTTPException(status_code=403, detail="Access to this event is restricted")
|
||||||
|
|
||||||
event.pop("_id", None)
|
event.pop("_id", None)
|
||||||
|
|
||||||
# Fetch related events for context (same actor or target in last 24h)
|
# Fetch related events for context (same actor or target in last 24h)
|
||||||
@@ -678,6 +689,7 @@ async def ask_question(body: AskRequest, user: dict = Depends(require_auth)):
|
|||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Build and run query
|
# Build and run query
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
privacy_excluded = [] if user_can_access_privacy_services(user) else list(PRIVACY_SERVICES)
|
||||||
query = _build_event_query(
|
query = _build_event_query(
|
||||||
entity,
|
entity,
|
||||||
start,
|
start,
|
||||||
@@ -689,6 +701,8 @@ async def ask_question(body: AskRequest, user: dict = Depends(require_auth)):
|
|||||||
include_tags=body.include_tags,
|
include_tags=body.include_tags,
|
||||||
exclude_tags=body.exclude_tags,
|
exclude_tags=body.exclude_tags,
|
||||||
)
|
)
|
||||||
|
if privacy_excluded:
|
||||||
|
query["$and"] = query.get("$and", []) + [{"service": {"$nin": privacy_excluded}}]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
total = events_collection.count_documents(query)
|
total = events_collection.count_documents(query)
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import re
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from audit_trail import log_action
|
from audit_trail import log_action
|
||||||
from auth import require_auth
|
from auth import require_auth, user_can_access_privacy_services
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
|
from config import PRIVACY_SERVICES
|
||||||
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 (
|
||||||
@@ -44,6 +45,7 @@ def _build_query(
|
|||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
include_tags: list[str] | None = None,
|
include_tags: list[str] | None = None,
|
||||||
exclude_tags: list[str] | None = None,
|
exclude_tags: list[str] | None = None,
|
||||||
|
exclude_services: list[str] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
filters = []
|
filters = []
|
||||||
|
|
||||||
@@ -51,6 +53,8 @@ def _build_query(
|
|||||||
filters.append({"service": service})
|
filters.append({"service": service})
|
||||||
if services:
|
if services:
|
||||||
filters.append({"service": {"$in": services}})
|
filters.append({"service": {"$in": services}})
|
||||||
|
if exclude_services:
|
||||||
|
filters.append({"service": {"$nin": exclude_services}})
|
||||||
if actor:
|
if actor:
|
||||||
actor_safe = re.escape(actor)
|
actor_safe = re.escape(actor)
|
||||||
filters.append(
|
filters.append(
|
||||||
@@ -125,6 +129,7 @@ def list_events(
|
|||||||
exclude_tags: list[str] | None = Query(default=None),
|
exclude_tags: list[str] | None = Query(default=None),
|
||||||
user: dict = Depends(require_auth),
|
user: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
|
privacy_excluded = [] if user_can_access_privacy_services(user) else list(PRIVACY_SERVICES)
|
||||||
query = _build_query(
|
query = _build_query(
|
||||||
service=service,
|
service=service,
|
||||||
services=services,
|
services=services,
|
||||||
@@ -137,6 +142,7 @@ def list_events(
|
|||||||
cursor=cursor,
|
cursor=cursor,
|
||||||
include_tags=include_tags,
|
include_tags=include_tags,
|
||||||
exclude_tags=exclude_tags,
|
exclude_tags=exclude_tags,
|
||||||
|
exclude_services=privacy_excluded,
|
||||||
)
|
)
|
||||||
|
|
||||||
safe_page_size = max(1, min(page_size, 500))
|
safe_page_size = max(1, min(page_size, 500))
|
||||||
@@ -202,6 +208,7 @@ def bulk_tags(
|
|||||||
exclude_tags: list[str] | None = Query(default=None),
|
exclude_tags: list[str] | None = Query(default=None),
|
||||||
user: dict = Depends(require_auth),
|
user: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
|
privacy_excluded = [] if user_can_access_privacy_services(user) else list(PRIVACY_SERVICES)
|
||||||
query = _build_query(
|
query = _build_query(
|
||||||
service=service,
|
service=service,
|
||||||
services=services,
|
services=services,
|
||||||
@@ -213,6 +220,7 @@ def bulk_tags(
|
|||||||
search=search,
|
search=search,
|
||||||
include_tags=include_tags,
|
include_tags=include_tags,
|
||||||
exclude_tags=exclude_tags,
|
exclude_tags=exclude_tags,
|
||||||
|
exclude_services=privacy_excluded,
|
||||||
)
|
)
|
||||||
tags = [t.strip() for t in body.tags if t.strip()]
|
tags = [t.strip() for t in body.tags if t.strip()]
|
||||||
if not tags:
|
if not tags:
|
||||||
@@ -235,7 +243,10 @@ def bulk_tags(
|
|||||||
|
|
||||||
|
|
||||||
@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),
|
||||||
|
user: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
safe_limit = max(1, min(limit, 1000))
|
safe_limit = max(1, min(limit, 1000))
|
||||||
try:
|
try:
|
||||||
services = sorted(events_collection.distinct("service"))[:safe_limit]
|
services = sorted(events_collection.distinct("service"))[:safe_limit]
|
||||||
@@ -247,6 +258,9 @@ def filter_options(limit: int = Query(default=200, ge=1, le=1000)):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to load filter options: {exc}") from exc
|
raise HTTPException(status_code=500, detail=f"Failed to load filter options: {exc}") from exc
|
||||||
|
|
||||||
|
if not user_can_access_privacy_services(user):
|
||||||
|
services = [s for s in services if s not in PRIVACY_SERVICES]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"services": services,
|
"services": services,
|
||||||
"operations": operations,
|
"operations": operations,
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ def client(mock_events_collection, mock_watermarks_collection, monkeypatch):
|
|||||||
monkeypatch.setattr("routes.fetch.set_watermark", lambda source, ts: None)
|
monkeypatch.setattr("routes.fetch.set_watermark", lambda source, ts: None)
|
||||||
monkeypatch.setattr("auth.AUTH_ENABLED", False)
|
monkeypatch.setattr("auth.AUTH_ENABLED", False)
|
||||||
monkeypatch.setattr("routes.mcp.AUTH_ENABLED", False)
|
monkeypatch.setattr("routes.mcp.AUTH_ENABLED", False)
|
||||||
|
monkeypatch.setattr("config.PRIVACY_SERVICES", set())
|
||||||
|
monkeypatch.setattr("routes.events.PRIVACY_SERVICES", set())
|
||||||
|
monkeypatch.setattr("routes.ask.PRIVACY_SERVICES", set())
|
||||||
monkeypatch.setattr("database.db.command", lambda cmd: {"ok": 1} if cmd == "ping" else {})
|
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
|
# Mock audit trail and rules collections so tests don't wait on real MongoDB
|
||||||
|
|||||||
@@ -149,6 +149,79 @@ def test_saved_searches_create_validation(client, monkeypatch):
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_privacy_filtering_events(client, mock_events_collection, monkeypatch):
|
||||||
|
monkeypatch.setattr("config.PRIVACY_SERVICES", {"Exchange", "Teams"})
|
||||||
|
monkeypatch.setattr("routes.events.PRIVACY_SERVICES", {"Exchange", "Teams"})
|
||||||
|
monkeypatch.setattr("auth.PRIVACY_SERVICE_ROLES", {"SecurityAdmin"})
|
||||||
|
monkeypatch.setattr("auth.user_can_access_privacy_services", lambda claims: False)
|
||||||
|
monkeypatch.setattr("routes.events.user_can_access_privacy_services", lambda claims: False)
|
||||||
|
|
||||||
|
mock_events_collection.insert_one(
|
||||||
|
{
|
||||||
|
"id": "evt-dir",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Directory",
|
||||||
|
"operation": "Add user",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Alice",
|
||||||
|
"raw_text": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_events_collection.insert_one(
|
||||||
|
{
|
||||||
|
"id": "evt-exc",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Exchange",
|
||||||
|
"operation": "Send",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Bob",
|
||||||
|
"raw_text": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/api/events")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
ids = [e["id"] for e in data["items"]]
|
||||||
|
assert "evt-dir" in ids
|
||||||
|
assert "evt-exc" not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_privacy_filter_options(client, mock_events_collection, monkeypatch):
|
||||||
|
monkeypatch.setattr("config.PRIVACY_SERVICES", {"Exchange"})
|
||||||
|
monkeypatch.setattr("routes.events.PRIVACY_SERVICES", {"Exchange"})
|
||||||
|
monkeypatch.setattr("auth.PRIVACY_SERVICE_ROLES", {"SecurityAdmin"})
|
||||||
|
monkeypatch.setattr("auth.user_can_access_privacy_services", lambda claims: False)
|
||||||
|
monkeypatch.setattr("routes.events.user_can_access_privacy_services", lambda claims: False)
|
||||||
|
|
||||||
|
response = client.get("/api/filter-options")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "Exchange" not in data["services"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_privacy_explain_forbidden(client, mock_events_collection, monkeypatch):
|
||||||
|
monkeypatch.setattr("config.PRIVACY_SERVICES", {"Exchange"})
|
||||||
|
monkeypatch.setattr("routes.ask.PRIVACY_SERVICES", {"Exchange"})
|
||||||
|
monkeypatch.setattr("auth.PRIVACY_SERVICE_ROLES", {"SecurityAdmin"})
|
||||||
|
monkeypatch.setattr("auth.user_can_access_privacy_services", lambda claims: False)
|
||||||
|
monkeypatch.setattr("routes.ask.user_can_access_privacy_services", lambda claims: False)
|
||||||
|
|
||||||
|
mock_events_collection.insert_one(
|
||||||
|
{
|
||||||
|
"id": "evt-exc2",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Exchange",
|
||||||
|
"operation": "Send",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Bob",
|
||||||
|
"raw_text": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = client.post("/api/events/evt-exc2/explain")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_health(client):
|
def test_health(client):
|
||||||
response = client.get("/health")
|
response = client.get("/health")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
Reference in New Issue
Block a user