diff --git a/CHANGELOG.md b/CHANGELOG.md index d033886..b71234e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html ### Added +- **Role-scoped viewer tokens** — viewer token links can now be restricted to a single role so the recipient can only see student or staff items. A new **Role scope** dropdown (All roles / Ansatte / Elever) in the Share modal is selected when creating a token. The scope is stored as `"scope": {"role": "student"|"staff"}` in `viewer_tokens.json`. Enforcement is two-layered: `GET /api/db/flagged` filters items server-side using `session["viewer_scope"].role` set at token validation time; the `#filterRole` dropdown in the viewer is pre-set and hidden so the constraint cannot be bypassed client-side. Tokens without a scope field (existing tokens, PIN sessions) remain unrestricted. Role badge (Ansatte / Elever) shown on each scoped token row in the Active links list. + - **Role filter in results + role-scoped exports** — a new **Role** dropdown in the filter bar (All roles / Ansatte / Elever) narrows the results grid to staff or student items. Clicking **Excel** or **Art.30** while a role is selected exports only that group — the `?role=student|staff` param is forwarded to both export endpoints. `_build_excel_bytes()` and `_build_article30_docx()` now accept a `role` param; all internal sheets (GPS, External transfers, Art.30 staff/student tables) respect the filter. Filenames get an `_elever` or `_ansatte` suffix. - **Scan filter options for student environments** — two new profile options reduce noise when scanning student accounts: diff --git a/CLAUDE.md b/CLAUDE.md index 5a3cf02..66d0617 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,9 +44,12 @@ Read-only access for DPOs and reviewers. Key invariants: - **`/view` auth chain** — token (`?token=`) → session cookie (`session["viewer_ok"]`) → PIN form (if PIN configured) → 403. Never skip this order. - **`window.VIEWER_MODE`** — injected by Jinja2 in `index.html`. `auth.js` reads it at startup; adds `viewer-mode` class to `
`. All hide rules are CSS (`body.viewer-mode …`), not scattered JS checks — except `delBtn` in the card builder which is also guarded in JS. Hidden in viewer mode: `.sidebar` (entire left panel), `#logWrap`, `#progressBar`, scan/stop/profile/bulk-delete buttons, share button. -- **`viewer_tokens.json` format** — stored as `{"tokens": [...], "__pin__": {"hash": "…", "salt": "…"}}`. The old bare-list format is migrated transparently on first write. Do not write the file as a bare list. +- **`window.VIEWER_SCOPE`** — injected alongside `VIEWER_MODE`. Contains the scope dict from the token (e.g. `{"role": "student"}`). Empty object `{}` means unrestricted. `auth.js` reads it at startup; if `VIEWER_SCOPE.role` is set, it pre-sets `#filterRole` to that value and hides the dropdown so the viewer cannot change it. +- **Token scope** — stored as `"scope": {"role": "student"|"staff"}` or `"scope": {}` in each token dict inside `viewer_tokens.json`. Enforced in two places: server-side (`GET /api/db/flagged` skips items whose `role` column does not match `session["viewer_scope"].role`) and client-side (the `#filterRole` dropdown is locked). Server-side is the authoritative guard. +- **`session["viewer_scope"]`** — set when a token is validated at `/view`. Persists for the browser session alongside `session["viewer_ok"]`. Reads from `session.get("viewer_scope", {})` in `/api/db/flagged` — defaults to `{}` (unrestricted) for PIN-authenticated sessions and legacy tokens without a scope key. +- **`viewer_tokens.json` format** — stored as `{"tokens": [...], "__pin__": {"hash": "…", "salt": "…"}}`. Token dicts now include `"scope": {}`. The old bare-list format and tokens without a `scope` key are handled transparently (`t.get("scope", {})`). Do not write the file as a bare list. - **`app.secret_key`** — derived from `machine_id` bytes so Flask sessions survive restarts. Set once at startup in `gdpr_scanner.py`; do not override it. -- **`GET /api/db/flagged`** — returns `get_session_items()` (last completed scan session, joined with dispositions). Used exclusively by `_loadViewerResults()` in `results.js`. Do not confuse with `get_flagged_items()` (single scan_id, no disposition join). +- **`GET /api/db/flagged`** — returns `get_session_items()` (last completed scan session, joined with dispositions), filtered by `session["viewer_scope"].role` when set. Used exclusively by `_loadViewerResults()` in `results.js`. Do not confuse with `get_flagged_items()` (single scan_id, no disposition join). - **Rate-limit state** (`_pin_attempts` dict in `routes/viewer.py`) — in-memory only, resets on server restart. Intentional — a restart clears lockouts without a persistent store. - **Token onclick attributes** — Copy/Revoke buttons in `_renderTokenList()` pass the token as a single-quoted JS string literal (`'\'' + tok.token + '\''`), never via `JSON.stringify`. `JSON.stringify` produces double-quoted strings that break the surrounding `onclick="…"` HTML attribute. - **Settings Security pane** — Admin PIN and Viewer PIN groups live in `stPaneSecurity`, not `stPaneGeneral`. `switchSettingsTab('security')` in `sources.js` triggers both `stLoadPinStatus()` and `stLoadViewerPinStatus()`. The Share modal Configure button opens `openSettings('security')`. diff --git a/README.md b/README.md index 736f5e7..5d6e5c4 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ an IDE with intelligent completion. The result is the author's work. - **Retention policy enforcement** — flag items older than a configurable retention period with a Overdue badge; supports both rolling and fiscal-year-aligned cutoffs (e.g. Bogføringsloven Dec 31); headless auto-delete via `--retention-years` - **Data subject lookup** — find all flagged items containing a specific CPR number across all scans; CPR is SHA-256 hashed before querying — never stored in plaintext - **Disposition tagging** — compliance officers can tag each flagged item with a legal basis (retain / delete-scheduled / deleted) directly from the preview panel -- **Read-only viewer mode** — share scan results with a DPO or manager via a secure token URL (`/view?token=…`) or a numeric PIN; viewers see the full results grid and disposition panel but cannot scan, delete, or change settings +- **Read-only viewer mode** — share scan results with a DPO or manager via a secure token URL (`/view?token=…`) or a numeric PIN; viewers see the full results grid and disposition panel but cannot scan, delete, or change settings. Tokens can be **role-scoped** (Ansatte / Elever) so a recipient can only see the items relevant to their remit - **Article 30 report** — one-click export of a structured Word document (`.docx`) satisfying the GDPR Article 30 register of processing activities obligation - **SQLite results database** — scan results, CPR index, PII breakdown, disposition decisions, and scan history are persisted to `~/.gdprscanner/scanner.db` alongside the JSON cache, enabling cross-scan queries and trend tracking - **Built-in user manual** — click the **?** button in the top bar to open the manual in a dedicated window. Available in Danish and English. Printable via the browser's print function. Served from `MANUAL-DA.md` / `MANUAL-EN.md` at `/manual?lang=da|en` — always in sync with the installed version, no internet required. In the packaged desktop app the manual opens as a native pywebview window; in the browser it opens as a popup. @@ -624,7 +624,7 @@ See [SUGGESTIONS.md](SUGGESTIONS.md) for the full feature roadmap with implement | `routes/email.py` | `/api/smtp/*` and `/api/send_report` | | `routes/database.py` | `/api/db/*`, `/api/admin/*`, `/api/preview`, `/api/thumb` | | `routes/export.py` | `/api/export_excel`, `/api/export_article30`, `/api/delete_bulk` | -| `routes/viewer.py` | `/view`, `/api/viewer/tokens`, `/api/viewer/pin` — read-only viewer mode: token + PIN auth, share-link management | +| `routes/viewer.py` | `/view`, `/api/viewer/tokens`, `/api/viewer/pin` — read-only viewer mode: token + PIN auth, share-link management, role-scoped tokens | | `routes/app_routes.py` | `/api/about`, `/api/langs`, `/api/lang`, `/manual` | | `docs/manuals/MANUAL-EN.md` | End-user manual in English (15 sections) — served at `/manual?lang=en` | | `docs/manuals/MANUAL-DA.md` | End-user manual in Danish (15 sections) — served at `/manual?lang=da` | diff --git a/app_config.py b/app_config.py index 6f51ca2..ccdb0a8 100644 --- a/app_config.py +++ b/app_config.py @@ -558,12 +558,14 @@ def _save_viewer_tokens(tokens: list) -> None: logger.error("[viewer_tokens] write failed: %s", e) -def create_viewer_token(label: str = "", expires_days: int | None = None) -> dict: +def create_viewer_token(label: str = "", expires_days: int | None = None, scope: dict | None = None) -> dict: """Generate a new viewer token, persist it, and return the token dict. Args: - label: Human-readable description (e.g. "DPO review April 2026"). + label: Human-readable description (e.g. "DPO review April 2026"). expires_days: Days until expiry. None = no expiry. + scope: Optional access scope, e.g. {"role": "student"} or {"role": "staff"}. + Empty dict / None means unrestricted. """ import secrets as _secrets token = _secrets.token_hex(32) # 64-char URL-safe hex string @@ -571,6 +573,7 @@ def create_viewer_token(label: str = "", expires_days: int | None = None) -> dic entry: dict = { "token": token, "label": label or "", + "scope": scope or {}, "created_at": now, "expires_at": now + expires_days * 86400 if expires_days else None, "last_used_at": None, diff --git a/docs/manuals/MANUAL-DA.md b/docs/manuals/MANUAL-DA.md index 9741735..ad12067 100644 --- a/docs/manuals/MANUAL-DA.md +++ b/docs/manuals/MANUAL-DA.md @@ -1,6 +1,6 @@ # GDPR Scanner — Brugermanual -Version 1.6.14 +Version 1.6.15 --- @@ -359,15 +359,18 @@ Du kan give en DPO, skoleleder eller compliance-koordinator skrivebeskyttet adga Klik på **🔗**-knappen øverst til højre i topbjælken for at åbne delingspanelet. 1. Angiv eventuelt en **Betegnelse** for at identificere, hvem linket er til (f.eks. "DPO-gennemgang april 2026"). -2. Vælg en **Udløbsdato** — 7 dage, 30 dage, 90 dage, 1 år eller Aldrig. -3. Klik på **Opret**. Der genereres et unikt link: `http://host:5100/view?token=…` -4. Klik på **Kopiér** for at kopiere linket til udklipsholderen, og send det til gennemgangeren. +2. Vælg et **Rolleomfang** — **Alle roller**, **Ansatte** eller **Elever**. Et afgrænset link begrænser modtageren til elementer tilhørende den valgte rollegruppe; de kan ikke se andre elementer, og rollefilteret er låst i deres visning. +3. Vælg en **Udløbsdato** — 7 dage, 30 dage, 90 dage, 1 år eller Aldrig. +4. Klik på **Opret**. Der genereres et unikt link: `http://host:5100/view?token=…` +5. Klik på **Kopiér** for at kopiere linket til udklipsholderen, og send det til gennemgangeren. -Gennemgangeren åbner linket i en browser. De kan se det fulde resultatgitter og mærke dispositioner, men kan ikke starte scanninger, ændre indstillinger, se loginoplysninger eller slette elementer. +Gennemgangeren åbner linket i en browser. De kan se resultatgitteret (afgrænset til det tilladte rolleomfang) og mærke dispositioner, men kan ikke starte scanninger, ændre indstillinger, se loginoplysninger eller slette elementer. **Administrer eksisterende links** -Delingspanelet viser alle aktive links. Hver række viser betegnelse, udløbsdato og hvornår linket sidst blev brugt. Klik på **Kopiér** for at kopiere et link igen, eller **Tilbagekald** for at gøre det ugyldigt med det samme. +Delingspanelet viser alle aktive links. Hver række viser betegnelse, rollemærkat (hvis afgrænset), udløbsdato og hvornår linket sidst blev brugt. Klik på **Kopiér** for at kopiere et link igen, eller **Tilbagekald** for at gøre det ugyldigt med det samme. + +> **Tip:** I skoler og kommuner er det almindeligt at have separate DPO'er eller compliance-ansvarlige for henholdsvis ansatte og elever. Opret ét afgrænset link til hver — eleve-DPO'en vil kun se elevdata, og ansatte-DPO'en vil kun se ansattedata. ### 10.2 Viewer-PIN @@ -375,7 +378,7 @@ Som alternativ til token-links kan du angive en numerisk PIN-kode (4–8 cifre) For at angive eller ændre PIN-koden skal du indtaste den nye kode i feltet **Ny PIN** og klikke på **Gem PIN**. Klik på **Ryd PIN** for at fjerne den. -> **Sikkerhedsnote:** Token-links er mere sikre end en PIN-kode, fordi hvert link kan tilbagekaldes individuelt og har en udløbsdato. Brug PIN-indstillingen kun til betroede interne gennemgangere på dit lokale netværk. +> **Sikkerhedsnote:** Token-links er mere sikre end en PIN-kode, fordi hvert link kan tilbagekaldes individuelt, har en udløbsdato og kan afgrænses til en bestemt rollegruppe. Brug PIN-indstillingen kun til betroede interne gennemgangere på dit lokale netværk, der har brug for adgang til alle resultater. ### 10.3 Hvad gennemgangeren kan gøre @@ -392,6 +395,7 @@ For at angive eller ændre PIN-koden skal du indtaste den nye kode i feltet **Ny | Slette elementer | Nej | | Tilgå indstillinger | Nej | | Oprette eller tilbagekalde viewer-links | Nej | +| Se elementer uden for deres rolleomfang | Nej | --- diff --git a/docs/manuals/MANUAL-EN.md b/docs/manuals/MANUAL-EN.md index ab095c9..eee8cc8 100644 --- a/docs/manuals/MANUAL-EN.md +++ b/docs/manuals/MANUAL-EN.md @@ -1,6 +1,6 @@ # GDPR Scanner — User Manual -Version 1.6.14 +Version 1.6.15 --- @@ -359,15 +359,18 @@ You can give a DPO, school principal, or compliance coordinator read-only access Click the **🔗** button in the top-right of the top bar to open the Share panel. 1. Optionally enter a **Label** to identify who the link is for (e.g. "DPO review April 2026"). -2. Choose an **Expiry** — 7 days, 30 days, 90 days, 1 year, or Never. -3. Click **Create**. A unique link is generated: `http://host:5100/view?token=…` -4. Click **Copy** to copy the link to your clipboard, then send it to the reviewer. +2. Choose a **Role scope** — **All roles**, **Ansatte** (staff only), or **Elever** (students only). A scoped link restricts the recipient to items belonging to that role group; they cannot see any other items, and the role filter is locked in their view. +3. Choose an **Expiry** — 7 days, 30 days, 90 days, 1 year, or Never. +4. Click **Create**. A unique link is generated: `http://host:5100/view?token=…` +5. Click **Copy** to copy the link to your clipboard, then send it to the reviewer. -The reviewer opens the link in any browser. They see the full results grid and can tag dispositions but cannot start scans, change settings, view credentials, or delete items. +The reviewer opens the link in any browser. They see the results grid (filtered to their permitted scope) and can tag dispositions but cannot start scans, change settings, view credentials, or delete items. **Managing existing links** -The Share panel lists all active links. Each row shows the label, expiry date, and when the link was last used. Click **Copy** to copy a link again, or **Revoke** to invalidate it immediately. +The Share panel lists all active links. Each row shows the label, role badge (if scoped), expiry date, and when the link was last used. Click **Copy** to copy a link again, or **Revoke** to invalidate it immediately. + +> **Tip:** In schools and municipalities it is common to have separate DPOs or compliance officers for staff data and student data. Create one scoped link for each — the student DPO will only ever see student items, and the staff DPO will only see staff items. ### 10.2 Viewer PIN @@ -375,7 +378,7 @@ As an alternative to token links, you can set a numeric PIN (4–8 digits) in ** To set or change the PIN, enter the new PIN in the **New PIN** field and click **Save PIN**. To remove it, click **Clear PIN**. -> **Security note:** Token links are more secure than a PIN because each link can be individually revoked and has an expiry date. Use the PIN option only for trusted internal reviewers on your local network. +> **Security note:** Token links are more secure than a PIN because each link can be individually revoked, has an expiry date, and can be role-scoped. Use the PIN option only for trusted internal reviewers on your local network who need access to all results. ### 10.3 What the reviewer can do @@ -392,6 +395,7 @@ To set or change the PIN, enter the new PIN in the **New PIN** field and click * | Delete items | No | | Access Settings | No | | Create or revoke viewer links | No | +| See items outside their role scope | No | --- diff --git a/gdpr_scanner.py b/gdpr_scanner.py index 61a41ad..95e069b 100644 --- a/gdpr_scanner.py +++ b/gdpr_scanner.py @@ -383,17 +383,21 @@ def viewer(): from app_config import validate_viewer_token, get_viewer_pin_hash token = request.args.get("token", "").strip() if token: - if validate_viewer_token(token) is None: + entry = validate_viewer_token(token) + if entry is None: return render_template("viewer_denied.html"), 403 # Bind a session so the viewer doesn't need the token on every navigation - session["viewer_ok"] = True + session["viewer_ok"] = True + session["viewer_scope"] = entry.get("scope", {}) return render_template("index.html", app_version=APP_VERSION, lang_json=json.dumps(LANG, ensure_ascii=False), - viewer_mode=True) + viewer_mode=True, + viewer_scope=json.dumps(entry.get("scope", {}), ensure_ascii=False)) if session.get("viewer_ok"): return render_template("index.html", app_version=APP_VERSION, lang_json=json.dumps(LANG, ensure_ascii=False), - viewer_mode=True) + viewer_mode=True, + viewer_scope=json.dumps(session.get("viewer_scope", {}), ensure_ascii=False)) # No token, no session — show PIN form if a PIN is configured, else deny pin_hash = get_viewer_pin_hash() if pin_hash: diff --git a/lang/da.json b/lang/da.json index 491f61d..1e8a884 100644 --- a/lang/da.json +++ b/lang/da.json @@ -766,6 +766,10 @@ "share_create_error": "Kunne ikke oprette link:", "share_revoke_confirm": "Tilbagekald dette link? Alle der bruger det, mister straks adgang.", "share_revoke_error": "Kunne ikke tilbagekalde:", + "share_scope_lbl": "Rolleomfang", + "share_scope_all": "Alle roller", + "share_scope_staff": "Ansatte", + "share_scope_student": "Elever", "viewer_pin_group_title": "Seerens PIN", "viewer_pin_desc": "En numerisk PIN (4–8 cifre), der lader alle åbne/view i en browser for skrivebeskyttet adgang til resultater uden et token-link.",
diff --git a/lang/de.json b/lang/de.json
index aab9bea..ba67c41 100644
--- a/lang/de.json
+++ b/lang/de.json
@@ -766,6 +766,10 @@
"share_create_error": "Link konnte nicht erstellt werden:",
"share_revoke_confirm": "Diesen Link widerrufen? Alle Nutzer verlieren sofort den Zugriff.",
"share_revoke_error": "Widerrufen fehlgeschlagen:",
+ "share_scope_lbl": "Rollenumfang",
+ "share_scope_all": "Alle Rollen",
+ "share_scope_staff": "Mitarbeitende",
+ "share_scope_student": "Schüler",
"viewer_pin_group_title": "Betrachter-PIN",
"viewer_pin_desc": "Eine numerische PIN (4–8 Stellen), die es jedem ermöglicht, /view im Browser zu öffnen und schreibgeschützt auf Ergebnisse zuzugreifen \u2013 ohne Token-Link.",
diff --git a/lang/en.json b/lang/en.json
index 62ed6aa..943bc3b 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -766,6 +766,10 @@
"share_create_error": "Failed to create link:",
"share_revoke_confirm": "Revoke this link? Anyone using it will immediately lose access.",
"share_revoke_error": "Failed to revoke:",
+ "share_scope_lbl": "Role scope",
+ "share_scope_all": "All roles",
+ "share_scope_staff": "Staff",
+ "share_scope_student": "Students",
"viewer_pin_group_title": "Viewer PIN",
"viewer_pin_desc": "A numeric PIN (4\u20138 digits) that lets anyone open /view in a browser for read-only access to results without a token URL.",
diff --git a/routes/database.py b/routes/database.py
index cbefcaf..3de902c 100644
--- a/routes/database.py
+++ b/routes/database.py
@@ -148,13 +148,19 @@ def db_get_disposition(item_id):
def db_flagged_items():
"""Return flagged items from the most recent completed scan session.
Used by the read-only viewer to load results without an active SSE connection.
+ Respects viewer_scope.role stored in the session for scoped tokens.
"""
if not DB_OK: return jsonify([])
+ from flask import session as _session
+ scope = _session.get("viewer_scope", {})
+ role_filt = scope.get("role", "") if isinstance(scope, dict) else ""
items = _get_db().get_session_items()
# Normalise JSON-encoded columns the same way scan_engine does for SSE cards
import json as _json
out = []
for row in items:
+ if role_filt and row.get("role", "") != role_filt:
+ continue
row["special_category"] = _json.loads(row.get("special_category") or "[]") if isinstance(row.get("special_category"), str) else row.get("special_category", [])
row["exif"] = _json.loads(row.get("exif_json") or "{}") if isinstance(row.get("exif_json"), str) else row.get("exif", {})
row.pop("exif_json", None)
diff --git a/routes/viewer.py b/routes/viewer.py
index e9d6845..c7eaf24 100644
--- a/routes/viewer.py
+++ b/routes/viewer.py
@@ -52,6 +52,7 @@ def list_tokens():
"token_hint": t["token"][:8] + "…",
"token": t["token"],
"label": t.get("label", ""),
+ "scope": t.get("scope", {}),
"created_at": t.get("created_at"),
"expires_at": t.get("expires_at"),
"last_used_at": t.get("last_used_at"),
@@ -73,7 +74,14 @@ def create_token():
return jsonify({"error": "expires_days must be a positive integer"}), 400
except (TypeError, ValueError):
return jsonify({"error": "expires_days must be a positive integer"}), 400
- entry = create_viewer_token(label=label, expires_days=expires_days)
+ raw_scope = body.get("scope", {})
+ if not isinstance(raw_scope, dict):
+ return jsonify({"error": "scope must be an object"}), 400
+ role = str(raw_scope.get("role", "")).strip()
+ if role not in ("", "student", "staff"):
+ return jsonify({"error": "scope.role must be '', 'student', or 'staff'"}), 400
+ scope = {"role": role} if role else {}
+ entry = create_viewer_token(label=label, expires_days=expires_days, scope=scope)
return jsonify(entry), 201
diff --git a/static/js/auth.js b/static/js/auth.js
index 732986c..0768d87 100644
--- a/static/js/auth.js
+++ b/static/js/auth.js
@@ -159,6 +159,12 @@ if (window.VIEWER_MODE) {
document.body.classList.add('viewer-mode');
document.getElementById('authScreen').style.display = 'none';
document.getElementById('scannerScreen').style.display = 'flex';
+ // If this token is role-scoped, lock the filter to that role and hide the dropdown.
+ const _scopeRole = (window.VIEWER_SCOPE || {}).role || '';
+ if (_scopeRole) {
+ const _fr = document.getElementById('filterRole');
+ if (_fr) { _fr.value = _scopeRole; _fr.style.display = 'none'; }
+ }
try { loadTrend(); } catch(e) {}
} else {
(async function() {
diff --git a/static/js/viewer.js b/static/js/viewer.js
index e334bb4..f33cf52 100644
--- a/static/js/viewer.js
+++ b/static/js/viewer.js
@@ -20,6 +20,8 @@ function openShareModal() {
document.getElementById('shareNewLinkRow').style.display = 'none';
document.getElementById('shareLabel').value = '';
document.getElementById('shareExpiry').value = '30';
+ const scopeSel = document.getElementById('shareScope');
+ if (scopeSel) scopeSel.value = '';
_renderTokenList();
fetch('/api/viewer/pin').then(function(r){ return r.json(); }).then(function(d) {
const el = document.getElementById('sharePinStatus');
@@ -51,10 +53,18 @@ async function _renderTokenList() {
: '—';
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:12px';
+ const roleVal = tok.scope?.role || '';
+ const roleLbl = roleVal === 'student' ? t('share_scope_student', 'Elever')
+ : roleVal === 'staff' ? t('share_scope_staff', 'Ansatte')
+ : '';
+ const roleBadge = roleLbl
+ ? '' + roleLbl + ''
+ : '';
row.innerHTML =
'