Skip to content

Commit 8b2661a

Browse files
committed
AuthPowerBI endpoint with some tests
1 parent 123c8a9 commit 8b2661a

File tree

3 files changed

+239
-0
lines changed

3 files changed

+239
-0
lines changed

api/test_views.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import re
12
import uuid
3+
from unittest.mock import patch
24

35
from django.contrib.auth.models import User
46
from django.core.files.uploadedfile import SimpleUploadedFile
7+
from django.test import override_settings
8+
from django.urls import reverse
59

610
import api.models as models
711
from api.factories.event import (
@@ -18,6 +22,82 @@
1822
from main.test_case import APITestCase
1923

2024

25+
class AuthPowerBITest(APITestCase):
26+
def setUp(self):
27+
self.url = reverse("auth_power_bi")
28+
29+
def test_not_authenticated_returns_401(self):
30+
resp = self.client.get(self.url)
31+
self.assertEqual(resp.status_code, 401)
32+
self.assertIn("error_code", resp.json())
33+
34+
def test_authenticated_returns_ok(self):
35+
user = User.objects.create_user(username="alice", password="pass1234")
36+
# self.client.login(username="alice", password="pass1234") # session auth, if needed in the future
37+
# Use token authentication instead of session auth
38+
self.authenticate(user=user)
39+
resp = self.client.get(self.url)
40+
self.assertEqual(resp.status_code, 200)
41+
data = resp.json()
42+
self.assertEqual(data.get("detail"), "ok")
43+
self.assertEqual(data.get("user"), user.username)
44+
45+
def test_authenticated_returns_mock_values_shape(self):
46+
user = User.objects.create_user(username="bob", password="pass1234")
47+
self.assertEqual("bob", user.username)
48+
# Use token authentication instead of session auth
49+
self.authenticate(user=user)
50+
51+
# Force mock path regardless of settings by returning no MI token
52+
with patch("api.views._pbi_token_via_managed_identity", return_value=None):
53+
resp = self.client.get(self.url)
54+
self.assertEqual(resp.status_code, 200)
55+
data = resp.json()
56+
57+
# embed_token: 16 hex chars
58+
self.assertIsInstance(data.get("embed_token"), str)
59+
self.assertTrue(re.fullmatch(r"[0-9a-f]{16}", data["embed_token"]))
60+
# embed_url: 10-char random string
61+
self.assertIsInstance(data.get("embed_url"), str)
62+
self.assertEqual(len(data["embed_url"]), 10)
63+
# report_id: positive int
64+
self.assertIsInstance(data.get("report_id"), int)
65+
self.assertGreater(data["report_id"], 0)
66+
67+
def test_authenticated_powerbi_values_when_configured(self):
68+
user = User.objects.create_user(username="carol", password="pass1234")
69+
self.assertEqual("carol", user.username)
70+
# Use token authentication instead of session auth
71+
self.authenticate(user=user)
72+
73+
expected = {
74+
"embed_url": "https://app.powerbi.com/reportEmbed?reportId=rep-123",
75+
"report_id": "rep-123",
76+
"embed_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", # dummy string
77+
"expires_at": "2099-01-01T00:00:00Z",
78+
}
79+
80+
with override_settings(POWERBI_WORKSPACE_ID="ws-abc"):
81+
with (
82+
patch("api.views._pbi_token_via_managed_identity", return_value="access-token") as p_token,
83+
patch(
84+
"api.views._pbi_get_embed_info",
85+
return_value=(expected["embed_url"], expected["report_id"], expected["embed_token"], expected["expires_at"]),
86+
) as p_info,
87+
):
88+
resp = self.client.get(self.url)
89+
90+
self.assertEqual(resp.status_code, 200)
91+
data = resp.json()
92+
self.assertEqual(data.get("embed_url"), expected["embed_url"])
93+
self.assertEqual(data.get("report_id"), expected["report_id"])
94+
self.assertEqual(data.get("embed_token"), expected["embed_token"])
95+
self.assertEqual(data.get("user"), "carol")
96+
# helpers were called
97+
p_token.assert_called_once()
98+
p_info.assert_called_once()
99+
100+
21101
class SecureFileFieldTest(APITestCase):
22102
def is_valid_uuid(self, uuid_to_test):
23103
try:

api/views.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import base64
12
import json
3+
import os
4+
import secrets
25
from datetime import datetime, timedelta
36
from urllib.parse import urlparse
47

8+
import requests
9+
from azure.identity import DefaultAzureCredential, ManagedIdentityCredential
510
from django.conf import settings
611
from django.contrib.auth import authenticate, login, logout
712
from django.contrib.auth.models import User
@@ -1065,3 +1070,155 @@ def logout_user(request):
10651070
if request.method == "POST" and request.user.is_authenticated:
10661071
logout(request)
10671072
return redirect(reverse(settings.LOGIN_URL))
1073+
1074+
1075+
# Power BI embedding via managed identity
1076+
PBI_SCOPE = "https://analysis.windows.net/powerbi/api/.default"
1077+
PBI_BASE = "https://api.powerbi.com/v1.0/myorg"
1078+
1079+
1080+
def _pbi_token_via_managed_identity() -> str | None:
1081+
"""
1082+
Acquire an AAD access token for Power BI using the AKS managed identity.
1083+
If AZURE_CLIENT_ID is provided, target that user-assigned MI.
1084+
"""
1085+
try:
1086+
client_id = getattr(settings, "AZURE_CLIENT_ID", None) or os.getenv("AZURE_CLIENT_ID")
1087+
if client_id:
1088+
cred = ManagedIdentityCredential(client_id=client_id)
1089+
else:
1090+
# Will use workload identity / MSI / Azure CLI (in dev) automatically
1091+
cred = DefaultAzureCredential(exclude_interactive_browser_credential=True)
1092+
return cred.get_token(PBI_SCOPE).token
1093+
except Exception as exc:
1094+
logger.exception("Power BI MI token acquisition failed: %s", exc)
1095+
return None
1096+
1097+
1098+
def _pbi_get_embed_info(access_token: str, workspace_id: str, report_id: str | None = None, timeout: int = 10):
1099+
"""
1100+
Get report embedUrl and generate an embed token (embed-for-your-organization).
1101+
Requires the service principal (managed identity) to be a member of the workspace.
1102+
"""
1103+
headers = {"Authorization": f"Bearer {access_token}"}
1104+
1105+
# Resolve report metadata
1106+
if report_id:
1107+
r = requests.get(f"{PBI_BASE}/groups/{workspace_id}/reports/{report_id}", headers=headers, timeout=timeout)
1108+
r.raise_for_status()
1109+
rep = r.json()
1110+
else:
1111+
r = requests.get(f"{PBI_BASE}/groups/{workspace_id}/reports", headers=headers, timeout=timeout)
1112+
r.raise_for_status()
1113+
items = r.json().get("value", [])
1114+
if not items:
1115+
raise RuntimeError("No reports found in workspace.")
1116+
rep = items[0]
1117+
report_id = rep["id"]
1118+
1119+
embed_url = rep["embedUrl"]
1120+
1121+
# Generate embed token (view access)
1122+
payload = {"accessLevel": "View"}
1123+
t = requests.post(
1124+
f"{PBI_BASE}/groups/{workspace_id}/reports/{report_id}/GenerateToken",
1125+
headers={**headers, "Content-Type": "application/json"},
1126+
json=payload,
1127+
timeout=timeout,
1128+
)
1129+
t.raise_for_status()
1130+
token_json = t.json()
1131+
embed_token = token_json["token"]
1132+
expires = token_json.get("expiration")
1133+
1134+
return embed_url, report_id, embed_token, expires
1135+
1136+
1137+
def _log_token_claims_safe(access_token: str) -> None:
1138+
"""
1139+
Debug-lite helper: log selected AAD JWT token claims without exposing the token.
1140+
Logs tid (tenant), appid (client), oid (object id), aud (audience), exp (UTC).
1141+
"""
1142+
try:
1143+
parts = (access_token or "").split(".")
1144+
if len(parts) < 2:
1145+
logger.warning("AuthPowerBI: token not in JWT format; cannot decode claims")
1146+
return
1147+
# Add base64 padding if needed
1148+
pad = "=" * (-len(parts[1]) % 4)
1149+
payload = json.loads(base64.urlsafe_b64decode(parts[1] + pad))
1150+
exp = payload.get("exp", 0)
1151+
try:
1152+
exp_iso = datetime.utcfromtimestamp(exp).isoformat() + "Z"
1153+
except Exception:
1154+
exp_iso = str(exp)
1155+
logger.info(
1156+
"AuthPowerBI token claims: tid=%s appid=%s oid=%s aud=%s exp=%s",
1157+
payload.get("tid"),
1158+
payload.get("appid"),
1159+
payload.get("oid"),
1160+
payload.get("aud"),
1161+
exp_iso,
1162+
)
1163+
except Exception as exc:
1164+
logger.warning("AuthPowerBI: failed to decode token claims: %s", exc)
1165+
1166+
1167+
class AuthPowerBI(APIView):
1168+
authentication_classes = (authentication.TokenAuthentication,) # later to SessionAuthentication
1169+
permission_classes = (permissions.IsAuthenticated,)
1170+
1171+
def get(self, request):
1172+
# Try real Power BI via managed identity
1173+
# Accept config from settings or environment for parity with diagnostics command
1174+
workspace_id = getattr(settings, "POWERBI_WORKSPACE_ID", None) or os.getenv("POWERBI_WORKSPACE_ID")
1175+
# Support both POWERBI_REPORT_ID (preferred) and legacy REPORT_ID, plus env override
1176+
report_id_cfg = (
1177+
getattr(settings, "POWERBI_REPORT_ID", None) or getattr(settings, "REPORT_ID", None) or os.getenv("POWERBI_REPORT_ID")
1178+
)
1179+
access_token = _pbi_token_via_managed_identity()
1180+
1181+
# Optional debug-lite: log selected token claims and config when requested
1182+
debug_flag = str(request.query_params.get("debug", "")).lower() in {"1", "true", "yes", "on"}
1183+
if debug_flag:
1184+
logger.info(
1185+
"AuthPowerBI debug-lite enabled: workspace_id=%s report_id_cfg=%s has_token=%s",
1186+
workspace_id,
1187+
report_id_cfg,
1188+
bool(access_token),
1189+
)
1190+
if access_token:
1191+
_log_token_claims_safe(access_token)
1192+
1193+
if access_token and workspace_id:
1194+
try:
1195+
embed_url, report_id, embed_token, expires_at = _pbi_get_embed_info(access_token, workspace_id, report_id_cfg)
1196+
return Response(
1197+
{
1198+
"detail": "ok",
1199+
"embed_url": embed_url,
1200+
"report_id": report_id,
1201+
"embed_token": embed_token,
1202+
"expires_at": expires_at,
1203+
"user": request.user.username,
1204+
}
1205+
)
1206+
except Exception as e:
1207+
logger.exception("Power BI REST call failed, falling back to mock: %s", e)
1208+
1209+
# Fallback mock if not configured or failed
1210+
embed_token = secrets.token_hex(8) # 16-char hex
1211+
embed_url = get_random_string(10)
1212+
report_id = secrets.randbelow(2_147_483_647) + 1
1213+
expires_at = (timezone.now() + timedelta(hours=1)).isoformat()
1214+
1215+
return Response(
1216+
{
1217+
"detail": "ok",
1218+
"embed_url": embed_url,
1219+
"report_id": report_id,
1220+
"embed_token": embed_token,
1221+
"expires_at": expires_at,
1222+
"user": request.user.username,
1223+
}
1224+
)

main/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
AggregateByTime,
3131
AggregateHeaderFigures,
3232
AreaAggregate,
33+
AuthPowerBI,
3334
Brief,
3435
DelSubscription,
3536
DummyExceptionError,
@@ -241,6 +242,7 @@
241242
url(r"^api/v2/event/(?P<slug>[-\w]+)", api_views.EventViewset.as_view({"get": "retrieve"}, lookup_field="slug")),
242243
url(r"^api/v2/delegation-office/(?P<pk>\d+)", DelegationOfficeDetailAPIView.as_view()),
243244
url(r"^api/v2/delegation-office/", DelegationOfficeListAPIView.as_view()),
245+
url(r"^api/v2/auth-power-bi/", AuthPowerBI.as_view(), name="auth_power_bi"),
244246
url(r"^tinymce/", include("tinymce.urls")),
245247
url(r"^$", RedirectView.as_view(url="/admin")),
246248
# url(r'^', admin.site.urls),

0 commit comments

Comments
 (0)