Files
krow_backend/go-api/internal/knowledge/acl.go
2026-08-28 12:21:44 +05:30

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
}