mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
A group states the rule over its own ops
The authorizer was already per-definition and a group has always been one, so a group could hold a tighter rule than whatever composed it — App.rule asks its own first. What was missing was the way to say so: Authorize existed only on App, and a subsystem that guards its own writes had to assert its group back to the concrete type to reach the field. Without it there are two spellings and both are wrong. The rule on nothing leaves every write there authenticated and unchecked. The rule on the host makes it the HOST's, and that covers every op the host serves — so a sibling subsystem's ops answer to rules written about a principal this guard never attached, and every valid call to it is refused. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -142,6 +142,25 @@ func (g *Group) Undeclared() *Group {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authorize installs fn as the rule over the ops declared on THIS group, and
|
||||||
|
// only those. It is [App.Authorize] at a prefix, with the same semantics: asked
|
||||||
|
// at invoke on the decoded input, a tighter rule than the one composed above it
|
||||||
|
// wins, and declaring none here falls through to that one rather than dropping
|
||||||
|
// it.
|
||||||
|
//
|
||||||
|
// The distinction is the whole point of having it. A subsystem that guards its
|
||||||
|
// own writes must put the rule on the GROUP it guards: on the group, every op
|
||||||
|
// under it answers the rule; on the host app, the rule becomes the HOST's, and
|
||||||
|
// [App.Authorize] covers every op the host serves — including a sibling
|
||||||
|
// subsystem's, whose ops would then be judged by rules written about a principal
|
||||||
|
// this guard never attached. Refusing every valid call to an unrelated
|
||||||
|
// subsystem is the failure that shape produces, and putting the rule nowhere is
|
||||||
|
// the other one: an authenticated write that nothing checked.
|
||||||
|
func (g *Group) Authorize(fn Authorizer) *Group {
|
||||||
|
g.on.authorizer = fn
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
// Get declares a GET operation.
|
// Get declares a GET operation.
|
||||||
func (g *Group) Get[In, Out any](path string, fn TypedHandler[In, Out], opts ...OpOption) *Operation[In, Out] {
|
func (g *Group) Get[In, Out any](path string, fn TypedHandler[In, Out], opts ...OpOption) *Operation[In, Out] {
|
||||||
return g.declare("GET", path, fn, opts)
|
return g.declare("GET", path, fn, opts)
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package zip_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/zap-proto/zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
type guardIn struct {
|
||||||
|
Org string `json:"org"`
|
||||||
|
}
|
||||||
|
type guardOut struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// A GROUP'S RULE COVERS ITS OWN OPS AND NO SIBLING'S.
|
||||||
|
//
|
||||||
|
// Both halves are load-bearing, and each one missing is its own defect. Without
|
||||||
|
// the rule on the guarded group, every write there runs authenticated but
|
||||||
|
// unchecked — a valid bearer editing another org's records. With the rule on the
|
||||||
|
// HOST instead, it becomes the host's rule, and [zip.App.Authorize] covers every
|
||||||
|
// op the host serves, so a sibling subsystem's ops get judged by rules written
|
||||||
|
// about a principal this guard never attached: a refusal on every valid call.
|
||||||
|
//
|
||||||
|
// hanzoai/iam is the service this is about. It used to reach a group's rule by
|
||||||
|
// asserting app.Group("") back to the concrete type, which the router no longer
|
||||||
|
// allows, so the rule needs a name of its own rather than a cast.
|
||||||
|
func TestGroup_AuthorizeCoversItsOwnOpsOnly(t *testing.T) {
|
||||||
|
app := zip.New(zip.Config{AppName: "guarded", DisableStartupMessage: true})
|
||||||
|
|
||||||
|
// The guarded subsystem: only its own org may write.
|
||||||
|
guarded := app.Group("/v1/iam").Authorize(
|
||||||
|
func(_ context.Context, _ zip.Op, in any) (zip.Decision, error) {
|
||||||
|
gi, ok := in.(*guardIn)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("authorizer got %T, want the decoded *guardIn", in)
|
||||||
|
}
|
||||||
|
if gi.Org != "mine" {
|
||||||
|
return zip.Decision{Effect: zip.Deny, Clause: "org", Reason: "not your org"}, nil
|
||||||
|
}
|
||||||
|
return zip.Decision{Effect: zip.Allow}, nil
|
||||||
|
})
|
||||||
|
guarded.Post("/records", func(_ context.Context, _ *guardIn) (*guardOut, error) {
|
||||||
|
return &guardOut{OK: true}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// A sibling that declares no rule of its own, and must not inherit this one.
|
||||||
|
app.Group("/v1/reports").Post("/run", func(_ context.Context, _ *guardIn) (*guardOut, error) {
|
||||||
|
return &guardOut{OK: true}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := app.Build(); err != nil {
|
||||||
|
t.Fatalf("Build: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
post := func(path, org string) int {
|
||||||
|
b, _ := json.Marshal(guardIn{Org: org})
|
||||||
|
req := httptest.NewRequest("POST", path, bytes.NewReader(b))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := app.Fiber().Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", path, err)
|
||||||
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := post("/v1/iam/records", "mine"); code != 200 {
|
||||||
|
t.Errorf("guarded write by its own org = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if code := post("/v1/iam/records", "someone-else"); code != 403 {
|
||||||
|
t.Errorf("guarded write by another org = %d, want 403 — the group's rule is what checks it", code)
|
||||||
|
}
|
||||||
|
if code := post("/v1/reports/run", "someone-else"); code != 200 {
|
||||||
|
t.Errorf("sibling write = %d, want 200 — a group's rule is not the host's", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user