165 lines
6.3 KiB
Go
165 lines
6.3 KiB
Go
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 ") }
|