Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e0ce6996b | ||
|
|
5e60d17d60 | ||
|
|
20156ffc93 | ||
|
|
c55db48894 |
No files matched your search
@@ -11,6 +11,15 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.7.13] — 2026-09-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Result cards showed an ID instead of the person's name** — cards from scheduled scans of profiles saved in the profile editor showed the account's Microsoft 365 ID (e.g. `38a9797a-e065-…`) where the name should be, and no Elev/Ansat badge. The profile editor saved accounts as bare IDs, and the scan used the ID as the name. The scan now looks such accounts up for their name and role (using the same Elev/Ansat classification as the account list), the profile editor saves the name and role with each account, and existing cards with an ID as their name now show the name and role from the account list — no rescan needed.
|
||||||
|
- **Excel report could show IDs instead of names** — for scans started from ID-only account lists, the report's account-name lookup failed as a whole and fell back to the stored values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.7.12] — 2026-09-28
|
## [1.7.12] — 2026-09-28
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -45,6 +45,42 @@ Six root causes fixed in `scan_engine.py` and `document_scanner.py`:
|
|||||||
|
|
||||||
## Pending
|
## Pending
|
||||||
|
|
||||||
|
### M365 not scanning M365+GWS accounts after the 1.7.12 update — ⏳ awaiting verification
|
||||||
|
**Reported 2026-09-28.** After updating the server to 1.7.12, a scan did not cover the Microsoft 365 sources for accounts shown with the unified **M365 + GWS** pill, even though they were ticked. After running the update again the problem no longer appeared. Status: being tested by the maintainer; reopen with the details below if it comes back.
|
||||||
|
|
||||||
|
**What was verified (against a copy of real profile data, scans intercepted, nothing actually scanned):**
|
||||||
|
- Applying `STAFF-M365-GWS-FULL` and pressing Scan sends `POST /api/scan/start` with all 33 accounts (31 M365+GWS, 2 M365-only), each with its M365 GUID and no `google:` ids; the Google scan gets the 31 Google emails.
|
||||||
|
- Server side, the only 1.7.12 change that filters accounts is `scan_engine._resolve_scan_users()`, which drops ids starting with `google:`. M365+GWS accounts are sent with their M365 GUID, so they pass. No other change since 1.7.9 filters accounts.
|
||||||
|
|
||||||
|
**Likely cause: a half-applied update** (not proven — no logs from the first update):
|
||||||
|
- `update_gdpr.sh` only restarts the process if a systemd unit named `gdprscanner` exists; otherwise it prints "restart GDPRScanner manually" and the old Python keeps running against the new static JS.
|
||||||
|
- A browser tab left open through the update keeps running the old JS modules until reloaded.
|
||||||
|
- Both update paths auto-stash local edits (`git stash`), silently removing any hotfix made directly on the server.
|
||||||
|
|
||||||
|
**Checking a server after an update** (install dir `/opt/gdprscanner`):
|
||||||
|
```bash
|
||||||
|
git -C /opt/gdprscanner log --oneline -1 # expect the release commit on origin/main
|
||||||
|
cat /opt/gdprscanner/VERSION # expect the released version
|
||||||
|
git -C /opt/gdprscanner status --short # expect no output
|
||||||
|
git -C /opt/gdprscanner stash list # any entries = local changes the updater set aside
|
||||||
|
systemctl show gdprscanner -p ActiveEnterTimestamp # must be after the update (CLI/cron updates only)
|
||||||
|
journalctl -u gdprscanner --since "<update time>" --no-pager | grep "\[skus\] Loaded" # one line per app start
|
||||||
|
```
|
||||||
|
The **web-UI update** (Settings → Software update) restarts via `os.execv` in the same process (same PID), so `ActiveEnterTimestamp` does **not** change — use the `[skus] Loaded …` startup line instead: there must be one after the release commit was pushed. (`[ou-roles] Loaded …` lines are logged per account-list load, not per start.)
|
||||||
|
Then hard-refresh the browser (Cmd+Shift+R) before scanning. Also confirm `git -C /opt/gdprscanner remote -v` points at `https://git.fejl40.nu/Fejl40.nu/GDPRScanner.git` (the repo moved from GitHub).
|
||||||
|
|
||||||
|
**If it recurs, collect:** the log from "Starter scanning: …" onward (look for *"Ingen Microsoft 365-konti valgt"*, *"Sprang N konto(er) over, der kun findes i Google Workspace"*, or no Outlook/OneDrive lines at all); how the scan was started (profile / hand-ticked / scheduled job) and which profile; whether the Elev/Ansat filter or search was active; and the output of the checks above.
|
||||||
|
|
||||||
|
**Rollback:** 1.7.11 (`v1.7.11`) doesn't have this symptom but still sends Google-only accounts to Graph (404s). Reverting `b547622` on `main` restores that behaviour.
|
||||||
|
|
||||||
|
**Possible hardening (not started):**
|
||||||
|
1. The page notices when the server's `VERSION` has changed since it loaded and asks the user to reload.
|
||||||
|
2. `update_gdpr.sh` exits non-zero with a clear warning when it cannot restart the service, instead of only logging it.
|
||||||
|
|
||||||
|
**Size:** Small · **Priority:** High until verified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### #15 — Scan profiles ✅
|
### #15 — Scan profiles ✅
|
||||||
Named, reusable scan configurations. Full spec in SUGGESTIONS.md §15.
|
Named, reusable scan configurations. Full spec in SUGGESTIONS.md §15.
|
||||||
**Size:** Large · **Priority:** High
|
**Size:** Large · **Priority:** High
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# GDPR Scanner — Brugermanual
|
# GDPR Scanner — Brugermanual
|
||||||
|
|
||||||
Version 1.7.12
|
Version 1.7.13
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -683,4 +683,4 @@ For en typisk skole- eller kommunescanning er omkostningen ubetydelig — Claude
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*GDPR Scanner v1.7.12 — teknisk opsætning og konfiguration: se README.md*
|
*GDPR Scanner v1.7.13 — teknisk opsætning og konfiguration: se README.md*
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# GDPR Scanner — User Manual
|
# GDPR Scanner — User Manual
|
||||||
|
|
||||||
Version 1.7.12
|
Version 1.7.13
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -683,4 +683,4 @@ For a typical school or municipality scan the cost is negligible — Claude Haik
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*GDPR Scanner v1.7.12 — for technical setup and configuration see README.md*
|
*GDPR Scanner v1.7.13 — for technical setup and configuration see README.md*
|
||||||
+5
-1
@@ -744,9 +744,13 @@ def _build_article30_docx() -> tuple[bytes, str]:
|
|||||||
# user_ids are stored in the options JSON column
|
# user_ids are stored in the options JSON column
|
||||||
opts_json = json.loads(row["options"] or "{}") if row else {}
|
opts_json = json.loads(row["options"] or "{}") if row else {}
|
||||||
for u in opts_json.get("user_ids", []):
|
for u in opts_json.get("user_ids", []):
|
||||||
|
# Scheduled scans of editor-saved profiles stored bare id
|
||||||
|
# strings; .get() on those raised and dropped the whole map.
|
||||||
|
if not isinstance(u, dict):
|
||||||
|
continue
|
||||||
uid = u.get("id", "")
|
uid = u.get("id", "")
|
||||||
name = u.get("displayName", "")
|
name = u.get("displayName", "")
|
||||||
if uid and name:
|
if uid and name and name != uid:
|
||||||
_acct_map[uid] = name
|
_acct_map[uid] = name
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
+25
-1
@@ -226,7 +226,8 @@ class M365Connector:
|
|||||||
|
|
||||||
def lookup_user(self, upn: str) -> dict:
|
def lookup_user(self, upn: str) -> dict:
|
||||||
"""Look up one directory user by UPN or email (requires User.Read.All)."""
|
"""Look up one directory user by UPN or email (requires User.Read.All)."""
|
||||||
return self._get(f"/users/{upn}", {"$select": "id,displayName,mail,userPrincipalName"})
|
return self._get(f"/users/{upn}",
|
||||||
|
{"$select": "id,displayName,mail,userPrincipalName,assignedLicenses"})
|
||||||
|
|
||||||
def list_users(self, top: int = 999) -> list:
|
def list_users(self, top: int = 999) -> list:
|
||||||
"""List licensed domain users in the tenant (requires User.Read.All).
|
"""List licensed domain users in the tenant (requires User.Read.All).
|
||||||
@@ -1156,6 +1157,29 @@ class M365Connector:
|
|||||||
break
|
break
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def build_role_sku_map(self, users: list) -> dict:
|
||||||
|
"""The {skuId: skuPartNumber} map classify_user_role() needs.
|
||||||
|
|
||||||
|
Tenant SKUs (get_subscribed_skus) merged with a sample of per-user
|
||||||
|
licenseDetails — the tenant call may only return the admin's own SKUs.
|
||||||
|
Shared by GET /api/users and the scan engine so both classify roles
|
||||||
|
the same way. Never raises; an empty map means ID-only classification.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sku_map = self.get_subscribed_skus()
|
||||||
|
except Exception:
|
||||||
|
sku_map = {}
|
||||||
|
try:
|
||||||
|
per_user = self.build_sku_map_from_users(users)
|
||||||
|
if per_user:
|
||||||
|
added = len(set(per_user) - set(sku_map))
|
||||||
|
sku_map.update(per_user)
|
||||||
|
if added:
|
||||||
|
logger.info("[skus] merged %d additional SKU(s) from per-user licenseDetails", added)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return sku_map
|
||||||
|
|
||||||
def classify_user_role(self, assigned_licenses: list,
|
def classify_user_role(self, assigned_licenses: list,
|
||||||
sku_map: dict) -> str:
|
sku_map: dict) -> str:
|
||||||
"""Return 'student', 'staff', or 'other' based on assigned O365 licenses.
|
"""Return 'student', 'staff', or 'other' based on assigned O365 licenses.
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ Optional AI-powered NER replacing spaCy. Activated via `config.json` keys `claud
|
|||||||
- **`apply_update()` never restarts itself** — callers decide. Tests patch `_schedule_restart`; the auto-update thread calls `_restart_self()` directly.
|
- **`apply_update()` never restarts itself** — callers decide. Tests patch `_schedule_restart`; the auto-update thread calls `_restart_self()` directly.
|
||||||
- **Auto-update thread** — `start_auto_update_thread()` called from `gdpr_scanner.py` `__main__`. Hourly tick, applies at most once per 24 h when `config.json["auto_update"]` is true; skips (and retries next tick) while a scan runs.
|
- **Auto-update thread** — `start_auto_update_thread()` called from `gdpr_scanner.py` `__main__`. Hourly tick, applies at most once per 24 h when `config.json["auto_update"]` is true; skips (and retries next tick) while a scan runs.
|
||||||
- **`update_gdpr.sh`** — standalone CLI/cron equivalent of the same logic; keep stash/ff-only/requirements behaviour in sync.
|
- **`update_gdpr.sh`** — standalone CLI/cron equivalent of the same logic; keep stash/ff-only/requirements behaviour in sync.
|
||||||
|
- **Half-applied updates look like code bugs** — `update_gdpr.sh` restarts only if a `gdprscanner` systemd unit exists (else the old Python keeps running under the new static JS), open browser tabs keep old JS modules until reloaded, and both paths auto-stash local edits. Before debugging a post-update regression, verify the server state (HEAD, `VERSION`, clean `git status`, `git stash list`, service start time after the update) — see the "M365 not scanning M365+GWS accounts after the 1.7.12 update" entry in `TODO.md`.
|
||||||
|
|
||||||
## Viewer mode — routes/viewer.py
|
## Viewer mode — routes/viewer.py
|
||||||
|
|
||||||
|
|||||||
+3
-19
@@ -24,25 +24,9 @@ def get_users():
|
|||||||
out = []
|
out = []
|
||||||
seen = set()
|
seen = set()
|
||||||
|
|
||||||
# Build SKU map for role classification.
|
# SKU map for role classification — shared with the scan engine so the
|
||||||
# get_subscribed_skus() tries /subscribedSkus → /me/licenseDetails.
|
# account list and scanned cards classify Elev/Ansat identically.
|
||||||
# Then always merge per-user licenseDetails on top — this ensures we
|
sku_map = state.connector.build_role_sku_map(users)
|
||||||
# have skuPartNumbers for every distinct SKU in the tenant, not just
|
|
||||||
# the admin's own license (which is all /me/licenseDetails returns).
|
|
||||||
try:
|
|
||||||
sku_map = state.connector.get_subscribed_skus()
|
|
||||||
except Exception:
|
|
||||||
sku_map = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
per_user = state.connector.build_sku_map_from_users(users)
|
|
||||||
if per_user:
|
|
||||||
added = len(set(per_user) - set(sku_map))
|
|
||||||
sku_map.update(per_user)
|
|
||||||
if added:
|
|
||||||
logger.info("[skus] merged %d additional SKU(s) from per-user licenseDetails", added)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Load any manual role overrides set by the admin
|
# Load any manual role overrides set by the admin
|
||||||
_role_overrides = _load_role_overrides()
|
_role_overrides = _load_role_overrides()
|
||||||
|
|||||||
@@ -87,6 +87,49 @@ def _resolve_scan_users(conn, user_ids: list[dict]) -> list[dict]:
|
|||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_bare_users(conn, user_ids: list[dict]) -> list[dict]:
|
||||||
|
"""Fill in displayName and userRole for accounts given only by id.
|
||||||
|
|
||||||
|
Profiles saved by the profile editor (and scheduled scans of them) pass
|
||||||
|
plain id strings, which run_scan normalises to displayName=<id>,
|
||||||
|
userRole="other" — so their cards showed the GUID as the name and no
|
||||||
|
Elev/Ansat badge. Look those accounts up in the directory once, using the
|
||||||
|
same role classification as GET /api/users. Accounts that can't be looked
|
||||||
|
up keep what they had.
|
||||||
|
"""
|
||||||
|
bare = {id(u) for u in user_ids
|
||||||
|
if not u.get("displayName") or u.get("displayName") == u.get("id")}
|
||||||
|
if not bare:
|
||||||
|
return user_ids
|
||||||
|
|
||||||
|
directory: dict[str, dict] = {}
|
||||||
|
sku_map: dict = {}
|
||||||
|
try:
|
||||||
|
users = conn.list_users()
|
||||||
|
directory = {u["id"]: u for u in users if u.get("id")}
|
||||||
|
sku_map = conn.build_role_sku_map(users)
|
||||||
|
except Exception as e: # e.g. delegated mode without User.Read.All
|
||||||
|
logger.info("[run_scan] directory list unavailable for name lookup: %s", e)
|
||||||
|
|
||||||
|
enriched = []
|
||||||
|
for u in user_ids:
|
||||||
|
if id(u) in bare:
|
||||||
|
uid = u.get("id", "")
|
||||||
|
d = directory.get(uid)
|
||||||
|
if d is None:
|
||||||
|
try:
|
||||||
|
d = conn.lookup_user(uid) # not in the licensed-member list
|
||||||
|
except Exception:
|
||||||
|
d = None
|
||||||
|
if d:
|
||||||
|
_email = d.get("mail") or d.get("userPrincipalName", "")
|
||||||
|
u = {**u, "displayName": _resolve_display_name(d.get("displayName", ""), _email, uid)}
|
||||||
|
if d.get("assignedLicenses") is not None and u.get("userRole", "other") == "other":
|
||||||
|
u["userRole"] = conn.classify_user_role(d.get("assignedLicenses", []), sku_map)
|
||||||
|
enriched.append(u)
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
def _get_scan_abort():
|
def _get_scan_abort():
|
||||||
return _state._scan_abort
|
return _state._scan_abort
|
||||||
|
|
||||||
@@ -686,6 +729,7 @@ def run_scan(options: dict):
|
|||||||
me_info.get("mail") or me_info.get("userPrincipalName", "me"))}]
|
me_info.get("mail") or me_info.get("userPrincipalName", "me"))}]
|
||||||
else:
|
else:
|
||||||
user_ids = _resolve_scan_users(conn, user_ids)
|
user_ids = _resolve_scan_users(conn, user_ids)
|
||||||
|
user_ids = _enrich_bare_users(conn, user_ids)
|
||||||
sample = user_ids[0] if user_ids else None
|
sample = user_ids[0] if user_ids else None
|
||||||
logger.info("[run_scan] user_ids: %d entries, type=%s, sample=%s",
|
logger.info("[run_scan] user_ids: %d entries, type=%s, sample=%s",
|
||||||
len(user_ids), type(user_ids).__name__, sample)
|
len(user_ids), type(user_ids).__name__, sample)
|
||||||
|
|||||||
+2
-2
@@ -57,7 +57,7 @@ Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — thos
|
|||||||
|
|
||||||
## Card user/group badge — results.js
|
## Card user/group badge — results.js
|
||||||
|
|
||||||
- **`_accountPill(f)`** builds the account/role pill for both card layouts (list + grid). The **group badge is driven by `f.user_role`** (`student`/`staff`) alone, so it renders even with no display name — items from scans saved before `account_name` was persisted (DB migration 11) have only `user_role` + `account_id`. The user label resolves best-effort: `f.account_name` → `S._allUsers` match (by `id` or `email`) → email-style `account_id` → omit. Do not re-nest the role badge inside an `account_name` check (the old bug) — that hides the group badge for legacy items. Both layouts call `_accountPill(f)`; keep them sharing the one helper.
|
- **`_accountPill(f)`** builds the account/role pill for both card layouts (list + grid). The **group badge is driven by `f.user_role`** (`student`/`staff`) alone, so it renders even with no display name — items from scans saved before `account_name` was persisted (DB migration 11) have only `user_role` + `account_id`. The user label resolves best-effort: `f.account_name` → `S._allUsers` match (by `id` or `email`) → email-style `account_id` → omit. An `account_name` equal to `account_id` (a GUID stored by older scheduled scans of editor-saved profiles) counts as missing, and a role other than `student`/`staff` is filled from the same `S._allUsers` match. Do not re-nest the role badge inside an `account_name` check (the old bug) — that hides the group badge for legacy items. Both layouts call `_accountPill(f)`; keep them sharing the one helper.
|
||||||
|
|
||||||
## CPR cross-referencing — results.js
|
## CPR cross-referencing — results.js
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — thos
|
|||||||
|
|
||||||
- **`scheduler.js` strings must use `t()`** — frequency labels, "Next", "Running...", "Disabled", empty-job text, and empty-history text all have translation keys. Do not hard-code English strings in `schedLoad()` or `schedRenderJobs()`.
|
- **`scheduler.js` strings must use `t()`** — frequency labels, "Next", "Running...", "Disabled", empty-job text, and empty-history text all have translation keys. Do not hard-code English strings in `schedLoad()` or `schedRenderJobs()`.
|
||||||
- **Scheduler UI — `schedToggleReportOnly()`** — dims the Profile row, shows/hides `#schedReportOnlyHint`, and forces `#schedAutoEmail` checked. Called from the checkbox `onchange` handler and at the start of `schedAddJob()` / `schedEditJob()`.
|
- **Scheduler UI — `schedToggleReportOnly()`** — dims the Profile row, shows/hides `#schedReportOnlyHint`, and forces `#schedAutoEmail` checked. Called from the checkbox `onchange` handler and at the start of `schedAddJob()` / `schedEditJob()`.
|
||||||
- **Profile editor accounts** — default to unchecked. Only explicitly saved `user_ids` are checked. Saved IDs not in `S._allUsers` (`manual:<email>`, users not loaded yet, users gone from the directory) are rendered by `_unresolvedAccountRows()` as checked rows so the next save keeps them — do not go back to building rows from `S._allUsers` alone (that silently dropped them on save). `user_ids: "all"` is kept as `"all"` when saved before users have loaded.
|
- **Profile editor accounts** — default to unchecked. Only explicitly saved `user_ids` are checked. Saved IDs not in `S._allUsers` (`manual:<email>`, users not loaded yet, users gone from the directory) are rendered by `_unresolvedAccountRows()` as checked rows so the next save keeps them — do not go back to building rows from `S._allUsers` alone (that silently dropped them on save). `user_ids: "all"` is kept as `"all"` when saved before users have loaded. The editor saves each account as `{id, displayName, userRole}` (plus `email`/`manual` for manual accounts), the same shape as 💾 — not a bare id string: scheduled scans pass profile accounts straight to `run_scan`, and bare ids produced GUID-named cards with no role badge. Server side, `scan_engine._enrich_bare_users()` names any bare ids that remain (older profiles) using `conn.build_role_sku_map()`, the same role classification as `GET /api/users`.
|
||||||
- **Manual accounts must be scannable** — they aren't in the `/api/users` directory list, so:
|
- **Manual accounts must be scannable** — they aren't in the `/api/users` directory list, so:
|
||||||
- `getSelectedUsers()` treats a missing `platform` as `'m365'`. Sidebar `addUserManually()` users historically had no platform and were silently excluded from every scan. Do not go back to a strict `u.platform === 'm365'` check.
|
- `getSelectedUsers()` treats a missing `platform` as `'m365'`. Sidebar `addUserManually()` users historically had no platform and were silently excluded from every scan. Do not go back to a strict `u.platform === 'm365'` check.
|
||||||
- The profile editor's "+ Tilføj konto" looks the email up via `/api/users/lookup` and stores `{id: <directory id>, displayName, email, manual: true}`. It falls back to `"manual:<email>"` only when the lookup fails. Checkboxes with `data-manual` save as objects; everything else saves as plain id strings.
|
- The profile editor's "+ Tilføj konto" looks the email up via `/api/users/lookup` and stores `{id: <directory id>, displayName, email, manual: true}`. It falls back to `"manual:<email>"` only when the lookup fails. Checkboxes with `data-manual` save as objects; everything else saves as plain id strings.
|
||||||
|
|||||||
+18
-7
@@ -437,8 +437,9 @@ function _openEditorForProfile(profile) {
|
|||||||
const accountRows = S._allUsers.map(u => {
|
const accountRows = S._allUsers.map(u => {
|
||||||
// Only check if the user was explicitly saved — default to unchecked like the main window
|
// Only check if the user was explicitly saved — default to unchecked like the main window
|
||||||
const checked = isSaved(u) ? 'checked' : '';
|
const checked = isSaved(u) ? 'checked' : '';
|
||||||
const manualAttrs = u.manual
|
// data-name lets the save keep the display name with the id (see _pmgmtSaveFullEdit)
|
||||||
? ` data-manual="1" data-name="${_esc(u.displayName || '')}" data-email="${_esc(u.email || '')}"` : '';
|
const manualAttrs = ` data-name="${_esc(u.displayName || '')}"` + (u.manual
|
||||||
|
? ` data-manual="1" data-email="${_esc(u.email || '')}"` : '');
|
||||||
const platBadge = u.platform === 'both' ? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:linear-gradient(90deg,#E6F1FB 50%,#EAF3DE 50%);color:#1a4a1a;font-weight:500;border:0.5px solid #b5d4b5">M365+GWS</span>'
|
const platBadge = u.platform === 'both' ? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:linear-gradient(90deg,#E6F1FB 50%,#EAF3DE 50%);color:#1a4a1a;font-weight:500;border:0.5px solid #b5d4b5">M365+GWS</span>'
|
||||||
: (u.platform || 'm365') === 'google' ? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#EAF3DE;color:#3B6D11;font-weight:500">GWS</span>'
|
: (u.platform || 'm365') === 'google' ? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#EAF3DE;color:#3B6D11;font-weight:500">GWS</span>'
|
||||||
: '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#E6F1FB;color:#185FA5;font-weight:500">M365</span>';
|
: '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#E6F1FB;color:#185FA5;font-weight:500">M365</span>';
|
||||||
@@ -550,8 +551,9 @@ function _unresolvedAccountRows(savedList) {
|
|||||||
const label = (typeof u === 'object' && u.displayName) || id.replace(/^(manual|google):/, '');
|
const label = (typeof u === 'object' && u.displayName) || id.replace(/^(manual|google):/, '');
|
||||||
const badge = isManual ? t('m365_profile_acct_manual', 'Manual')
|
const badge = isManual ? t('m365_profile_acct_manual', 'Manual')
|
||||||
: t('m365_profile_acct_unresolved', 'Not loaded');
|
: t('m365_profile_acct_unresolved', 'Not loaded');
|
||||||
const manualAttrs = _isManualEntry(u) && typeof u === 'object'
|
const savedName = typeof u === 'object' && u.displayName && u.displayName !== id ? u.displayName : '';
|
||||||
? ` data-manual="1" data-name="${_esc(u.displayName || '')}" data-email="${_esc(u.email || '')}"` : '';
|
const manualAttrs = (savedName ? ` data-name="${_esc(savedName)}"` : '') + (isManual && typeof u === 'object'
|
||||||
|
? ` data-manual="1" data-email="${_esc(u.email || '')}"` : '');
|
||||||
return `<label class="pmgmt-acct-row" data-uid="${_esc(id)}" data-role="${_esc((typeof u === 'object' && u.userRole) || 'other')}"><input type="checkbox" checked data-uid="${_esc(id)}"${manualAttrs}><span style="flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${_esc(id)}">${_esc(label)}</span><span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#D3D1C7;color:#444441">${_esc(badge)}</span></label>`;
|
return `<label class="pmgmt-acct-row" data-uid="${_esc(id)}" data-role="${_esc((typeof u === 'object' && u.userRole) || 'other')}"><input type="checkbox" checked data-uid="${_esc(id)}"${manualAttrs}><span style="flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${_esc(id)}">${_esc(label)}</span><span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#D3D1C7;color:#444441">${_esc(badge)}</span></label>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -737,9 +739,18 @@ async function _pmgmtSaveFullEdit() {
|
|||||||
const allSources = m365Sources.concat(effectiveGoogleSources).concat(effectiveFileSources);
|
const allSources = m365Sources.concat(effectiveGoogleSources).concat(effectiveFileSources);
|
||||||
let user_ids = Array.from(document.querySelectorAll('#pmgmtAcctList input[type=checkbox]:checked'))
|
let user_ids = Array.from(document.querySelectorAll('#pmgmtAcctList input[type=checkbox]:checked'))
|
||||||
.filter(cb => cb.dataset.uid)
|
.filter(cb => cb.dataset.uid)
|
||||||
.map(cb => cb.dataset.manual
|
.map(cb => {
|
||||||
? {id: cb.dataset.uid, displayName: cb.dataset.name || '', email: cb.dataset.email || '', manual: true}
|
// Save name + role with the id (same shape as 💾 / getSelectedUsers()):
|
||||||
: cb.dataset.uid);
|
// scheduled scans pass these straight to the scan, and a bare id became
|
||||||
|
// the card's "name" with no Elev/Ansat badge.
|
||||||
|
const role = cb.closest('label')?.dataset.role || 'other';
|
||||||
|
if (cb.dataset.manual) {
|
||||||
|
return {id: cb.dataset.uid, displayName: cb.dataset.name || '', email: cb.dataset.email || '',
|
||||||
|
userRole: role, manual: true};
|
||||||
|
}
|
||||||
|
return cb.dataset.name ? {id: cb.dataset.uid, displayName: cb.dataset.name, userRole: role}
|
||||||
|
: cb.dataset.uid;
|
||||||
|
});
|
||||||
// "all" profile opened before users loaded: there were no rows to tick, so keep "all"
|
// "all" profile opened before users loaded: there were no rows to tick, so keep "all"
|
||||||
if (window._pmgmtSavedAll && !S._allUsers.length && !user_ids.length) user_ids = 'all';
|
if (window._pmgmtSavedAll && !S._allUsers.length && !user_ids.length) user_ids = 'all';
|
||||||
const updated = {
|
const updated = {
|
||||||
|
|||||||
+17
-7
@@ -32,21 +32,31 @@ const SOURCE_BADGES = {
|
|||||||
// email), falling back to an email-style account_id. Returns '' when there is
|
// email), falling back to an email-style account_id. Returns '' when there is
|
||||||
// neither a label nor a role to show.
|
// neither a label nor a role to show.
|
||||||
function _accountPill(f) {
|
function _accountPill(f) {
|
||||||
const roleBadge =
|
let role = f.user_role;
|
||||||
f.user_role === 'student' ? '<span class="role-badge">' + t('role_student', 'Elev') + '</span>' :
|
|
||||||
f.user_role === 'staff' ? '<span class="role-badge">' + t('role_staff', 'Ansat') + '</span>' : '';
|
|
||||||
let label = f.account_name || '';
|
let label = f.account_name || '';
|
||||||
if (!label && f.account_id) {
|
// Scheduled scans of editor-saved profiles stored the account id (a GUID) as
|
||||||
|
// the name, with role "other" — treat that like a missing name.
|
||||||
|
if (label && f.account_id && label === String(f.account_id)) label = '';
|
||||||
|
const needName = !label && f.account_id;
|
||||||
|
const needRole = role !== 'student' && role !== 'staff';
|
||||||
|
if (f.account_id && (needName || needRole)) {
|
||||||
const aid = String(f.account_id);
|
const aid = String(f.account_id);
|
||||||
const u = (S._allUsers || []).find(function(u) {
|
const u = (S._allUsers || []).find(function(u) {
|
||||||
return u.id === f.account_id ||
|
return u.id === f.account_id ||
|
||||||
(u.email && u.email.toLowerCase() === aid.toLowerCase());
|
(u.email && u.email.toLowerCase() === aid.toLowerCase());
|
||||||
});
|
});
|
||||||
if (u) label = u.displayName || '';
|
if (u) {
|
||||||
else if (aid.includes('@')) label = aid; // an email is already human-readable
|
if (needName) label = u.displayName || '';
|
||||||
|
if (needRole) role = u.userRole;
|
||||||
|
} else if (needName && aid.includes('@')) {
|
||||||
|
label = aid; // an email is already human-readable
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
const roleBadge =
|
||||||
|
role === 'student' ? '<span class="role-badge">' + t('role_student', 'Elev') + '</span>' :
|
||||||
|
role === 'staff' ? '<span class="role-badge">' + t('role_staff', 'Ansat') + '</span>' : '';
|
||||||
if (!label && !roleBadge) return '';
|
if (!label && !roleBadge) return '';
|
||||||
const title = label || f.user_role || '';
|
const title = label || role || '';
|
||||||
return '<span class="account-pill" title="' + esc(title) + '">' + roleBadge + (label ? esc(label) : '') + '</span>';
|
return '<span class="account-pill" title="' + esc(title) + '">' + roleBadge + (label ? esc(label) : '') + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
Result cards must show the account's name and Elev/Ansat role.
|
||||||
|
|
||||||
|
Regression: profiles saved by the profile editor stored bare id strings, and
|
||||||
|
scheduled scans pass those straight to run_scan, which normalised them to
|
||||||
|
displayName=<id>, userRole="other" — so cards showed the GUID as the name and
|
||||||
|
no role badge. run_scan now looks bare accounts up in the directory.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import scan_engine
|
||||||
|
|
||||||
|
|
||||||
|
class _Conn:
|
||||||
|
"""Directory stub: list_users + role classification + single lookups."""
|
||||||
|
|
||||||
|
def __init__(self, directory=None, extra=None, list_fails=False):
|
||||||
|
self.directory = directory or []
|
||||||
|
self.extra = extra or {} # users only reachable via lookup_user
|
||||||
|
self.list_fails = list_fails
|
||||||
|
self.list_calls = 0
|
||||||
|
self.lookups: list[str] = []
|
||||||
|
|
||||||
|
def list_users(self):
|
||||||
|
self.list_calls += 1
|
||||||
|
if self.list_fails:
|
||||||
|
raise RuntimeError("Authorization_RequestDenied")
|
||||||
|
return list(self.directory)
|
||||||
|
|
||||||
|
def build_role_sku_map(self, users):
|
||||||
|
return {"sku-staff": "STANDARDWOFFPACK_FACULTY", "sku-stud": "STANDARDWOFFPACK_STUDENT"}
|
||||||
|
|
||||||
|
def classify_user_role(self, assigned_licenses, sku_map):
|
||||||
|
names = {sku_map.get(l.get("skuId", ""), "") for l in assigned_licenses}
|
||||||
|
if any("FACULTY" in n for n in names):
|
||||||
|
return "staff"
|
||||||
|
if any("STUDENT" in n for n in names):
|
||||||
|
return "student"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
def lookup_user(self, uid):
|
||||||
|
self.lookups.append(uid)
|
||||||
|
if uid in self.extra:
|
||||||
|
return self.extra[uid]
|
||||||
|
raise RuntimeError("Request_ResourceNotFound")
|
||||||
|
|
||||||
|
|
||||||
|
DIRECTORY = [
|
||||||
|
{"id": "guid-henrik", "displayName": "Henrik Højmark", "mail": "henrik@school.dk",
|
||||||
|
"assignedLicenses": [{"skuId": "sku-staff"}]},
|
||||||
|
{"id": "guid-elna", "displayName": "Elna Sofie", "mail": "elna@school.dk",
|
||||||
|
"assignedLicenses": [{"skuId": "sku-stud"}]},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _bare(uid): # what run_scan makes of a bare id string
|
||||||
|
return {"id": uid, "displayName": uid, "userRole": "other"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnrichBareUsers:
|
||||||
|
def test_bare_ids_get_name_and_role_from_directory(self):
|
||||||
|
out = scan_engine._enrich_bare_users(_Conn(DIRECTORY), [_bare("guid-henrik"), _bare("guid-elna")])
|
||||||
|
assert out == [
|
||||||
|
{"id": "guid-henrik", "displayName": "Henrik Højmark", "userRole": "staff"},
|
||||||
|
{"id": "guid-elna", "displayName": "Elna Sofie", "userRole": "student"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_named_accounts_untouched_and_directory_not_listed(self):
|
||||||
|
conn = _Conn(DIRECTORY)
|
||||||
|
users = [{"id": "guid-henrik", "displayName": "Henrik Højmark", "userRole": "staff"}]
|
||||||
|
assert scan_engine._enrich_bare_users(conn, users) == users
|
||||||
|
assert conn.list_calls == 0 # no directory call when nothing is bare
|
||||||
|
|
||||||
|
def test_existing_role_is_kept(self):
|
||||||
|
# e.g. a role the UI already knew — only a missing ("other") role is filled
|
||||||
|
out = scan_engine._enrich_bare_users(
|
||||||
|
_Conn(DIRECTORY), [{"id": "guid-elna", "displayName": "guid-elna", "userRole": "staff"}])
|
||||||
|
assert out[0]["displayName"] == "Elna Sofie" and out[0]["userRole"] == "staff"
|
||||||
|
|
||||||
|
def test_account_outside_member_list_is_looked_up(self):
|
||||||
|
extra = {"guid-guest": {"id": "guid-guest", "displayName": "Gæst Lærer", "mail": "g@school.dk",
|
||||||
|
"assignedLicenses": [{"skuId": "sku-staff"}]}}
|
||||||
|
conn = _Conn(DIRECTORY, extra=extra)
|
||||||
|
out = scan_engine._enrich_bare_users(conn, [_bare("guid-guest")])
|
||||||
|
assert out == [{"id": "guid-guest", "displayName": "Gæst Lærer", "userRole": "staff"}]
|
||||||
|
assert conn.lookups == ["guid-guest"]
|
||||||
|
|
||||||
|
def test_directory_list_unavailable_falls_back_to_lookup(self):
|
||||||
|
extra = {"guid-henrik": DIRECTORY[0]}
|
||||||
|
out = scan_engine._enrich_bare_users(_Conn(list_fails=True, extra=extra), [_bare("guid-henrik")])
|
||||||
|
assert out[0]["displayName"] == "Henrik Højmark"
|
||||||
|
|
||||||
|
def test_unknown_account_keeps_what_it_had(self):
|
||||||
|
out = scan_engine._enrich_bare_users(_Conn(DIRECTORY), [_bare("guid-gone")])
|
||||||
|
assert out == [_bare("guid-gone")]
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildRoleSkuMap:
|
||||||
|
def _conn(self):
|
||||||
|
from m365_connector import M365Connector
|
||||||
|
return M365Connector.__new__(M365Connector) # no auth needed for these calls
|
||||||
|
|
||||||
|
def test_merges_tenant_and_per_user_skus(self, monkeypatch):
|
||||||
|
conn = self._conn()
|
||||||
|
monkeypatch.setattr(conn, "get_subscribed_skus", lambda: {"a": "ADMIN_SKU"})
|
||||||
|
monkeypatch.setattr(conn, "build_sku_map_from_users", lambda users: {"b": "FACULTY_SKU"})
|
||||||
|
assert conn.build_role_sku_map([{"id": "x"}]) == {"a": "ADMIN_SKU", "b": "FACULTY_SKU"}
|
||||||
|
|
||||||
|
def test_never_raises(self, monkeypatch):
|
||||||
|
conn = self._conn()
|
||||||
|
def _boom(*a, **kw): raise RuntimeError("403")
|
||||||
|
monkeypatch.setattr(conn, "get_subscribed_skus", _boom)
|
||||||
|
monkeypatch.setattr(conn, "build_sku_map_from_users", _boom)
|
||||||
|
assert conn.build_role_sku_map([]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestUsersRouteUsesSharedMap:
|
||||||
|
def test_api_users_classifies_with_build_role_sku_map(self, monkeypatch):
|
||||||
|
import gdpr_scanner
|
||||||
|
from routes import state
|
||||||
|
import routes.users as users_mod
|
||||||
|
monkeypatch.setattr(users_mod, "_load_role_overrides", lambda: {})
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.is_app_mode = True
|
||||||
|
conn.list_users.return_value = DIRECTORY
|
||||||
|
conn.build_role_sku_map.return_value = {"sku-staff": "X_FACULTY"}
|
||||||
|
conn.classify_user_role.side_effect = lambda lic, m: "staff" if m else "other"
|
||||||
|
monkeypatch.setattr(state, "connector", conn)
|
||||||
|
gdpr_scanner.app.config["TESTING"] = True
|
||||||
|
with gdpr_scanner.app.test_client() as c:
|
||||||
|
data = c.get("/api/users").get_json()
|
||||||
|
conn.build_role_sku_map.assert_called_once_with(DIRECTORY)
|
||||||
|
assert {u["userRole"] for u in data["users"]} == {"staff"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunScanNamesBareIds:
|
||||||
|
def test_scheduled_style_bare_ids_are_named_in_the_scan(self, monkeypatch):
|
||||||
|
"""End to end: bare id strings (as a scheduled scan passes them) reach the
|
||||||
|
per-user collection with their real name."""
|
||||||
|
from routes import state
|
||||||
|
import sse
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(scan_engine, "broadcast", lambda ev, data: sent.append((ev, data)))
|
||||||
|
monkeypatch.setattr(scan_engine, "DB_OK", False)
|
||||||
|
monkeypatch.setattr(scan_engine, "_load_checkpoint", lambda key, **kw: None)
|
||||||
|
monkeypatch.setattr(scan_engine, "_save_checkpoint", lambda *a, **kw: None)
|
||||||
|
monkeypatch.setattr(scan_engine, "_clear_checkpoint", lambda **kw: None)
|
||||||
|
monkeypatch.setattr(scan_engine, "_load_role_overrides", lambda: {})
|
||||||
|
monkeypatch.setattr(state, "flagged_items", [])
|
||||||
|
stub = _Conn(DIRECTORY)
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.is_app_mode = True
|
||||||
|
conn.list_users.side_effect = stub.list_users
|
||||||
|
conn.build_role_sku_map.side_effect = stub.build_role_sku_map
|
||||||
|
conn.classify_user_role.side_effect = stub.classify_user_role
|
||||||
|
conn.list_all_mail_folders_for.return_value = []
|
||||||
|
monkeypatch.setattr(state, "connector", conn)
|
||||||
|
saved = sse._current_scan_id
|
||||||
|
try:
|
||||||
|
scan_engine.run_scan({"sources": ["email"], "options": {},
|
||||||
|
"user_ids": ["guid-henrik", "guid-elna"]})
|
||||||
|
finally:
|
||||||
|
sse._current_scan_id = saved
|
||||||
|
phases = " | ".join(d.get("phase", "") for e, d in sent if e == "scan_phase")
|
||||||
|
assert "Henrik Højmark" in phases and "Elna Sofie" in phases
|
||||||
|
assert "guid-henrik" not in phases
|
||||||
Reference in new issue
Block a user