Skip to content
Contextely

Trust boundary

The security model

Contextely exists because of one design decision, so it is worth stating precisely and being honest about its edges.

Entitlement is a factor in the score, not a filter on the answer

The common design for permissioned RAG retrieves broadly, hands everything to a model, and asks the model to leave out what the reader should not see. That is not access control. A model that has read a salary can allude to it, can be coaxed into confirming it, and gives you no artefact proving it did neither.

Contextely applies the asker's entitlement inside the ranking pass itself:

const entitled = isEntitled(asker, candidate.requiredScopes);
const entitlementFactor = entitled ? 1 : 0;

const score = relevance
            * (1 - W + W * freshness)
            * entitlementFactor;

// only score > 0 survives, then:
assertAllEntitled(ranked);   // throws rather than leaks

An object the asker may not see scores exactly zero and is removed by the same cut that removes irrelevant results. Nothing downstream ever holds it: not the synthesis step, not the summariser, not a log line.

Why the ordering closes a side channel

Scoring runs before the freshness refresh, not after. That is deliberate. A staleness refresh makes an outbound call to your system of record. If entitlement were applied afterwards, an unentitled asker's query would still cause that record to be read on their behalf, which is observable in your database logs, and measurable in response timing. Scoring first means the re-fetch never happens for someone who could not have seen the result.

The scope algebra

A scope is a colon-delimited path such as finance:payroll:uk. A grant may end in * to cover a subtree. Requirements are a conjunction: an object requiring finance and exec is readable only by an asker who satisfies both. Owners and admins hold an implicit *. An unrecognised role holds nothing, so the model fails closed.

The refusals are written down

Every retrieval writes a row recording who asked, how many candidates the scoring function considered, and how many it refused on entitlement grounds. That last number is the one a security reviewer actually wants, and it is a stored fact rather than something reconstructed from logs after an incident.

Keys are scoped to people, not workspaces

An API key resolves to a member, and retrieval is scored against that member's entitlement. An agent holding a key can never see more than the person it belongs to. Only an owner or admin can mint a key that speaks as somebody else, which stops a member manufacturing a wider entitlement than they hold.

Source credentials

A source holds a credential Contextely has to replay: a read-only Postgres connection string, or a bearer token for an MCP server. That is a different problem from an API key. An API key is only ever compared, so it is stored as a SHA-256 hash and the plaintext is shown once and never again. A source credential has to be used, so it cannot be hashed, and the honest answer is encryption rather than a hash-shaped word that would not be true.

Source configuration is sealed with AES-256-GCM before it is written, and the ciphertext is bound to the workspace it belongs to, so one cannot be moved to another tenant's row and still open. The key comes from the deployment environment and is not in the database, so a dump, a backup file or a read replica carries ciphertext and nothing else. The dashboard never renders a stored credential back: a field you have already filled shows as unchanged, and replacing it means entering it again.

What that does not do is protect you from an attacker running code inside the application, because the application must be able to decrypt a credential to do the one job it exists for. Encryption at rest is protection against the database leaving the building, not against the process.

Two things reduce the blast radius further and both are worth doing. Give Contextely a Postgres role that can read only the tables your queries name: the connector already refuses anything that is not a single SELECT or WITH and opens the session read-only, but a narrow role is what makes that a boundary rather than a convention. And point a source only at a public address. Contextely resolves the host before every round-trip and refuses a private, loopback, link-local or cloud-metadata address, so a source cannot be turned into a poller of your own internal network. That check resolves the name rather than pinning the connection to the address it resolved, so it stops a statically configured internal target and not a live rebinding attack.

Tenancy

There is no Row Level Security. DATABASE_URL is server-only and never reaches a browser, and the tenant boundary is an explicit workspace predicate on every query. That convention is enforced by a test that reads the query layer and fails the build if a query loses its predicate. A convention nothing checks is a convention that erodes.

What this does not claim