diff --git a/lang/da.json b/lang/da.json index 0fcf6d6..09cbe41 100644 --- a/lang/da.json +++ b/lang/da.json @@ -260,6 +260,7 @@ "m365_admin_note": "Viser kun din konto. For at liste alle brugere skal en administrator give samtykke til User.Read.All i Azure Portal, eller tilføj konti manuelt nedenfor.", "m365_btn_scan": "Scan", "m365_btn_stop": "Stop", + "m365_no_m365_accounts": "Ingen Microsoft 365-konti valgt — Microsoft 365-kilderne springes over.", "m365_btn_stopping": "Stopper…", "m365_scan_stopping": "Stopper scanning — færdiggør det aktuelle element…", "m365_scan_stopped": "Scanning stoppet.", @@ -517,6 +518,7 @@ "m365_filter_special_only": "Art. 9 følsomme data", "m365_badge_special": "Art.9", "m365_phase_scanning": "Scanner…", + "m365_skipped_google_accounts": "Sprang {n} konto(er) over, der kun findes i Google Workspace — ikke i Microsoft 365", "a30_special_cat": "Særlige kategorier (artikel 9)", "a30_special_cat_note": "Disse elementer indeholder helbredsoplysninger, straffeoplysninger, biometriske data, religiøse, etniske, fagforeningsmæssige, politiske eller seksuelle oplysninger. Et eksplicit retsgrundlag (art. 9, stk. 2) og eventuelt en konsekvensanalyse (art. 35) er påkrævet.", "a30_col_special": "Art. 9", diff --git a/lang/de.json b/lang/de.json index 6232f72..f48fd5b 100644 --- a/lang/de.json +++ b/lang/de.json @@ -260,6 +260,7 @@ "m365_admin_note": "Es wird nur Ihr Konto angezeigt. Um alle Benutzer aufzulisten, muss ein Administrator User.Read.All im Azure Portal genehmigen oder Konten manuell unten hinzufügen.", "m365_btn_scan": "Scannen", "m365_btn_stop": "Stop", + "m365_no_m365_accounts": "Keine Microsoft 365-Konten ausgewählt — Microsoft 365-Quellen werden übersprungen.", "m365_btn_stopping": "Wird gestoppt…", "m365_scan_stopping": "Scan wird gestoppt — aktuelles Element wird abgeschlossen…", "m365_scan_stopped": "Scan gestoppt.", @@ -517,6 +518,7 @@ "m365_filter_special_only": "Art. 9 besondere Kategorien", "m365_badge_special": "Art.9", "m365_phase_scanning": "Scanne…", + "m365_skipped_google_accounts": "{n} reine Google Workspace-Konten übersprungen — nicht in Microsoft 365", "a30_special_cat": "Besondere Kategorien (Artikel 9)", "a30_special_cat_note": "Diese Elemente enthalten Gesundheits-, Straf-, biometrische, religiöse, ethnische, gewerkschaftliche, politische oder sexuelle Daten. Eine ausdrückliche Rechtsgrundlage (Art. 9 Abs. 2) und ggf. eine DSFA (Art. 35) sind erforderlich.", "a30_col_special": "Art. 9", diff --git a/lang/en.json b/lang/en.json index 3dd1cb9..43298e6 100644 --- a/lang/en.json +++ b/lang/en.json @@ -260,6 +260,7 @@ "m365_admin_note": "Only showing your account. To list all users, an admin must grant User.Read.All consent in Azure Portal, or add accounts manually below.", "m365_btn_scan": "Scan", "m365_btn_stop": "Stop", + "m365_no_m365_accounts": "No Microsoft 365 accounts selected — skipping the Microsoft 365 sources.", "m365_btn_stopping": "Stopping…", "m365_scan_stopping": "Stopping scan — finishing the current item…", "m365_scan_stopped": "Scan stopped.", @@ -517,6 +518,7 @@ "m365_filter_special_only": "Art. 9 special category", "m365_badge_special": "Art.9", "m365_phase_scanning": "Scanning…", + "m365_skipped_google_accounts": "Skipped {n} Google Workspace-only account(s) — not in Microsoft 365", "a30_special_cat": "Art. 9 special category items", "a30_special_cat_note": "These items contain health, criminal, biometric, religious, ethnic, trade union, political, or sexual orientation data. An explicit legal basis (Art. 9(2)) and possibly a DPIA (Art. 35) is required.", "a30_col_special": "Art. 9", diff --git a/scan_engine.py b/scan_engine.py index e1b7100..f1f429d 100644 --- a/scan_engine.py +++ b/scan_engine.py @@ -42,18 +42,27 @@ except ImportError: from routes import state as _state _MANUAL_PREFIX = "manual:" +_GOOGLE_PREFIX = "google:" -def _resolve_manual_users(conn, user_ids: list[dict]) -> list[dict]: - """Resolve profile accounts saved as "manual:" to their directory id. +def _resolve_scan_users(conn, user_ids: list[dict]) -> list[dict]: + """Turn the requested accounts into Graph user ids for the M365 scan. - Older profile-editor saves (and saves made while M365 was not connected) - store manual accounts by email only; Graph needs the user id. Accounts that - can't be resolved are reported and skipped — never widened to "all users". + - "manual:" (older profile-editor saves, or saves made while M365 + was not connected) is looked up to get the directory id. + - "google:" is a Google-Workspace-only account: it has no M365 + mailbox/drive and Graph 404s on it. Profiles and older UI selections can + contain them, so they are dropped here (the Google scan covers them). + Accounts that can't be used are reported and skipped — never widened to + "all users". """ resolved, seen = [], set() + skipped_google = [] for u in user_ids: uid = u.get("id", "") + if uid.startswith(_GOOGLE_PREFIX): + skipped_google.append(uid[len(_GOOGLE_PREFIX):]) + continue if uid.startswith(_MANUAL_PREFIX): upn = uid[len(_MANUAL_PREFIX):] try: @@ -69,6 +78,12 @@ def _resolve_manual_users(conn, user_ids: list[dict]) -> list[dict]: continue seen.add(u["id"]) resolved.append(u) + if skipped_google: + logger.info("[run_scan] skipped %d Google-only account(s) in M365 scan", len(skipped_google)) + broadcast("scan_phase", {"phase": LANG.get( + "m365_skipped_google_accounts", + "Skipped {n} Google Workspace-only account(s) — not in Microsoft 365").replace( + "{n}", str(len(skipped_google)))}) return resolved @@ -670,7 +685,7 @@ def run_scan(options: dict): me_info.get("displayName", ""), me_info.get("mail") or me_info.get("userPrincipalName", "me"))}] else: - user_ids = _resolve_manual_users(conn, user_ids) + user_ids = _resolve_scan_users(conn, user_ids) sample = user_ids[0] if user_ids else None logger.info("[run_scan] user_ids: %d entries, type=%s, sample=%s", len(user_ids), type(user_ids).__name__, sample) diff --git a/static/js/CLAUDE.md b/static/js/CLAUDE.md index 217e470..93ef6f6 100644 --- a/static/js/CLAUDE.md +++ b/static/js/CLAUDE.md @@ -91,7 +91,7 @@ Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — thos - The profile editor's "+ Tilføj konto" looks the email up via `/api/users/lookup` and stores `{id: , displayName, email, manual: true}`. It falls back to `"manual:"` only when the lookup fails. Checkboxes with `data-manual` save as objects; everything else saves as plain id strings. - `_applyProfile()` → `_addProfileManualUsers()` pushes manual accounts into `S._allUsers` (`manual: true`, `platform: 'm365'`). Legacy `manual:` entries are looked up and the pending ID is rewritten to the resolved one. Without this the account can never be ticked in the sidebar, so it's never scanned. - `_userListFinal()` keys off `S._m365UsersLoaded`, not `S._allUsers.length`, because manual users can be pushed before the directory loads. - - Server side, `scan_engine._resolve_manual_users()` resolves any remaining `manual:` (e.g. from scheduled scans) via `conn.lookup_user()`. It skips and reports failures and never widens to "all users". + - Server side, `scan_engine._resolve_scan_users()` resolves any remaining `manual:` (e.g. from scheduled scans) via `conn.lookup_user()`, and drops `google:` (Google-only accounts, which Graph 404s on). It skips and reports failures and never widens to "all users". - **Pending profile users wait for the Google merge** — `_applyPendingProfileUsers()` keeps `window._pendingProfileUserIds` until every ID is matched or `_userListFinal()` (users loaded, and Google either not connected or `S._googleUsersMerged`). `_mergeGoogleUsers()` re-applies in its `finally`. A saved `google:` also matches an M365 user merged to `platform: 'both'` via `u.googleEmail`. Do not clear the pending IDs unconditionally — Google-only users would stay unticked. - **Date presets** — stored as `years * 365` (integer days). Do not use `* 365.25`. - **`copyTokenLink` is async** — called from `onclick` as fire-and-forget. Do not make it synchronous. diff --git a/static/js/scan.js b/static/js/scan.js index 817ee23..ef457a4 100644 --- a/static/js/scan.js +++ b/static/js/scan.js @@ -569,7 +569,13 @@ function startScan(resume) { document.getElementById('resumeBanner').style.display = 'none'; document.getElementById('logPanel').innerHTML = ''; try { sessionStorage.removeItem(_LOG_SESSION_KEY); } catch(e) {} - S._m365ScanRunning = sources.length > 0; + // The M365 scan needs explicit accounts — the server treats an empty list as + // "every user in the tenant". With only Google accounts ticked, skip it. + const runM365 = sources.length > 0 && user_ids.length > 0; + if (sources.length && !runM365) { + log(t('m365_no_m365_accounts', 'No Microsoft 365 accounts selected — skipping the Microsoft 365 sources.'), 'warn'); + } + S._m365ScanRunning = runM365; S._googleScanRunning = googleSources.length > 0; S._fileScanRunning = fileSources.length > 0; S._srcPct = { m365: 0, google: 0, file: 0 }; @@ -613,8 +619,8 @@ function startScan(resume) { } setTimeout(() => { - // Fire M365 scan if any M365 sources are selected - if (sources.length > 0) { + // Fire M365 scan if any M365 sources and accounts are selected + if (runM365) { fetch('/api/scan/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({sources, user_ids, options, resume: !!resume, diff --git a/static/js/users.js b/static/js/users.js index ae76c2d..81c0117 100644 --- a/static/js/users.js +++ b/static/js/users.js @@ -461,10 +461,17 @@ function getSelectedUsers() { { id: u.id, displayName: u.displayName, userRole: u.userRole || 'other' }, u.manual ? { manual: true, email: u.email || '' } : {})); } - // Fallback to DOM if S._allUsers not yet populated - return Array.from(document.querySelectorAll('.account-check:checked')).map(cb => ({ - id: cb.dataset.id, displayName: cb.dataset.name, userRole: cb.dataset.role || 'other' - })); + // Once the user list is loaded it is authoritative: no ticked M365 users means + // none. (Falling back to the DOM here collected every ticked checkbox — + // including Google-only "google:" rows — and sent them to the M365 + // scan, where Graph 404s on them.) + if (S._allUsers.length) return []; + // Fallback to DOM only while S._allUsers is not yet populated + return Array.from(document.querySelectorAll('.account-check:checked')) + .filter(cb => !String(cb.dataset.id || '').startsWith('google:')) + .map(cb => ({ + id: cb.dataset.id, displayName: cb.dataset.name, userRole: cb.dataset.role || 'other' + })); } // ── Window exports (HTML handlers + cross-module calls) ───────────────────── diff --git a/tests/test_manual_users.py b/tests/test_manual_users.py index 94cf94c..60ea6eb 100644 --- a/tests/test_manual_users.py +++ b/tests/test_manual_users.py @@ -37,7 +37,7 @@ class TestResolveManualUsers: def test_manual_email_resolved_to_directory_id(self, broadcasts): conn = _Conn({"ann@school.dk": {"id": "guid-ann", "displayName": "Ann Andersen", "mail": "ann@school.dk"}}) - out = scan_engine._resolve_manual_users(conn, [ + out = scan_engine._resolve_scan_users(conn, [ {"id": "manual:ann@school.dk", "displayName": "manual:ann@school.dk", "userRole": "other"}, ]) assert out == [{"id": "guid-ann", "displayName": "Ann Andersen", "userRole": "other"}] @@ -48,12 +48,12 @@ class TestResolveManualUsers: conn = _Conn({}) users = [{"id": "guid-1", "displayName": "One"}, {"id": "guid-2", "displayName": "Two", "manual": True, "email": "two@school.dk"}] - assert scan_engine._resolve_manual_users(conn, users) == users + assert scan_engine._resolve_scan_users(conn, users) == users assert conn.lookups == [] def test_unknown_manual_account_skipped_and_reported(self, broadcasts): conn = _Conn({}) - out = scan_engine._resolve_manual_users(conn, [ + out = scan_engine._resolve_scan_users(conn, [ {"id": "guid-1", "displayName": "One"}, {"id": "manual:ghost@school.dk", "displayName": "ghost"}, ]) @@ -63,7 +63,7 @@ class TestResolveManualUsers: def test_manual_account_already_selected_is_deduplicated(self, broadcasts): conn = _Conn({"ann@school.dk": {"id": "guid-ann", "displayName": "Ann"}}) - out = scan_engine._resolve_manual_users(conn, [ + out = scan_engine._resolve_scan_users(conn, [ {"id": "guid-ann", "displayName": "Ann"}, {"id": "manual:ann@school.dk", "displayName": "ann"}, ]) @@ -73,10 +73,56 @@ class TestResolveManualUsers: # run_scan resolves inside the non-empty branch, so an empty result must # stay empty — it must never widen to "scan every tenant user". conn = _Conn({}) - assert scan_engine._resolve_manual_users( + assert scan_engine._resolve_scan_users( conn, [{"id": "manual:x@school.dk", "displayName": "x"}]) == [] +class TestGoogleOnlyAccountsDropped: + """Regression: google: ids reached the M365 scan and Graph 404'd + ("/users/google:anne@school.dk/mailFolders").""" + + def test_google_only_accounts_are_dropped_and_reported(self, broadcasts): + conn = _Conn({}) + out = scan_engine._resolve_scan_users(conn, [ + {"id": "guid-1", "displayName": "One"}, + {"id": "google:anne@school.dk", "displayName": "Anne"}, + {"id": "google:appleadmin@adm.school.dk", "displayName": "Apple Admin"}, + ]) + assert out == [{"id": "guid-1", "displayName": "One"}] + assert conn.lookups == [] + phases = [d["phase"] for e, d in broadcasts if e == "scan_phase"] + assert phases and "2" in phases[0] + + def test_only_google_accounts_gives_empty_list_not_all_users(self, broadcasts): + assert scan_engine._resolve_scan_users(_Conn({}), [ + {"id": "google:anne@school.dk", "displayName": "Anne"}]) == [] + + def test_run_scan_never_calls_graph_for_google_ids(self, broadcasts, monkeypatch): + from routes import state + 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(state, "flagged_items", []) + conn = MagicMock() + conn.is_app_mode = True + conn.list_all_mail_folders_for.return_value = [] + monkeypatch.setattr(state, "connector", conn) + import sse + saved = sse._current_scan_id + try: + scan_engine.run_scan({"sources": ["email"], "options": {}, "user_ids": [ + {"id": "google:anne@school.dk", "displayName": "Anne"}, + {"id": "guid-1", "displayName": "One"}, + ]}) + finally: + sse._current_scan_id = saved + called_ids = {c.args[0] for c in conn.list_all_mail_folders_for.call_args_list} + assert "guid-1" in called_ids + assert not any(str(i).startswith("google:") for i in called_ids) + conn.list_users.assert_not_called() # never widened to the whole tenant + + class TestLookupRoute: @pytest.fixture() def client(self):