mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
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]>
82 lines
2.7 KiB
Go
82 lines
2.7 KiB
Go
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)
|
|
}
|
|
}
|