// Package knowledge is the retrieval layer: ingest, permissioning and hybrid // search over documents an agent may read. // // Two invariants shape every line of it, and they are not independent. // // **I1 — an agent reads exactly what its caller could read directly.** Not one // chunk more. Retrieval is the easiest place in a platform to break this, // because a retriever's natural signature is `retrieve(query, k)` and the // caller is nowhere in it. §5 is blunt about the fix: the entry point is // `retrieve(query, principal, scopes, k)` and there is no overload without a // principal. This package has exactly one exported way to search and it will // not run without one. // // **I2 — ACL filtering happens before scoring, never after.** The tempting // implementation is to rank first and drop forbidden results afterwards; it is // simpler, it is one line, and it leaks. Not through the text — the forbidden // chunk is never printed — but through everything around it: a result count // that is short, a top-3 that is missing its top-1, a summary whose confidence // tracks documents the caller cannot see. So the permission predicate is pushed // into BOTH the keyword query and the vector query as a pre-filter, and the // fusion that follows only ever sees rows the caller was entitled to. // // This file is the permission half. It answers two questions and nothing else: // what tags does a document carry, and what tags does this caller hold. package knowledge import ( "fmt" "sort" "strings" "github.com/krow/krow-backend/go-api/internal/authctx" "github.com/krow/krow-backend/go-api/internal/domain" ) // ACLVersion is the generation of the derivation below. // // §5: a reindex is required whenever ACL derivation logic changes. Bumping this // constant is what makes that requirement enforceable — every document records // the version that produced its tags, so "which documents predate the change" // is a query rather than a guess, and a retriever can refuse stale rows instead // of quietly serving tags that mean something different now. // // Bump it whenever GrantsFor or TagsFor changes what a tag MEANS. Adding a new // tag kind that nothing yet emits does not need a bump; changing who `tenant` // reaches does. const ACLVersion = 1 /* ── The tag vocabulary ─────────────────────────────────────────────────── */ // Tag prefixes. A closed set, deliberately. // // The alternative — free-text tags supplied at ingest — makes the ACL a // scripting surface: whoever writes the ingest call decides what "internal" // means, and two callers can disagree. Here a tag is derived from a declared // audience by code in this file, and a tag nobody can hold is refused at ingest // rather than indexing a document into invisibility. const ( // TagTenant reaches everyone in the organization. The ordinary case for a // handbook or a policy: internal, but not restricted. TagTenant = "tenant" // TagRole reaches one role. `role:admin`, `role:employer`, `role:talent`. TagRole = "role:" // TagUser reaches one person by id. For a document about them. TagUser = "user:" // TagEmail reaches one person by email. The schema ties several resources // to a person by email rather than by foreign key (see the policy table's // note on ScopeEmail), so a document derived from one of those rows can // only name its subject this way. TagEmail = "email:" ) // Audience is what an ingest call declares about who a document is for. // // Deliberately not tags. An ingester says "this is for the whole tenant" or // "this is about this worker"; TagsFor turns that into the strings the index // stores. Keeping the two apart is what lets ACLVersion mean anything — the // declared audience is stable, the encoding of it is what changes. type Audience struct { // Tenant makes the document readable by everyone in the organization. Tenant bool // Roles restricts it to specific roles. Roles []domain.Role // UserIDs and Emails restrict it to specific people. UserIDs []string Emails []string } // TenantWide is the ordinary audience: everyone in the organization. func TenantWide() Audience { return Audience{Tenant: true} } // ForRoles restricts a document to specific roles. func ForRoles(roles ...domain.Role) Audience { return Audience{Roles: roles} } // ForPerson restricts a document to one person, by whichever identifiers are // known. Both are accepted because the schema addresses people both ways. func ForPerson(userID, email string) Audience { a := Audience{} if userID != "" { a.UserIDs = []string{userID} } if email != "" { a.Emails = []string{email} } return a } // TagsFor renders an audience as the tags a chunk row carries. // // Returns an error rather than an empty slice when an audience reaches nobody. // §5 says a chunk without ACL metadata is rejected at ingest, and the reason is // worth stating: an empty tag array is not "private", it is a row the `&&` // operator can never match. A document that indexed to nothing looks ingested, // reports a chunk count, and is silently unreachable — which is a support // ticket that takes a week to diagnose. func TagsFor(a Audience) ([]string, error) { seen := map[string]bool{} var tags []string add := func(t string) { if t == "" || seen[t] { return } seen[t] = true tags = append(tags, t) } if a.Tenant { add(TagTenant) } for _, r := range a.Roles { // Only the three the authorization table recognises. An unrecognised // role would produce a tag no principal can ever hold, which is the // invisible-document failure arriving by a different route. if _, ok := domain.ParseRole(string(r)); !ok { return nil, fmt.Errorf("knowledge: %q is not a role", r) } add(TagRole + string(r)) } for _, id := range a.UserIDs { add(TagUser + strings.TrimSpace(id)) } for _, email := range a.Emails { // Lower-cased at both ends. The column is citext so the database does // not care, but the tag is a plain text array element and `Maya@x` and // `maya@x` would be two different tags. add(TagEmail + strings.ToLower(strings.TrimSpace(email))) } if len(tags) == 0 { return nil, fmt.Errorf( "knowledge: this document declares no audience; a chunk with no ACL is not private, " + "it is unreachable, so ingest refuses it (§5)") } // Sorted so the same audience always produces the same array. Two documents // with identical permissions should compare equal, and a diff of a reindex // should show only what actually changed. sort.Strings(tags) return tags, nil } /* ── What a caller holds ────────────────────────────────────────────────── */ // GrantsFor is the tags a principal holds. // // The other side of TagsFor, and the whole of I1 as far as retrieval is // concerned: a chunk is visible when `acl && grants` is true, so this function // decides exactly what an agent can reach. It is small on purpose. Every line // added here widens what every agent in the platform can see. // // Returns nil for a principal this platform does not recognise — no tenant, no // role, an unlisted role. nil grants match nothing, because `acl && '{}'` is // false for every row, so an unknown caller retrieves an empty result set // rather than being special-cased somewhere downstream. func GrantsFor(p authctx.Identity) []string { if strings.TrimSpace(p.OrgID) == "" { // I5. There is no cross-tenant reader and no "all organizations" mode. return nil } role, ok := domain.ParseRole(p.Role) if !ok { return nil } grants := []string{TagTenant, TagRole + string(role)} if id := strings.TrimSpace(p.UserID); id != "" { grants = append(grants, TagUser+id) } if email := strings.ToLower(strings.TrimSpace(p.Email)); email != "" { grants = append(grants, TagEmail+email) } sort.Strings(grants) return grants } // CanRead reports whether a set of grants reaches a set of tags. // // The Go mirror of the `&&` in the SQL, for tests and for the ingest-time // sanity check. Retrieval does NOT call this: filtering in Go is exactly the // post-filter I2 forbids, and having a Go implementation available is precisely // the temptation worth naming here so nobody reaches for it. func CanRead(grants, tags []string) bool { held := make(map[string]bool, len(grants)) for _, g := range grants { held[g] = true } for _, t := range tags { if held[t] { return true } } return false }