220 lines
8.4 KiB
Go
220 lines
8.4 KiB
Go
// 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
|
|
}
|