|
| 1 | +import base64 |
1 | 2 | import json |
| 3 | +import os |
| 4 | +import secrets |
2 | 5 | from datetime import datetime, timedelta |
3 | 6 | from urllib.parse import urlparse |
4 | 7 |
|
| 8 | +import requests |
| 9 | +from azure.identity import DefaultAzureCredential, ManagedIdentityCredential |
5 | 10 | from django.conf import settings |
6 | 11 | from django.contrib.auth import authenticate, login, logout |
7 | 12 | from django.contrib.auth.models import User |
@@ -1065,3 +1070,155 @@ def logout_user(request): |
1065 | 1070 | if request.method == "POST" and request.user.is_authenticated: |
1066 | 1071 | logout(request) |
1067 | 1072 | 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 | + ) |
0 commit comments