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 ") }