lundie.io Get In Touch

Phase 8: Security & Authentication

November 2025

Building the Gate

User ownership was built into the data model from the start, but enforcement had been deferred while architectural questions remained open – multi-user graph sharing among them. Phase 7 completed the core loop. Now it was time to build the gate.

Firebase Authentication (2 Nov)

Commits: API 8ebfba2 – "Firebase authentication integration and user ownership enforcement" / Web 0ac3075 – "Firebase authentication integration and feature module restructuring"

At the centre was auth_service.py, a FastAPI dependency that verifies Firebase ID tokens and returns a typed CurrentUser object to every protected route. Every endpoint was updated systematically in one coordinated pass – a deliberate choice to ensure no ambiguity around route protection.

auth_service.py – get_current_user dependency
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def get_current_user(authorization: str | None = Header(None)) -> CurrentUser:
    if not authorization or not authorization.lower().startswith("bearer "):
        raise HTTPException(
            status_code=401,
            detail="Missing or malformed Authorization header. Expected: 'bearer <token>'"
        )

    token = authorization.split(" ", 1)[1].strip()

    backend = get_auth_backend()
    verifier = {
        "firebase": verify_firebase_token,
        "custom": verify_custom_jwt,
    }.get(backend)

    if not verifier:
        raise HTTPException(status_code=500, detail="Authentication backend not configured")

    return verifier(token)

verify_firebase_token() calls fb_auth.verify_id_token() and auto-provisions the user in Firestore on first login. The pluggable AUTH_BACKEND env var routes between Firebase and a custom JWT path – Firebase won out on implementation speed, but the door was left open.

Alongside the dependency, a small utility was added to every repository-layer read and update. It returns 404 for both "not found" and "wrong user":

ownership.py – ensure_owner()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def ensure_owner(resource: Optional[BaseModel | dict], user_id: str) -> None:
    if not resource:
        raise HTTPException(status_code=404, detail="Resource not found")

    resource_user_id = (
        getattr(resource, "user_id", None)
        if isinstance(resource, BaseModel)
        else resource.get("user_id")
    )

    if resource_user_id is None:
        raise HTTPException(status_code=500, detail="Resource missing user_id field")

    if resource_user_id != user_id:
        # Return 404 (not 403) to prevent enumeration attacks
        raise HTTPException(status_code=404, detail="Resource not found")

A 403 confirms that a resource exists. A 404 gives an attacker nothing to work with. The same pattern reappears in Phase 10.

The AI Context Gap (5 Nov)

Commit: API daad2bf – "Ownership validation for OpenAI node suggestions and expanded test coverage"

The suggestion endpoint accepted a list of context node IDs and forwarded the associated contents to OpenAI without validating ownership of any of them. This meant an authenticated user could potentially supply node IDs belonging to other users.

The fix validates ownership before the OpenAI call. Firestore's in operator is capped at 10 items per query, so the check works in chunks:

nodes_router.py – batch ownership validation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def batch_validate_ownership():
    registry_ref = get_firestore_client().collection("registry")

    validated_ids = set()
    for i in range(0, len(node_ids), 10):
        chunk = node_ids[i:i + 10]
        query = registry_ref.where("id", "in", chunk).where("user_id", "==", current_user.user_id)
        docs = query.stream()
        validated_ids.update(doc.get("id") for doc in docs)

    return validated_ids

validated_ids = await run_in_threadpool(batch_validate_ownership)

if len(validated_ids) != len(node_ids):
    logger.warning(
        f"User {current_user.user_id} attempted to use {len(missing)} unauthorized context nodes"
    )
    raise HTTPException(status_code=404, detail="One or more context nodes not found")

If fewer IDs come back validated than were submitted, the request is rejected with a 404 – the same anti-enumeration choice as ensure_owner(). The chunked query is a direct consequence of working within Firestore's API constraints rather than abstracting around them.

Authorization Hardening (16–20 Nov)

600640f extended the ownership sweep to edges. Creating an edge requires both the source and target nodes to be owned by the authenticated user – otherwise the graph structure itself becomes a cross-user attack surface. The edge service uses the same chunked Firestore query approach as the suggestion endpoint.

Field Protection via Schema

Commit: API 4d0061c – "Atomic writes, field validation, endpoint hardening and cleanup automation"

Individual PATCH endpoints for thoughts, prompts, insights and results were removed entirely. In their place, typed update schemas use Pydantic's extra = "forbid" to reject any field not on the allowlist at the validation boundary:

journal_schema.py – JournalUpdateSchema
1
2
3
4
5
6
7
class JournalUpdateSchema(AliasModel):
    title: Optional[str] = None
    content: Optional[str] = None
    visibility: Optional[str] = None
    tags: Optional[List[str]] = None

    model_config = {"extra": "forbid"}

Privileged fields – user_id, node_type, created_at, id – are simply absent from the schema; any client sending them receives a 422 at the validation boundary without any manual filtering.

Cross-Tenant Cache Isolation

Commit: API a7ba582 – "Atomic writes, cross-tenant cache isolation and automated cleanup"

The idempotency cache for mutation operations had been keyed by op_id alone. If two users submitted an operation with the same ID – plausible given that op_id values are client-generated – the second user's request would hit the first user's cached result: the wrong node ID returned, a reference to data they don't own. This was caught during the security sweep, not by a live incident, but the risk was concrete.

mutations_service.py – cache key (before / after)
1
2
3
4
5
6
# Before: keyed by op_id alone
op_cache_ref = db.collection("processed_operations").document(op.op_id)

# After: scoped by user to prevent cross-tenant collisions
cache_key = f"{op.user_id}:{op.op_id}"
op_cache_ref = db.collection("processed_operations").document(cache_key)

The cache TTL was also reduced from 24 hours to 4 hours. The longer window had no practical justification and left stale data sitting in Firestore longer than necessary.

Key Commits from This Phase

API 8ebfba2 2025-11-02
[Feat/Security] Firebase authentication integration and user ownership enforcement
API daad2bf 2025-11-05
[Security/Feat] Ownership validation for OpenAI node suggestions and expanded test coverage
Web 0ac3075 2025-11-07
[Feat/Auth] Firebase authentication integration and feature module restructuring
API cd58b98 2025-11-08
[Fix/Refactor] Standardized backend casing and data flow consistency
API 600640f 2025-11-16
[Security] Ownership validation and authorization hardening across node, edge and repository layers
API 4d0061c 2025-11-19
[Security/Refactor] Atomic writes, field validation, endpoint hardening and cleanup automation
API a7ba582 2025-11-20
[Security/Fix] Atomic writes, cross-tenant cache isolation and automated cleanup
Web fee309a 2025-11-24
[Feat/UI] Integrate Radiant design system + node tree visual polish

Get In Touch

Prefer using email? Say hi at hello@lundie.io