Documentation
¶
Overview ¶
Package authz is the key and scope model behind CortexDB's gRPC server.
The server's documented deployment is a shared brain: many agents on many machines against one CortexDB. Until this package existed there was exactly one authorisation state — hold the bearer token and you have full read/write over every row every other agent ever wrote, so an agent on a laptop that only needs to recall its own project notes could delete somebody else's memory. A key here carries a clearance (read-only or read-write) and a scope confining which rows it may touch, so the blast radius of one leaked or misbehaving agent is the slice it was given rather than the whole brain.
What this is not: transport security. CortexDB's gRPC transport is plaintext by design — loopback, a trusted LAN, or a Tailscale interface — so anyone who can read the wire reads the secrets along with the traffic and can then act as any key they have observed. Scoped keys reduce the blast radius between cooperating agents that already share a trusted network; they are not a defence against someone on that network. If the network is not trusted, put TLS or a tunnel underneath. Nothing in this package changes that.
Index ¶
- Constants
- Variables
- func ClassifiedMethods() []string
- func ClassifiedTools() []string
- type Access
- type Clearance
- type FieldLookup
- type Key
- func (k Key) AuthorizeCall(fullMethod string, tool ToolNameLookup) error
- func (k Key) AuthorizeMethod(fullMethod string) error
- func (k Key) AuthorizeOperation(name string, m Method) error
- func (k Key) AuthorizeOperationRows(name string, m Method, lookup FieldLookup) error
- func (k Key) AuthorizeRows(fullMethod string, lookup FieldLookup) error
- type KeySet
- type Method
- type Scope
- type ToolNameLookup
Constants ¶
const ( FieldUserID = "user_id" FieldScope = "scope" FieldNamespace = "namespace" FieldCollection = "collection" )
The request field names a scope confines. These are proto field names, not Go ones, because that is what the interceptor reads off the wire.
const CallToolMethod = "/cortexdb.v1.ToolsService/CallTool"
CallToolMethod is the generic tool entry point, and the one RPC in the table whose classification cannot be decided from its name alone: it dispatches on a tool name carried in the request, and the toolbox behind it holds both reads and writes.
const LegacyKeyID = "legacy-token"
LegacyKeyID names the implicit key synthesised from the single-token configuration, so denials and audit lines have something to name even for deployments that never wrote a key file.
Variables ¶
var ErrDenied = errors.New("denied")
ErrDenied is the sentinel behind every refusal in this package. The gRPC layer maps it onto PERMISSION_DENIED; callers that only want to know whether something was an authorisation failure can use errors.Is instead of matching on message text.
Functions ¶
func ClassifiedMethods ¶
func ClassifiedMethods() []string
ClassifiedMethods lists every classified full method name. Tests use it to compare the table against what the server actually serves.
func ClassifiedTools ¶
func ClassifiedTools() []string
ClassifiedTools lists every tool name the policy knows, sorted. Tests use it to compare the policy's view of the toolbox against the toolbox itself.
Types ¶
type Access ¶
type Access int
Access is what an RPC does to the brain.
func LookupTool ¶
LookupTool returns what calling the named tool does. The second result is false for a name the toolbox does not define.
type Clearance ¶
type Clearance string
Clearance is how much a key may do, independently of which rows it may see. There are two values on purpose: the moment clearance becomes a set of per-RPC grants, nobody can answer "what can this key do" by looking at it.
type FieldLookup ¶
FieldLookup reports what a request message carries under a scope field name.
declared is whether the request type has such a field at the top level at all; top is its top-level value (empty when unset); nested is every non-empty value of that name found deeper in the populated message, such as inside a RetrievalPlan's filters. The split matters: only the top-level field is reliably the one the handler acts on, while a nested copy may override it somewhere downstream, so a nested value is checked for conflict rather than accepted as satisfying the confinement.
type Key ¶
type Key struct {
ID string `json:"id"`
Secret string `json:"secret"`
Clearance Clearance `json:"clearance"`
Scope Scope `json:"scope,omitempty"`
}
Key is one credential. The secret is the bearer token as it arrives on the wire; the id exists so a denial can be attributed and so a key can be revoked by name — deleting its entry from the key file is the revocation.
func (Key) AuthorizeCall ¶
func (k Key) AuthorizeCall(fullMethod string, tool ToolNameLookup) error
AuthorizeCall is AuthorizeMethod with the one refinement CallTool needs.
Every other RPC is decided by the method table alone. CallTool is decided by the Mutates flag of the tool it names, because the table can only say one thing about a method that reaches the whole toolbox, and that one thing has to be "write" — which left a read-only key unable to call search. The MCP shared-brain client proxies every tool call through here, so that made read-only clearance useless for the client path it matters most on.
Three ways this refuses, all of them closed:
- the name cannot be read: an authorization decision that cannot see what it is deciding about is not a decision;
- the name is not a tool: allowing it would mean trusting that the handler really does reject it a moment later, and the handler is not the policy;
- the tool writes and the key does not.
func (Key) AuthorizeMethod ¶
AuthorizeMethod reports whether the key's clearance permits fullMethod.
An unclassified method is denied. Failing open here would mean a newly added RPC — the case most likely to be a write — is reachable by every read-only key until somebody notices.
func (Key) AuthorizeOperation ¶
AuthorizeOperation reports whether the key's clearance permits an operation the caller has already classified. name is what the denial should call the operation — for HTTP, the method and path.
An Unclassified operation is denied, exactly as an unclassified gRPC method is. Failing open here would mean a newly added route — the case most likely to be a write — is reachable by every read-only key until somebody notices.
func (Key) AuthorizeOperationRows ¶
func (k Key) AuthorizeOperationRows(name string, m Method, lookup FieldLookup) error
AuthorizeOperationRows reports whether the key's scope permits the rows an already-classified operation asks for. It is a no-op for an unconfined key.
The rule is the one AuthorizeRows documents at length, and the reasoning there — reject, never narrow, and an unset confined field is a rejection too — applies here unchanged. It has to: a request that names no user_id means "every user" over JSON for the same reason it does over protobuf, and a surface that quietly narrowed it instead would hand back a subset of the answer with nothing to say so.
func (Key) AuthorizeRows ¶
func (k Key) AuthorizeRows(fullMethod string, lookup FieldLookup) error
AuthorizeRows reports whether the key's scope permits the rows this request asks for. It is a no-op for an unconfined key.
The rule, for each confined field:
- the request type must declare the field at its top level, otherwise there is nothing to confine and the call is denied;
- the top-level value must equal the key's value — an unset field means "every user" or "every collection", which a confined key may not ask for;
- no nested copy of the field may disagree.
Denial rather than narrowing is deliberate. Rewriting an over-broad request into a narrower one would hand the caller a quietly different answer than the one it asked for, and a search that silently returns a subset is impossible to debug from the client side. The one place narrowing would be defensible is a search whose field is simply unset, where "" plausibly means "unspecified" rather than "all" — but the same code path also serves deletes, where guessing is not acceptable, so the strict rule applies uniformly.
type KeySet ¶
type KeySet struct {
// contains filtered or unexported fields
}
KeySet is the whole policy a server enforces. A nil or empty KeySet means no authentication at all, which is the historical behaviour of an unset token and is preserved deliberately: silently locking an open loopback deployment on upgrade would be a worse surprise than the open default.
func LegacyToken ¶
LegacyToken maps the historical CORTEXDB_GRPC_TOKEN deployment onto the key model: one full-access key, unconfined. Every deployment in the wild is that shape, and this mapping is the whole of the backward compatibility story — there is no second code path where the old token is treated differently.
func Resolve ¶
Resolve picks the policy for a server from its two configuration inputs.
A key file, when given, is the entire policy: the legacy token is not also admitted alongside it. Honouring both would leave the environment variable as a master key outranking every scope in the file, which is exactly the hole the file exists to close.
func (*KeySet) Lookup ¶
Lookup resolves a presented bearer secret to its key.
Every key is compared and a match does not break the loop: returning early would leak, through timing, roughly where in the file a guessed secret sits. The comparison itself stays crypto/subtle.ConstantTimeCompare, as it was when there was one token and one comparison.
type Method ¶
type Method struct {
Access Access
// Rowless marks an RPC that touches no scoped row at all — liveness,
// server identity, the static tool catalogue. A confined key may still
// call these: they expose nothing a scope could protect, and refusing
// them would break the health probe of every scoped deployment.
Rowless bool
}
Method is one RPC's classification.
func LookupMethod ¶
LookupMethod returns the classification of a gRPC full method name.
type Scope ¶
type Scope struct {
UserID string `json:"user_id,omitempty"`
// MemoryScope is the memory scope string (MemoryScopeUser, Session, …).
// It is called "scope" on the wire; the Go field is not, because
// Scope.Scope reads like a typo every time it appears.
MemoryScope string `json:"scope,omitempty"`
Namespace string `json:"namespace,omitempty"`
Collection string `json:"collection,omitempty"`
}
Scope confines a key to rows matching every field it sets. The zero Scope is unconfined and sees everything the clearance allows.
These four fields are exactly the ones CortexDB requests already carry as proto fields, which is what makes the confinement decidable in an interceptor without the server first reading the row. Deliberately left out: anything that would need a query to evaluate — row ids, content predicates, time windows, per-RPC allow lists. A scope that cannot be decided from the request message alone is a scope that fails open somewhere, and a scope nobody can reason about is worse than no scope at all.
type ToolNameLookup ¶
ToolNameLookup reports the tool a CallTool request names. The second result is false when the name could not be read at all — a request that is not the expected message, or one whose name field is missing.