Files
zip/authorize.go
T
zeekayandHanzo Dev f051fd8a08 A host can ask whether a definition composes without ending it
Cloud composes each plugin as its own definition and asks Declaration for
its routes before composing it. A projection of a program that does not
compose panics rather than lying, so a plugin whose middleware guards no
routes took the host down with it; Build answers instead, but installs a
generation and freezes the definition the host is about to compose by
reference.

Verify is Build's first half and the same call — walk, structural rules,
derived rules, rule adoption — and stops there. The host reports which
group is wrong and carries on.

It answers for the definition as the root of its own walk. Middleware at a
served root is exempt, because composition brings it routes later; one
level down the same middleware guards a settled subtree. Only the host
knows which a definition turned out to be, so both verdicts are right and
the tests say so.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 20:02:09 -07:00

325 lines
14 KiB
Go

package zip
import (
"context"
"errors"
"fmt"
"reflect"
)
// Op identifies a typed handler to an [Authorizer], so a decision can key on the
// operation as well as on the input.
//
// AS DECLARED, which for Path is the path written at the registration plus
// whatever prefix the registrar reported — not the address the composition
// finally answers at. A definition declared on a [App.Group] leaves its prefix to
// the walk (see [App.OpScope]), so an op written as Get(app.Group("/v1"), "/x")
// reports "/x" here and answers at "/v1/x". OperationID is the resolved id — the
// explicit [WithOperationID], else the method+path default.
//
// So a rule that must key on WHERE an op answers should take the prefix into its
// registrar (a Router that reports one) or declare the path whole, rather than
// read an absolute address off this. Method and OperationID carry no such
// qualification and are the same value everywhere.
type Op struct {
Method string
Path string
OperationID string
}
// Effect is the outcome of authorizing an op, and there are three of them: run
// it, refuse it, or hold it until a person answers. That is the whole
// vocabulary, so every projection renders exactly these three and none can
// invent a fourth the others would not know how to show.
type Effect uint8
const (
Allow Effect = iota // run the handler
Deny // refuse before the handler runs
Approve // hold the op for a person; the handler does not run
)
// Decision is what an [Authorizer] answers with.
//
// Clause names which clause of the rule reached the outcome — a stable token a
// client dispatches on, written as the refusal's `code` over REST — and Reason
// is the sentence a person reads. Both are meaningful on a Deny and on an
// Approve and are ignored on an Allow, which withheld nothing and so has
// nothing to explain.
//
// It is written as a literal, which is the whole notation:
//
// return zip.Decision{Effect: zip.Allow}, nil
// return zip.Decision{Effect: zip.Deny, Clause: "owner", Reason: "not the owner"}, nil
// return zip.Decision{Effect: zip.Approve, Clause: "spend.cap", Reason: "over the cap"}, nil
type Decision struct {
Effect Effect
Clause string
Reason string
}
// Held is the ONE word for an op a Decision stopped and nobody has answered
// yet. One spelling, so a caller — or a generated client reading the pair of
// responses the document publishes — tells a held op from a finished one
// without guessing.
const Held = "held"
// Approval is what a held op yields: the handler did NOT run, Status is [Held],
// Clause names the clause that asked for sign-off, and Reason is what a person
// reads before answering it.
//
// It carries no id, because minting and tracking an approval belongs to the
// authority that will answer it. zip says once, in one shape, that the op is
// held and why: REST and the call plane give that shape a 202, MCP and the CLI
// carry it in the value, the graph reports it beside the field, and a Go caller
// reads it off the error lane — so no projection can report a held op as a
// finished one.
type Approval struct {
Status string `json:"status"` // always Held
Clause string `json:"clause"`
Reason string `json:"reason,omitempty"`
}
// Error lets an Approval travel the lane every Go caller already reads. A held
// op has no Out and never will have one — the handler did not run — so a
// projection that must answer (*Out, error) has exactly one honest shape for
// it. It is not a failure: nothing broke, and the next step is a person.
func (a *Approval) Error() string {
if a.Reason == "" {
return "held for approval: " + a.Clause
}
return "held for approval: " + a.Clause + ": " + a.Reason
}
// HeldOf reads the Approval back out of an error — the ONE way a caller asks
// whether an op was held, on any projection, with no status to match and no
// string to parse.
func HeldOf(err error) (*Approval, bool) {
var a *Approval
return a, errors.As(err, &a)
}
// approval is the record a held op answers with.
func (d Decision) approval() *Approval {
return &Approval{Status: Held, Clause: d.Clause, Reason: d.Reason}
}
// refusal is the 403 a denied op answers with, carrying the clause as its code
// and the reason as its message. Reusing the error lane is what fans a refusal
// out to REST, MCP, the call plane, the graph and the CLI with no
// per-projection branch: refusal already had exactly one shape.
func (d Decision) refusal() error {
return &HTTPError{Status: 403, Code: d.Clause, Msg: d.Reason}
}
// approvalType is the type the OpenAPI projection publishes for the 202, so a
// gated service documents the held body beside every op's success response.
var approvalType = reflect.TypeOf(Approval{})
// Authorizer authorizes a decoded, validated typed request at the op-invoke
// seam — the ONE point every projection of a typed handler funnels through. It
// runs after the request is decoded into the op's typed In and validated, and
// BEFORE the handler runs, over REST and MCP alike, so the value it authorizes
// is exactly the value the handler will act on: there is no second parse of the
// body for it to diverge from. in is the *In the handler will receive.
//
// It answers a [Decision], and every projection renders the three effects the
// same way: [Allow] runs the handler, [Deny] refuses with a 403 carrying the
// clause and the reason, [Approve] holds the op and renders it held without
// running it.
//
// The error return is for a genuine failure of the CHECK ITSELF — the authority
// is unreachable, say. It is not how a refusal is spelled; a refusal is a Deny.
// A non-nil error aborts the op and is the response, so return a zip.Err* for a
// clean status.
type Authorizer func(ctx context.Context, op Op, in any) (Decision, error)
// Authorize installs fn as the op-invoke authorization rule. It is the op-level
// counterpart to Use: Use wraps the whole request with transport middleware,
// which for a body request sees only the raw bytes; Authorize runs one decision
// on the DECODED typed input of every op, REST and MCP alike — the seam a
// mounted subsystem asks so the value it authorizes is the value the handler
// binds. Call once while mounting, before Listen — the field is read on serve
// goroutines and writing it under load is a race. A nil fn declares no rule.
//
// IT COVERS EVERY OP THIS APP SERVES: the ones it registered itself, the ones
// declared on its groups, and the ones a definition it composed with [App.Use]
// brought with it. All three answer at this app's addresses, at its /mcp, at its
// call plane, in its graph and to its CLI, so all three are its ops. Composition
// is what decides that, so [adopt] settles it at build rather than each
// registration guessing.
//
// A definition may declare a TIGHTER rule of its own and it wins, because
// [App.rule] asks its own first. It cannot declare a looser one: declaring none
// falls through to whatever composed it, so composing a definition can only add
// a rule, never drop one.
//
// A definition composed under two rules that DISAGREE is refused at [App.Build]:
// its ops are one closure and one closure answers to one rule, so there is no
// honest way to serve it under both. See [adopt].
func (a *App) Authorize(fn Authorizer) { a.authorizer = fn }
// OnResult installs fn as the rule for what to do with the OUTCOME of every
// typed op this app serves. It is [App.Authorize]'s counterpart: Authorize is
// ASKED before the handler and may refuse, OnResult is TOLD after it and may
// not. Both run at the one invoke seam, so both cover every projection — the
// REST route, tools/call at /mcp, the by-name call plane, the graph, and the
// in-process CLI, which is the only one no transport middleware can wrap.
//
// That last one is why this exists. Anything a middleware records — a trail, a
// metric, a span — is recorded about a REQUEST, and an in-process invoke is not
// one. A recorder installed here is told about it under the same operation name
// as every other door.
//
// fn is told the operation and the error, and nothing else. The output is
// deliberately absent: a hook that could read it would be a way for a response
// body to reach whatever the hook writes to. Call once while mounting, before
// Listen — the field is read on serve goroutines. A nil fn declares no hook.
func (a *App) OnResult(fn func(ctx context.Context, op Op, err error)) { a.onResult = fn }
// result is the [App.OnResult] hook in force over this App's ops: its own if it
// declared one, else the one it was composed under — the same resolution
// [App.rule] uses, for the same reason.
func (a *App) result() func(context.Context, Op, error) {
if a.onResult != nil {
return a.onResult
}
if over := a.over.Load(); over != nil {
return over.onResult
}
return nil
}
// rule is the Authorizer in force over this App's ops: its own if it declared
// one, else the one it was composed under.
//
// Asked at invoke rather than settled at registration, so [App.Authorize],
// [App.Group] and [App.Use] are independent of each other and of order: a rule
// declared after the paths were grouped still covers them, a rule declared
// before still covers a group made later, and a definition written with no idea
// who would compose it still answers to whoever did.
func (a *App) rule() Authorizer {
if a.authorizer != nil {
return a.authorizer
}
if over := a.over.Load(); over != nil {
return over.authorizer
}
return nil
}
// adopt settles which rule governs each definition in a composition, and is the
// only place that can: a definition is written without knowing what will include
// it, and what includes it is what decides whose ops its ops become.
//
// The rule over a definition is the nearest one declared ABOVE it on the path
// the walk took to reach it. A group has no rule of its own, so the walk carries
// straight through it to the app it groups; a composed definition that declares
// none likewise answers to its includer. One traversal answers both, because a
// group and a composed definition are one mechanism here.
//
// TWO INCLUDERS THAT DISAGREE ARE REFUSED, with both paths named. A definition's
// ops are one closure — composing it does not copy them — so serving it under
// two rules would mean one closure answering to two, which no implementation can
// do. The alternative is to keep whichever composition ran last, and half the
// time that is the looser of the two: a surface that reads as governed and is
// not. A build that stops says so; a surface that quietly serves does not.
//
// A definition composed BOTH under a rule and under no rule takes the rule. That
// is not a disagreement — nothing was said in the second place — and taking it
// is the answer that cannot be too permissive.
func adopt(occ []occurrence) error {
var errs []error
for _, o := range occ {
t := o.ctx.trail
if t == nil {
continue
}
over := ruling(t)
if over == nil || over == t.def {
// Nothing above declared a rule, or this definition declared its own.
continue
}
if prev := t.def.over.Load(); prev != nil && prev != over {
errs = append(errs, fmt.Errorf(
"zip: %s is composed under two rules that disagree — %s declares one and so does %s. "+
"Its ops are one closure and answer to one rule, so name the rule on %s itself "+
"or compose it under only one of them",
t.label, over.label(), prev.label(), t.label))
continue
}
t.def.over.Store(over)
}
return errors.Join(errs...)
}
// ruling is the nearest rule declared at or above t on the path the walk took.
// nil when nothing on that path declared one.
func ruling(t *trail) *App {
for ; t != nil; t = t.up {
if t.def.authorizer != nil {
return t.def
}
}
return nil
}
// Build constructs and installs a generation without starting a listener: the
// walk runs, every validation runs, the projections are rendered, and the
// router goes live — exactly what [App.Listen] does minus the sockets.
//
// It RETURNS THE VERDICT, which is the whole reason it replaced Prepare. A
// composition can be invalid — two definitions claiming one address, a cycle —
// and the old Prepare returned nothing, so the only way to learn was to start
// a server. Now a codegen step, a test or a wiring file's own main can ask
// whether the program it just wrote is a program, and get every conflict at
// once with both claimants named.
//
// Idempotent for the projections (they render once) and monotonic for the
// freeze. Listen calls it; there is no other way to build.
func (a *App) Build() error {
// VALIDATE FIRST. prepare() renders the projections, and a projection of a
// program that does not compose now panics — so validating after it would
// turn every build error into a panic escaping the one function whose whole
// job is to return it.
if err := verify(a); err != nil {
return err
}
a.prepare()
a.buildMu.Lock()
g, err := a.build()
if err == nil {
a.install(g)
}
a.buildMu.Unlock()
return err
}
// Verify answers whether this definition composes, and changes nothing about
// whether it is serving.
//
// It is [App.Build]'s first half and the same call: the walk, the structural
// rules, the derived rules, and rule adoption. What it does NOT do is the second
// half — it renders no projection, builds no generation and installs none, so it
// does not freeze the definition. A definition that is about to be composed by
// reference can therefore be checked first and still be composed.
//
// That is the case it exists for. A projection of a program that does not
// compose panics rather than lying, so asking [App.Declaration] or
// [App.Registry] about a definition whose middleware guards no routes takes the
// host down with it — while [App.Build] would answer, and freeze the definition
// in the same breath. Verify is the answer on its own: a host mounting a
// subsystem can report which group is wrong and carry on.
//
// It does settle which rule governs each definition, exactly as Build's first
// half does, because that is what the walk is for. Composing more and calling
// either again settles it again.
//
// It answers for this definition as the root of its own walk — as if it were
// the app being served. One rule reads differently from there: middleware at a
// served root wraps the routes composition brings it later, so it is exempt,
// while the same middleware one level down guards a subtree that is already
// settled. Only the host knows which of the two a definition turned out to be,
// so a clean Verify here and a refusal from the host's Build are both right.
func (a *App) Verify() error { return verify(a) }