Skip to content

D118 — Fine-grained RBAC and permission-aware retrieval

Decision. Authorization moves from a per-KB read/write scope to a per-principal policy evaluated at a single point in dispatch. auth.roles declares named allow rules (kb + access: r|rw + optional maps/journals/types selectors, internal/config.RoleSpec, validated by ValidateAuthRoles); a token references roles by name and may also carry a stable id. Roles compile into immutable auth.Permission/auth.Policy values (cmd/cartographer.scopedTokensWithRoles) that are unioned with any legacy scopes, and the middleware puts one auth.Principal in the request context. internal/mcpserver/policy.go resolves every call against that policy through resourceClassForTool, an exhaustive registry inventory: exact-concept, collection, source/destination, or whole-KB. Retrieval enforces the same predicate inside the index rather than after it — SearchFiltered, SearchFTSFiltered and AllEmbeddingsFiltered apply it before the limit. Writes are re-authorized under the git lock via reauthorizeUnderLock immediately before mutating.

Rationale. A KB is the wrong authorization unit for a shared wiki: an agent that must write runbooks under infra/ should not thereby be able to rewrite every other map, and the read side is where a leak actually happens — search, listings and semantic neighbors surface content the caller was never meant to enumerate. Filtering after the limit would have been much simpler, but it turns pagination into an oracle: a short page tells the caller exactly how many hidden concepts matched. Selectors are intersected and there are no deny rules so that policy evaluation stays deterministic and order-independent; adding a role can only widen access, never silently narrow another one. Re-checking under the lock closes the window in which a concept's type changes between the dispatch decision and the commit. resourceClassForTool is exhaustive rather than defaulting, so a tool added without a deliberate choice is denied instead of inheriting whole-KB semantics.

Consequences. Forbidden and missing exact resources are deliberately indistinguishable: both return genericNotFound, which means an operator debugging a 404 cannot tell from the response whether the concept exists — the answer is in the role config, not in the API. FTS pagination reads ranked rows in bounded batches (ftsSearchBatch) and stops at the requested limit, so a heavily filtered query costs more reads than an unfiltered one. AllEmbeddingsFiltered applies the predicate per candidate vector, so semantic search on a narrow role pays a per-concept frontmatter read; the trade was accepted over caching a per-principal view, which would have to be invalidated on every write. auth.roles is YAML-only — a rule is a structured object and the CARTOGRAPHER_TOKENS string form cannot express it — so a deployment configured purely through environment variables keeps whole-KB granularity. Principal IDs are derived from a token digest when not set explicitly, replacing an earlier warning path that logged an 8-character plaintext token prefix. Legacy behavior is preserved end to end: a token with neither scopes nor roles is still an admin, and Policy.Admin bypasses the resolver entirely.