Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/engineering/login_organisation_preferences.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion src/backend/security/cloudflare_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion src/backend/server/routes/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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,
}


Expand Down
10 changes: 7 additions & 3 deletions src/backend/server/routes/settings_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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(
{
Expand Down
91 changes: 91 additions & 0 deletions src/backend/services/login_organisation_preference_service.py
Original file line number Diff line number Diff line change
@@ -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}
1 change: 1 addition & 0 deletions src/frontend/web/von_interface/static/js/apiService.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ async function buildHeaders(extraHeaders = {}) {
export {
ensureUniqueWindowSessionId,
getWindowSessionId,
replaceMismatchedWindowSessionId,
WINDOW_SESSION_HEADER,
WINDOW_SESSION_KEY
};
Expand Down
17 changes: 15 additions & 2 deletions src/frontend/web/von_interface/static/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { evaluateServerHealthState } from './utils/serverHealthState.js';
import { handleSelectConceptByIdDetail } from './utils/selectConceptByIdHandler.js';
import {
hasAuthenticatedVonActor,
applyHomeAuthUnavailable,
initialiseHomeAuthentication,
shouldClearHomeIdentityMirrors,
} from './homeAuthGate.js';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
23 changes: 23 additions & 0 deletions tests/backend/test_cloudflare_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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},
Expand Down
60 changes: 60 additions & 0 deletions tests/backend/test_login_organisation_preferences.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading