authz

package
v2.111.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 15, 2026 License: MIT Imports: 8 Imported by: 0

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

View Source
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.

View Source
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.

View Source
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

View Source
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.

const (
	// Unclassified is the zero value, and it is denied. An RPC that nobody
	// classified is an RPC nobody thought about, and the failure this whole
	// package exists to prevent is a write quietly passing as a read.
	Unclassified Access = iota
	Read
	Write
)

func LookupTool

func LookupTool(name string) (Access, bool)

LookupTool returns what calling the named tool does. The second result is false for a name the toolbox does not define.

func (Access) String

func (a Access) String() string

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.

const (
	// ReadOnly may invoke read RPCs only.
	ReadOnly Clearance = "read-only"
	// ReadWrite may invoke everything its scope allows.
	ReadWrite Clearance = "read-write"
)

func (Clearance) Valid

func (c Clearance) Valid() bool

Valid reports whether c is one of the two clearances. There is no default: a key file that omits the clearance is rejected rather than assumed, because the assumption that would break quietly is "read-write".

type FieldLookup

type FieldLookup func(field string) (top string, nested []string, declared bool)

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

func (k Key) AuthorizeMethod(fullMethod string) error

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

func (k Key) AuthorizeOperation(name string, m Method) error

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

func LegacyToken(token string) *KeySet

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 Load

func Load(path string) (*KeySet, error)

Load reads a key file from disk.

func NewKeySet

func NewKeySet(keys []Key) (*KeySet, error)

NewKeySet validates keys and returns the policy they describe.

func Parse

func Parse(data []byte) (*KeySet, error)

Parse reads a key file's contents.

func Resolve

func Resolve(keyFilePath, legacyToken string) (*KeySet, error)

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) Enabled

func (ks *KeySet) Enabled() bool

Enabled reports whether the set actually enforces anything.

func (*KeySet) Len

func (ks *KeySet) Len() int

Len is the number of keys, for tests and for startup logging.

func (*KeySet) Lookup

func (ks *KeySet) Lookup(secret string) (Key, bool)

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

func LookupMethod(fullMethod string) (Method, bool)

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.

func (Scope) IsZero

func (s Scope) IsZero() bool

IsZero reports whether the scope confines nothing.

type ToolNameLookup

type ToolNameLookup func() (string, bool)

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL