From b4a33aa9b3b6b07f333b536ab2d99e7c11d96a7e Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 21 May 2026 08:14:49 -0500 Subject: [PATCH] feat(http): include missing_verb in ACL-deny 403 bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ACL-deny sites now write a JSON body naming the missing verb so the client-side toast can render "you need here" and offer elevation (the path-scoped /.profile/access?path= reports whether elevation would unlock the verb). Body shape: {"error": "Forbidden", "missing_verb": "w"} New helper writeForbidden(w, action) in errors.go, applied at the four primary ACL-deny gates: - directory.go (list, action=read) - fileapi.go (file CRUD; action varies per request) - tablehandler.go (table read) - archivehandler.go (existence-leak guard, treated as read) Other 403 sites (no authenticated principal, planreview detail errors) keep their plain-text bodies — "missing_verb" doesn't apply there. Existing clients that read the body as text see the JSON string instead of "Forbidden\n"; no client in this repo parses the body for content, so it's a non-breaking change in practice. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/internal/handler/archivehandler.go | 2 +- zddc/internal/handler/directory.go | 2 +- zddc/internal/handler/errors.go | 54 +++++++++++++++++++++++++ zddc/internal/handler/fileapi.go | 2 +- zddc/internal/handler/fileapi_test.go | 49 ++++++++++++++++++++++ zddc/internal/handler/tablehandler.go | 2 +- 6 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 zddc/internal/handler/errors.go diff --git a/zddc/internal/handler/archivehandler.go b/zddc/internal/handler/archivehandler.go index eb9c538..f6a4c1a 100644 --- a/zddc/internal/handler/archivehandler.go +++ b/zddc/internal/handler/archivehandler.go @@ -142,7 +142,7 @@ func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseW // non-empty bucket, 403 — never confirm the project's archive // exists to a caller with no permissions in it. if len(result) == 0 { - http.Error(w, "Forbidden", http.StatusForbidden) + writeForbidden(w, policy.ActionRead) return } diff --git a/zddc/internal/handler/directory.go b/zddc/internal/handler/directory.go index 3317215..768c8d3 100644 --- a/zddc/internal/handler/directory.go +++ b/zddc/internal/handler/directory.go @@ -78,7 +78,7 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit isRoot := dirPath == "" if !isRoot { if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, PrincipalFromContext(r), urlPath); !allowed { - http.Error(w, "Forbidden", http.StatusForbidden) + writeForbidden(w, policy.ActionRead) return } } diff --git a/zddc/internal/handler/errors.go b/zddc/internal/handler/errors.go new file mode 100644 index 0000000..656d8ac --- /dev/null +++ b/zddc/internal/handler/errors.go @@ -0,0 +1,54 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" +) + +// writeForbidden emits a 403 JSON response naming the missing verb. Used +// at every ACL-deny site so the client-side toast can render a specific +// "you need here" message and offer elevation when the path-scoped +// /.profile/access?path= reports a would_elevate_grant covering that verb. +// +// Body shape: +// +// {"error": "Forbidden", "missing_verb": "w"} +// +// Existing clients that read the body as text see the JSON string instead +// of "Forbidden\n" — both are diagnostic-only display strings, no client +// in this repo parses the previous plain-text body for content. Used in +// place of `http.Error(w, "Forbidden", http.StatusForbidden)` exclusively +// for ACL-deny cases. Other 403 conditions (no authenticated principal, +// existence-leak guards, etc.) keep the plain-text variant since +// "missing_verb" doesn't apply to them. +func writeForbidden(w http.ResponseWriter, action string) { + verb := verbForAction(action) + body, _ := json.Marshal(map[string]string{ + "error": "Forbidden", + "missing_verb": verb, + }) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write(body) +} + +// verbForAction maps a policy.Action constant to its single-character +// verb. Mirrors policy.actionVerb but emits the wire-format letter +// rather than the bitmask, so the JSON body carries "r"/"w"/"c"/"d"/"a" +// — the same alphabet the listing's `verbs` field uses. +func verbForAction(action string) string { + switch action { + case policy.ActionWrite: + return "w" + case policy.ActionCreate: + return "c" + case policy.ActionDelete: + return "d" + case policy.ActionAdmin: + return "a" + default: + return "r" + } +} diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index b3690c5..aa2b96e 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -156,7 +156,7 @@ func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request, decider := DeciderFromContext(r) allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, urlPath, action) if !allowed { - http.Error(w, "Forbidden", http.StatusForbidden) + writeForbidden(w, action) return false } return true diff --git a/zddc/internal/handler/fileapi_test.go b/zddc/internal/handler/fileapi_test.go index f10f903..88d9bb7 100644 --- a/zddc/internal/handler/fileapi_test.go +++ b/zddc/internal/handler/fileapi_test.go @@ -5,6 +5,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "net/http" "net/http/httptest" "os" @@ -146,6 +147,54 @@ func TestFileAPI_PutDenyForbidden(t *testing.T) { if rec.Code != http.StatusForbidden { t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String()) } + + // 403 body carries JSON with the missing verb so the client toast + // can render "you need here" and offer elevation when the + // path-scoped /.profile/access reports an elevation grant. PUT to + // a path with no existing file is gated on `c` (create); a PUT + // over an existing file would gate on `w` instead — covered by + // the test below. + if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + var body struct { + Error string `json:"error"` + MissingVerb string `json:"missing_verb"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode 403 body: %v (raw: %s)", err, rec.Body.String()) + } + if body.MissingVerb != "c" { + t.Errorf("missing_verb = %q, want c (PUT to non-existing file gates on create)", body.MissingVerb) + } +} + +// TestFileAPI_PutDenyForbiddenOverwriteVerb — PUT over an existing file +// gates on the write verb, so 403 reports missing_verb=w. Mirrors +// TestFileAPI_PutDenyForbidden but with a seeded file. +func TestFileAPI_PutDenyForbiddenOverwriteVerb(t *testing.T) { + cfg, do, _ := fileAPITestSetup(t, nil, map[string]string{ + "Working/seeded.md": "before", + }) + if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), + []byte("acl:\n permissions:\n \"*@allowed.com\": rwcd\n"), 0o644); err != nil { + t.Fatalf("rewrite .zddc: %v", err) + } + zddc.InvalidateCache(cfg.Root) + + rec := do(http.MethodPut, "/Working/seeded.md", "alice@example.com", []byte("after"), nil) + if rec.Code != http.StatusForbidden { + t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String()) + } + var body struct { + MissingVerb string `json:"missing_verb"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode 403 body: %v", err) + } + if body.MissingVerb != "w" { + t.Errorf("missing_verb = %q, want w (PUT over existing file gates on write)", body.MissingVerb) + } } func TestFileAPI_PutHiddenSegmentRejected(t *testing.T) { diff --git a/zddc/internal/handler/tablehandler.go b/zddc/internal/handler/tablehandler.go index f542f15..e657f0f 100644 --- a/zddc/internal/handler/tablehandler.go +++ b/zddc/internal/handler/tablehandler.go @@ -386,7 +386,7 @@ func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r * slog.Warn("table: policy error", "path", req.Dir, "err", err) } if allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, r.URL.Path, policy.ActionRead); !allowed { - http.Error(w, "Forbidden", http.StatusForbidden) + writeForbidden(w, policy.ActionRead) return }