diff --git a/docs/engineering/login_organisation_preferences.md b/docs/engineering/login_organisation_preferences.md new file mode 100644 index 000000000..7bd4e2b90 --- /dev/null +++ b/docs/engineering/login_organisation_preferences.md @@ -0,0 +1,37 @@ +# Login organisation defaults + +An authenticated email identifies a person through `#V#hasVonLoginEmail`. +It does not identify their active organisation. The same person can still +deliberately switch between their current `#V#memberOfVonOrg` memberships. + +`#V#hasVonLoginOrganisationPreferences` is a non-authority text predicate on +the person: a singleton JSON object mapping exact normalised login emails to +organisation concept IDs (or null for Personal). The canonical +`set_login_organisation_preference` service validates the email binding and +membership before writing, serialises per-person updates, and reads back. +Only an authorised preference owner/operator should invoke that primitive. +No email-domain inference, membership grant, or role change is performed. + +Authenticated status reads validate the selected default against live narrow +membership. An absent/revoked default selects Personal, not another membership. +Malformed/ambiguous preference data or a storage outage does not silently pick +an organisation. The browser displays its retry gate rather than starting chat +with an unconfirmed context. + +A changed authenticated email or changed effective default establishes a new +bootstrap generation. Before chat/history/tools initialise, the browser rotates +its opaque window ID, clears legacy organisation mirrors, and binds the exact +default through the membership-checking session endpoint. Reloads in that +generation preserve deliberate per-tab switches, including explicit Personal. +New tabs start with the login default rather than a browser-wide last-used org. +Cloudflare token renewal for the same email does not reset deliberate choices. +Browser-test fixtures retain their separately configured fixture bootstrap. + +The old `member_of_organisation` list is **not a preference**. Reading its first +value previously caused SAIL to be selected for Michael's work email. Its +descriptive assertions are preserved; preference readers no longer consume it. + +DGX-local Mongo and Mac/Atlas are separate authority stores. Configure and +verify each independently; federation does not replicate these authority or +preference records. A stored default is not proof of a successful Google login: +validate normal issuance, resulting tab context and actual effect namespace. diff --git a/src/backend/security/cloudflare_access.py b/src/backend/security/cloudflare_access.py index 0acd43803..86c98ce43 100644 --- a/src/backend/security/cloudflare_access.py +++ b/src/backend/security/cloudflare_access.py @@ -139,7 +139,8 @@ def authenticate_access_request(): if not user or not user.get("concept_id"): raise ValueError("von_login_email_not_authorised") actor = user["concept_id"] - if session.get("user_concept_id") != actor: + if (session.get("user_concept_id") != actor + or session.get("user_email") != email): session.clear() session.update( user_email=email, diff --git a/src/backend/server/routes/auth_routes.py b/src/backend/server/routes/auth_routes.py index b95d25e84..41d339fb5 100644 --- a/src/backend/server/routes/auth_routes.py +++ b/src/backend/server/routes/auth_routes.py @@ -68,7 +68,7 @@ def _clear_scope_if_authenticated_identity_changes( same_actor = False if previous_concept_id and next_concept_id: - same_actor = previous_concept_id == next_concept_id + same_actor = previous_concept_id == next_concept_id and previous_email == next_email elif previous_email and next_email: same_actor = previous_email == next_email @@ -110,6 +110,10 @@ def _build_auth_status_payload() -> dict: and user_concept_id and session_has_required_authentication_assurance(session) ) + login_context = None + if authenticated and auth_provider != "browser_test_fixture": + from ...services.login_organisation_preference_service import initialise_login_context + login_context = initialise_login_context(session) return { "authenticated": authenticated, "email": user_email if authenticated else None, @@ -121,6 +125,7 @@ def _build_auth_status_payload() -> dict: "user_concept_id": user_concept_id if authenticated else None, "auth_provider": auth_provider if authenticated else None, "browser_test_mode": browser_test_mode, + "login_context": login_context, } diff --git a/src/backend/server/routes/settings_routes.py b/src/backend/server/routes/settings_routes.py index 792562d99..56b5e1179 100644 --- a/src/backend/server/routes/settings_routes.py +++ b/src/backend/server/routes/settings_routes.py @@ -2251,7 +2251,6 @@ def get_model_parameter_capabilities(): # --- Per-user preference (language & organisation) storage via concept relationships --- USER_PREF_LANG_PREDICATE = "#V#preferred_language" -USER_PREF_ORG_PREDICATE = "#V#member_of_organisation" def _normalize_relationships(rel): @@ -2300,7 +2299,6 @@ def get_user_prefs(user_concept_id: str): return jsonify({"error": "User concept not found"}), 404 rel = _normalize_relationships(concept.get("relationships", {})) lang_raw = rel.get(USER_PREF_LANG_PREDICATE) - org_raw = rel.get(USER_PREF_ORG_PREDICATE) def first_val(v): if isinstance(v, list): @@ -2317,7 +2315,13 @@ def first_val(v): ) if language_rows: preferred_language = language_rows[0].get("text") - organisation_concept_id = first_val(org_raw) + # Legacy member_of_organisation describes membership, not preference. + # Never choose the first membership as a landing context. + from ...services.login_organisation_preference_service import initialise_login_context + organisation_concept_id = ( + initialise_login_context(session)["organisation_concept_id"] + if session.get("user_email") and session.get("user_concept_id") else None + ) return ( jsonify( { diff --git a/src/backend/services/login_organisation_preference_service.py b/src/backend/services/login_organisation_preference_service.py new file mode 100644 index 000000000..08e0f5e51 --- /dev/null +++ b/src/backend/services/login_organisation_preference_service.py @@ -0,0 +1,91 @@ +"""Explicit per-login landing preferences, never membership or role grants. + +Trusted callers supply the authenticated person and email. Represented JSON is +a small per-person preference map; arbitrary contact/domain facts are not read. +""" +from __future__ import annotations + +import hashlib +import json +from uuid import uuid4 + +from ..security.access_control import bypass_access_control +from .text_value_service import get_texts_for_concept, upsert_singleton_text_relation +from .organisation_membership_service import resolve_user_organisation_membership +from .von_user_authentication_service import ( + find_user_concept_by_login_email, normalise_von_login_email, +) + +PREDICATE = "#V#hasVonLoginOrganisationPreferences" + + +def read_preferences(user_concept_id: str) -> dict: + with bypass_access_control(): + rows = get_texts_for_concept(user_concept_id, predicate=PREDICATE, limit=2) + if not rows: + return {} + if len(rows) != 1: + raise ValueError("ambiguous_login_organisation_preferences") + value = json.loads(rows[0]["text"]) + if not isinstance(value, dict) or any( + not isinstance(k, str) or (v is not None and ( + not isinstance(v, str) or not v.startswith("#V#") + )) for k, v in value.items() + ): + raise ValueError("invalid_login_organisation_preferences") + return value + + +def set_login_organisation_preference(*, user_concept_id: str, email: str, + organisation_concept_id: str | None, + provenance: dict) -> dict: + """Canonical primitive for an authorised owner/operator; grants no access.""" + from .ontology_publication_authority_service import ontology_mutation_resource_lock + email = normalise_von_login_email(email) + user = find_user_concept_by_login_email(email) + if not user or user.get("concept_id") != user_concept_id: + raise ValueError("login_email_subject_mismatch") + if organisation_concept_id and not resolve_user_organisation_membership( + user_concept_id, organisation_concept_id + ): + raise ValueError("organisation_membership_required") + with ontology_mutation_resource_lock(f"login-organisation-preference:{user_concept_id}"): + preferences = read_preferences(user_concept_id) + preferences[email] = organisation_concept_id + with bypass_access_control(): + upsert_singleton_text_relation( + subject_concept_id=user_concept_id, predicate=PREDICATE, + text=json.dumps(preferences, sort_keys=True), provenance=provenance, + ) + if read_preferences(user_concept_id) != preferences: + raise RuntimeError("login_organisation_preference_readback_failed") + return {"user_concept_id": user_concept_id, "email": email, + "organisation_concept_id": organisation_concept_id} + + +def initialise_login_context(session) -> dict: + """Return a membership-validated default and a login/bootstrap generation. + + Called by authenticated status before browser features initialise. Existing + deliberate tab choices survive reload; email/default changes start fresh. + Missing/revoked preferences mean Personal, never the first membership. + """ + actor = session["user_concept_id"] + email = normalise_von_login_email(session["user_email"]) + desired = read_preferences(actor).get(email) + membership = resolve_user_organisation_membership(actor, desired) if desired else None + selected = desired if membership else None + reason = "explicit_login_preference" if selected else ( + "preferred_membership_unavailable" if desired else "personal_default" + ) + fingerprint = hashlib.sha256(json.dumps([actor, email, selected]).encode()).hexdigest() + if session.get("login_context_fingerprint") != fingerprint: + for key in ("organisation_concept_id", "role_in_org", "namespace", "session_id"): + session.pop(key, None) + session["login_context_fingerprint"] = fingerprint + session["login_context_generation"] = uuid4().hex + if selected: + session["organisation_concept_id"] = selected.removeprefix("#V#") + session["role_in_org"] = membership["role"] + return {"generation": session["login_context_generation"], + "organisation_concept_id": selected, "reason": reason} diff --git a/src/frontend/web/von_interface/static/js/apiService.js b/src/frontend/web/von_interface/static/js/apiService.js index 3ec75c283..06ad01f24 100644 --- a/src/frontend/web/von_interface/static/js/apiService.js +++ b/src/frontend/web/von_interface/static/js/apiService.js @@ -59,6 +59,7 @@ async function buildHeaders(extraHeaders = {}) { export { ensureUniqueWindowSessionId, getWindowSessionId, + replaceMismatchedWindowSessionId, WINDOW_SESSION_HEADER, WINDOW_SESSION_KEY }; diff --git a/src/frontend/web/von_interface/static/js/main.js b/src/frontend/web/von_interface/static/js/main.js index deacea49e..5ceb7abb2 100644 --- a/src/frontend/web/von_interface/static/js/main.js +++ b/src/frontend/web/von_interface/static/js/main.js @@ -13,6 +13,7 @@ import { evaluateServerHealthState } from './utils/serverHealthState.js'; import { handleSelectConceptByIdDetail } from './utils/selectConceptByIdHandler.js'; import { hasAuthenticatedVonActor, + applyHomeAuthUnavailable, initialiseHomeAuthentication, shouldClearHomeIdentityMirrors, } from './homeAuthGate.js'; @@ -43,6 +44,7 @@ import { } from './utils/runtimeIdentityBootstrap.js'; import { recoverPersonalContextAfterMembershipDenial } from './utils/organisationSessionRecovery.js'; import { hydrateStoredSelectionsFromUserPreferences } from './utils/userPreferenceBootstrap.js'; +import { initialiseLoginOrganisation } from './utils/loginOrganisationBootstrap.js'; import { isVontologyBusy, loadKeyConceptsForUser, preloadVontologyData, selectVontologyNodeByIdentifier, setupVontologySearchUI } from './vontology.js'; // JVNAUTOSCI-1011: getCurrentNamespace is now provided by sessionScopedStorage.js @@ -81,7 +83,12 @@ document.addEventListener('DOMContentLoaded', async () => { // Ensure user context is loaded BEFORE initializing chat to prevent race condition // where conversation history loads with null user_id - await ensureUserContext(authStatus); + try { + await ensureUserContext(authStatus); + } catch (error) { + applyHomeAuthUnavailable(new Error(`Organisation context could not be confirmed. ${error.message}`)); + return; + } loadDeferredSettingsFrame(); // JVNAUTOSCI-954: report bounded, client-reported capability hints (speech/audio) @@ -452,7 +459,7 @@ async function ensureUserContext(authStatus) { initSessionStorageFromLocalStorage(); // Import to ensure window session ID is generated - const { getJson, ensureUniqueWindowSessionId } = await import('./apiService.js'); + const { getJson, postJson, ensureUniqueWindowSessionId, replaceMismatchedWindowSessionId } = await import('./apiService.js'); // Resolve a copied duplicate-tab ID before any actor-scoped bootstrap call. await ensureUniqueWindowSessionId(); @@ -480,6 +487,11 @@ async function ensureUserContext(authStatus) { sessionStorage.setItem('von_current_user', encodedAuthenticatedUser); storedUser = authenticatedUser; + await initialiseLoginOrganisation(authStatus, { + postJson, + rotateWindow: replaceMismatchedWindowSessionId, + }); + try { await hydrateStoredSelectionsFromUserPreferences(authenticatedUser.concept_id); } catch (e) { @@ -544,6 +556,7 @@ async function ensureUserContext(authStatus) { await syncFlaskSessionOrg(); } catch (e) { console.warn('[main] Failed to ensure user context:', e); + throw e; // Do not initialise chat/tools in an unconfirmed organisation. } } diff --git a/src/frontend/web/von_interface/static/js/utils/loginOrganisationBootstrap.js b/src/frontend/web/von_interface/static/js/utils/loginOrganisationBootstrap.js new file mode 100644 index 000000000..bbe71381f --- /dev/null +++ b/src/frontend/web/von_interface/static/js/utils/loginOrganisationBootstrap.js @@ -0,0 +1,30 @@ +import { clearAllOrgContext, setSessionScopedOrgContext, setSessionScopedNamespace } from './sessionScopedStorage.js'; + +/** A new verified login/default cannot inherit a previous email's tab scope. */ +export async function initialiseLoginOrganisation(authStatus, { + storage = sessionStorage, + clear = clearAllOrgContext, + setOrganisation = setSessionScopedOrgContext, + setNamespace = setSessionScopedNamespace, + rotateWindow, + postJson, +} = {}) { + const context = authStatus?.login_context; + if (!context?.generation) return false; // Browser-test fixture compatibility. + const key = 'von_login_context_generation'; + if (storage.getItem(key) === context.generation) return false; + clear(); + await rotateWindow(); + const selected = context.organisation_concept_id || null; + const result = await postJson('/von/api/session/set_organisation', { + organisation_concept_id: selected, + }); + if ((result.organisation_id || null) !== selected) { + throw new Error('Login organisation binding did not match the requested context.'); + } + setOrganisation(selected ? { concept_id: selected, id: null, name: null } : null); + setNamespace(result.namespace); + // Commit only after the server has checked membership and bound the tab. + storage.setItem(key, context.generation); + return true; +} diff --git a/tests/backend/test_cloudflare_access.py b/tests/backend/test_cloudflare_access.py index ae2184907..0a908ed49 100644 --- a/tests/backend/test_cloudflare_access.py +++ b/tests/backend/test_cloudflare_access.py @@ -33,6 +33,8 @@ def token(signer, **overrides): @pytest.fixture def app(monkeypatch, signing_key): + from src.backend.services import login_organisation_preference_service as preferences + monkeypatch.setattr(preferences, "read_preferences", lambda actor: {}) monkeypatch.setenv("VON_CLOUDFLARE_ACCESS_ENABLED", "true") monkeypatch.setenv("VON_CLOUDFLARE_ACCESS_HOSTNAME", "von.example.test") monkeypatch.setenv("VON_CLOUDFLARE_ACCESS_TEAM_DOMAIN", TEAM) @@ -68,6 +70,27 @@ def test_normal_issuance_binding_and_scope_retention(app, signing_key): assert client.get("/von/api/auth/google/login", base_url=HOST, headers=headers).location == "/von/" +def test_same_person_other_email_gets_new_login_context(app, signing_key, monkeypatch): + from src.backend.services import login_organisation_preference_service as preferences + monkeypatch.setattr(preferences, "read_preferences", lambda actor: { + "user@example.test": "#V#primary", "home@example.test": "#V#household"}) + monkeypatch.setattr(preferences, "resolve_user_organisation_membership", + lambda actor, org: {"role": "member"}) + client = app.test_client() + first = client.get("/von/api/auth/status", base_url=HOST, + headers={"Cf-Access-Jwt-Assertion": token(signing_key[0])}).json + with client.session_transaction(base_url=HOST) as s: + s["session_id"] = "work-chat" + second = client.get("/von/api/auth/status", base_url=HOST, + headers={"Cf-Access-Jwt-Assertion": token(signing_key[0], email="home@example.test")}).json + assert first["user_concept_id"] == second["user_concept_id"] + assert first["login_context"]["organisation_concept_id"] == "#V#primary" + assert second["login_context"]["organisation_concept_id"] == "#V#household" + assert first["login_context"]["generation"] != second["login_context"]["generation"] + with client.session_transaction(base_url=HOST) as s: + assert "session_id" not in s + + @pytest.mark.parametrize("overrides", [ {"iss": "https://evil.cloudflareaccess.com"}, {"aud": ["other"]}, {"aud": None}, {"aud": "aud1"}, {"exp": int(time.time()) - 1}, diff --git a/tests/backend/test_login_organisation_preferences.py b/tests/backend/test_login_organisation_preferences.py new file mode 100644 index 000000000..9770c6ec2 --- /dev/null +++ b/tests/backend/test_login_organisation_preferences.py @@ -0,0 +1,60 @@ +import json +import pytest +from src.backend.services import login_organisation_preference_service as service + + +@pytest.fixture +def preferences(monkeypatch): + mapping = {"work@example.test": "#V#primary", "home@example.test": "#V#household"} + memberships = {"#V#primary", "#V#household"} + monkeypatch.setattr(service, "read_preferences", lambda actor: mapping) + monkeypatch.setattr(service, "resolve_user_organisation_membership", + lambda actor, org: {"role": "member"} if org in memberships else None) + return mapping, memberships + + +def test_email_change_same_person_resets_cookie_context_and_generation(preferences): + session = dict(user_concept_id="#V#person", user_email="work@example.test", + organisation_concept_id="sail", namespace="wrong", session_id="old") + first = service.initialise_login_context(session) + assert first["organisation_concept_id"] == "#V#primary" + assert session["organisation_concept_id"] == "primary" + assert "session_id" not in session and "namespace" not in session + session["organisation_concept_id"] = "deliberate_other_org" + assert service.initialise_login_context(session) == first + assert session["organisation_concept_id"] == "deliberate_other_org" + session["user_email"] = "home@example.test" + second = service.initialise_login_context(session) + assert second["generation"] != first["generation"] + assert session["organisation_concept_id"] == "household" + + +def test_missing_or_revoked_default_is_personal_never_first_membership(preferences): + session = dict(user_concept_id="#V#person", user_email="unknown@example.test") + assert service.initialise_login_context(session)["organisation_concept_id"] is None + session["user_email"] = "work@example.test" + before = service.initialise_login_context(session) + preferences[1].remove("#V#primary") + after = service.initialise_login_context(session) + assert after["organisation_concept_id"] is None + assert after["reason"] == "preferred_membership_unavailable" + assert after["generation"] != before["generation"] + assert "organisation_concept_id" not in session + + +def test_preference_write_cannot_grant_membership_or_bind_email(preferences, monkeypatch): + monkeypatch.setattr(service, "find_user_concept_by_login_email", lambda email: {"concept_id": "#V#person"}) + with pytest.raises(ValueError, match="membership_required"): + service.set_login_organisation_preference(user_concept_id="#V#person", email="work@example.test", + organisation_concept_id="#V#sail", provenance={}) + with pytest.raises(ValueError, match="subject_mismatch"): + service.set_login_organisation_preference(user_concept_id="#V#other", email="work@example.test", + organisation_concept_id="#V#household", provenance={}) + + +@pytest.mark.parametrize("rows", [[{"text":"not-json"}], [{"text":"[]"}], + [{"text":json.dumps({"email": 1})}], [{"text":"{}"}, {"text":"{}"}]]) +def test_malformed_or_ambiguous_preferences_fail_closed(monkeypatch, rows): + monkeypatch.setattr(service, "get_texts_for_concept", lambda *a, **k: rows) + with pytest.raises(ValueError): + service.read_preferences("#V#person") diff --git a/tests/backend/test_user_prefs_routes.py b/tests/backend/test_user_prefs_routes.py index c04ad9e82..3c9551869 100644 --- a/tests/backend/test_user_prefs_routes.py +++ b/tests/backend/test_user_prefs_routes.py @@ -111,7 +111,7 @@ def test_get_user_prefs_returns_404_when_user_concept_missing(app_client, monkey assert resp.get_json()["error"] == "User concept not found" -def test_get_user_prefs_reads_relationship_values(app_client, monkeypatch): +def test_get_user_prefs_does_not_treat_legacy_membership_as_preference(app_client, monkeypatch): _, client = app_client import src.backend.server.routes.settings_routes as settings_routes @@ -141,7 +141,7 @@ def _fake_get_concept_by_concept_id(*, concept_id: str, **_kwargs): assert resp.status_code == 200 data = resp.get_json() assert data["preferred_language"] == "en-NZ" - assert data["organisation_concept_id"] == "#V#the_lu_witbrock_household" + assert data["organisation_concept_id"] is None def test_set_user_language_uses_governed_text_effect(app_client, monkeypatch): diff --git a/tests/frontend/loginOrganisationBootstrap.test.js b/tests/frontend/loginOrganisationBootstrap.test.js new file mode 100644 index 000000000..3d655e556 --- /dev/null +++ b/tests/frontend/loginOrganisationBootstrap.test.js @@ -0,0 +1,41 @@ +import { initialiseLoginOrganisation } from '../../src/frontend/web/von_interface/static/js/utils/loginOrganisationBootstrap.js'; + +function dependencies() { + const values = new Map(); + return { + storage: { getItem: k => values.get(k), setItem: (k, v) => values.set(k, v) }, + clear: jest.fn(), rotateWindow: jest.fn(async () => {}), + setOrganisation: jest.fn(), setNamespace: jest.fn(), + postJson: jest.fn(async (_url, body) => ({ organisation_id: body.organisation_concept_id, + namespace: body.organisation_concept_id ? '#V#person@primary' : '#V#person' })), + }; +} + +test('new login discards legacy SAIL and binds verified default before committing generation', async () => { + const deps = dependencies(); + const status = { login_context: { generation: 'work-1', organisation_concept_id: '#V#primary' } }; + expect(await initialiseLoginOrganisation(status, deps)).toBe(true); + expect(deps.clear).toHaveBeenCalledTimes(1); + expect(deps.rotateWindow).toHaveBeenCalledTimes(1); + expect(deps.setOrganisation).toHaveBeenCalledWith({ concept_id: '#V#primary', id: null, name: null }); + expect(await initialiseLoginOrganisation(status, deps)).toBe(false); // Reload preserves deliberate switch. + expect(deps.postJson).toHaveBeenCalledTimes(1); + await initialiseLoginOrganisation({login_context:{generation:'home-2',organisation_concept_id:'#V#household'}}, deps); + expect(deps.rotateWindow).toHaveBeenCalledTimes(2); +}); + +test('Personal is explicit and cannot hydrate an old first-membership default', async () => { + const deps = dependencies(); + await initialiseLoginOrganisation({login_context:{generation:'new',organisation_concept_id:null}}, deps); + expect(deps.setOrganisation).toHaveBeenCalledWith(null); + expect(deps.setNamespace).toHaveBeenCalledWith('#V#person'); +}); + +test('failed or contradictory binding never marks bootstrap complete', async () => { + const deps = dependencies(); + const status = {login_context:{generation:'new',organisation_concept_id:'#V#primary'}}; + deps.postJson.mockResolvedValue({organisation_id:'#V#sail'}); + await expect(initialiseLoginOrganisation(status, deps)).rejects.toThrow('did not match'); + expect(deps.storage.getItem('von_login_context_generation')).toBeUndefined(); + expect(deps.setOrganisation).not.toHaveBeenCalled(); +});