agent build

This commit is contained in:
2026-08-28 12:21:44 +05:30
parent b6f8655909
commit f7df96c973
138 changed files with 24164 additions and 207 deletions

View File

@@ -0,0 +1,164 @@
package tools
import (
"fmt"
"strings"
"github.com/krow/krow-backend/go-api/internal/domain"
)
// query is a WHERE clause being assembled, with its bind parameters.
//
// Every tool builds its predicate through this rather than by hand. §13 names
// "passing the tenant id as a plain function argument through five layers" as
// an anti-pattern, and a hand-written `WHERE org_id = ?` at twenty call sites
// is the same failure wearing a different hat: twenty chances to forget, and
// the one that forgets is a cross-tenant read nobody notices until someone
// reports seeing another company's numbers.
//
// The only way to obtain one is authorize(), which cannot return a query
// without having first checked the policy and pinned the tenant.
type query struct {
where []string
args []any
// alias qualifies every column this query names, for the one handler that
// joins. Set through withAlias before any predicate is added, so a column
// cannot be added unqualified and then become ambiguous when a second
// table arrives.
alias string
}
// authorize resolves a caller against a resource's policy and returns the
// predicate their rows are behind.
//
// This is the first line of every handler body. It answers both of §2's
// retrieval questions at once:
//
// - **May this caller list this resource at all?** From the policy table,
// deny-by-default. An unlisted role, an unknown resource and a caller with
// no tenant all refuse.
// - **Which rows are theirs?** The talent scope, rendered as SQL. A
// pre-filter, per I2 — pushed into the query so counts, shares and
// rankings are all computed over exactly the caller's own rows.
//
// The returned Result is non-nil exactly when the caller is refused, and it is
// always the same opaque Denied(): a handler must return it unchanged rather
// than explaining, because two distinguishable refusals are an oracle.
func authorize(tc Context, resourcePath string) (*query, *Result) {
return authorizeOp(tc, resourcePath, domain.OpList, "")
}
// authorizeAs is authorize for a query whose table carries an alias.
func authorizeAs(tc Context, resourcePath, alias string) (*query, *Result) {
return authorizeOp(tc, resourcePath, domain.OpList, alias)
}
// authorizeOp is authorize for an operation other than listing.
//
// A write tool asks for OpCreate here, and the answer is a different set of
// roles: `assignments` lists to everyone and creates for operators only, so a
// talent caller who may perfectly well read their own roster is refused when
// they try to put themselves on one. Reusing the read check for a write would
// have granted exactly that — the most common way an authorization table gets
// quietly bypassed is by asking it the wrong question.
//
// The returned query still carries the caller's READ predicate. A write tool
// uses it to check that the rows it is about to reference are ones this caller
// could have seen: creating an assignment against a posting you cannot read is
// a write that confirms the posting exists.
func authorizeOp(tc Context, resourcePath string, op domain.Op, alias string) (*query, *Result) {
res, ok := domain.ResourceByPath[resourcePath]
if !ok || res.Policy == nil {
denied := Denied()
return nil, &denied
}
role, ok := domain.ParseRole(tc.Principal.Role)
if !ok || !res.Policy.Allows(op, role) {
denied := Denied()
return nil, &denied
}
// I5. No tenant means no query — there is no "all organizations" read, and
// a missing org is a bug upstream rather than a wildcard.
if tc.OrgID() == "" {
denied := Denied()
return nil, &denied
}
q := &query{args: []any{tc.OrgID()}, alias: alias}
q.where = []string{q.col("org_id") + " = $1::uuid"}
switch scope := res.Policy.ScopeFor(role); scope.Kind {
case domain.ScopeNone:
// Operators see the whole tenant. That is what an operator console is.
case domain.ScopeUserID:
q.eq(scope.Column+"::text", tc.Principal.UserID)
case domain.ScopeEmail:
q.eq(scope.Column, tc.Principal.Email)
case domain.ScopeActivePostings:
// Visibility rather than ownership: a talent caller sees the roles they
// could apply to, not the drafts, the paused roles or the closed
// history.
// "active" — the value repo.go renders for this scope. The enum has no
// "open" member, and a status literal that does not exist matches
// nothing, which fails safe and silently.
q.eq(scope.Column+"::text", "active")
case domain.ScopeOwnApplications:
// Ownership by reference. The subquery is the pre-filter — resolving
// the ids in Go first and filtering afterwards would be the
// post-filter I2 forbids.
q.args = append(q.args, tc.Principal.Email)
q.where = append(q.where, fmt.Sprintf(
"%s IN (SELECT id FROM job_applications WHERE org_id = $1::uuid AND email = $%d)",
q.col(scope.Column), len(q.args)))
default:
// An unrecognised scope kind matches nothing rather than everything.
// The safe direction to fail, and loud enough to find.
q.where = append(q.where, "false")
}
return q, nil
}
// withAlias qualifies this query's columns with a table alias.
//
// Called before any predicate is added — including the ones authorize() itself
// adds — so it is threaded through authorizeAs rather than applied afterwards.
func (q *query) col(name string) string {
if q.alias == "" {
return name
}
return q.alias + "." + name
}
// eq adds `column = value`.
func (q *query) eq(column string, value any) {
q.args = append(q.args, value)
q.where = append(q.where, fmt.Sprintf("%s = $%d", q.col(column), len(q.args)))
}
// gte adds `column >= value`.
func (q *query) gte(column string, value any) {
q.args = append(q.args, value)
q.where = append(q.where, fmt.Sprintf("%s >= $%d", q.col(column), len(q.args)))
}
// lt adds `column < value`.
func (q *query) lt(column string, value any) {
q.args = append(q.args, value)
q.where = append(q.where, fmt.Sprintf("%s < $%d", q.col(column), len(q.args)))
}
// raw adds a predicate with no bind parameters.
//
// For constant conditions only — a status literal, a NOT NULL. Never for
// anything derived from input: the whole point of eq/gte/lt is that a value
// cannot reach the statement except as a parameter.
func (q *query) raw(predicate string) {
q.where = append(q.where, predicate)
}
// clause renders the WHERE body.
func (q *query) clause() string { return strings.Join(q.where, " AND ") }