Skip to content
Indra on Cloud
Go back

Scope Every Read: Confidential Docs in a Shared Bucket

The ticket looked routine. A support engineer opened a confidential memo from matter A while debugging matter B, pasted the link into the thread for matter B, and the link worked. Every confidential read passes through the database before it touches storage.

That link kept working after the ticket closed. One shared bucket held every matter, one broad read role could list it, and the frontend treated a stored URL string as permission. Three quiet holes, open together.

No read reaches storage without a scope check. Client matter id plus session API check DB allows this matter Signed URL short window single object GCS prefix per matter boundary

The API decides. The URL only carries the decision for a short window. Storage only sees the prefixed key.

The first design stored all confidential documents in one Google Cloud Storage bucket under flat names. The service account held a broad read over the whole bucket. The API minted a signed URL once at upload time with far future expiry and the client reused that string on every later view.

That shape fails in three places at once. Anyone with the string reads without touching the API, so a paste across matters is a cross matter read. Anyone with the broad role fetches outside their matters. Anyone who copies a URL holds access after the matter closes. The tradeoff that tempts teams is convenience: one bucket, one role, one permanent link. The cost is that revocation has nowhere to live.

The fix starts with names. Every object lives under a per matter prefix such as matters slash matter id slash doc id. Only the signing identity reads, and it signs one prefixed key at a time after the check passes. A separate bucket per matter would give stronger blast radius control, but it multiplies lifecycle rules and identities, so the prefix keeps one bucket operable while the database carries the allow decision.

The database allows the read, never the client

The core rule is strict: database checked access on every read, never client side state. The client sends matter id plus session. The API loads the membership row for that user and that matter, then confirms the document belongs to the same matter. Only then does it mint a short lived signed URL for that single key. The client never sends an object path.

func openDoc(ctx context.Context, userID, matterID, docID string) (string, error) {
    ok, err := db.HasMatterAccess(ctx, userID, matterID)
    if err != nil || !ok {
        return "", errAccessDenied
    }
    doc, err := db.GetDocInMatter(ctx, docID, matterID)
    if err != nil {
        return "", errAccessDenied
    }
    return storage.SignedGetURL(doc.ObjectKey, shortWindow)
}

The tradeoff is load against safety. A check per open costs a query, and a short window means repeat opens re sign. Proxy streaming through the API would remove signed URLs entirely, but it puts every byte through application egress. Short lived URLs keep bytes on storage egress while the allow decision stays in the database per open. A cached URL that expires is a refresh, not a security event.

Logs keep the matter and the run, not the content

The third hole was the support log. It stored the full prompt plus a document excerpt to make debugging fast. That made the log a second copy of the confidential store, with weaker access and longer retention. The fix adds a redaction gate between code and log sink. Code calls a logger that accepts matter id and run id and a fixed message, and drops payload fields by construction. No raw text, no prompt body, no signed URL string reaches the sink.

A log line carries what traces without revealing: actor hash, matter id, document id, run id, prompt version id, decision, stop reason. A redacted log cannot show why a summary misread a clause. For that case the run row points to the exact versions, and an authorized member reopens them through the normal checked path.

Environments and prompts close the remaining paths

Development and production live in separate Google Cloud projects with separate credentials. No shared service account, no production key copied for a quick repro, no dev job that can sign a production prefix. The separation removes confused deputy reads where the code was correct and the credential was wrong.

Prompt versions are immutable with run linkage. A prompt edit creates a new version row. A run row stores matter id, document version id, prompt version id, parameters, stop reason, and output reference. On Genesis the largest tenant compiles to 613 resources, which taught me to link every run to the exact input that produced it. The same run discipline from my Go tool calling loop applies here: compile errors retry uncapped, run errors cap at five, the loop caps at 25, and a length finish is refused rather than returned. The run row records that stop reason, so a truncated output never ships as a complete memo. Carrier cache keys follow the same rule in smaller form: scope lives inside the key, there region and postal code, here matter id, always part of the lookup.

What I would tell someone building this

Scope the key before you scope the code. Per matter prefixes first, then checks, then URLs.

Mint late and expire early. Sign at open time for one key, not at upload time for reuse. A stored URL is a password you cannot rotate.

Check membership and containment on every open. User in matter plus document in matter, both from the database. The client hint is untrusted input.

Redact by construction, not by review. If the logger type cannot accept a payload, no reviewer has to catch one.

Separate projects, separate keys, versioned prompts. Dev cannot sign prod, and every output points to the exact prompt and document version that made it.


Share this post:

Previous Post
The Reply Arrived Twice. The Record Updated Once.
Next Post
The Code Rolled Back. The Schema Could Not.