From 53a10ab119bd039e6d6fe068907fa04cd3466df5 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 21 May 2026 08:14:25 -0500 Subject: [PATCH] feat(listing): per-entry verbs string for client-side capability gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `verbs` field (canonical "rwcda" subset) to every directory listing entry, computed via a new `policy.EffectiveVerbsFromChainP(ctx, d, chain, p, path)` helper that routes each of the five actions through the decider and unions the allowed bits — so an external OPA's overrides surface in the wire field, and active-admin elevation produces the full grant. Semantics: - file entry: verbs from the parent dir's chain (files inherit; they have no .zddc of their own). Same chain Writable uses. - directory entry: verbs from the subdir's OWN chain, so a fenced or extended .zddc inside it shows through. - virtual entries (auto-own homes, canonical-folder placeholders, workflow received/ window, table.yaml/form.yaml spec rows): verbs computed against the would-be path's chain so client affordances render correctly before any write materialises a real folder. Writable stays in lockstep with verbs for the transition window so existing clients (markdown/yaml editor save buttons) keep working unchanged. Clients should migrate to checking 'w' in verbs and let Writable wither. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/internal/fs/tree.go | 141 ++++++++++++++++++++------------- zddc/internal/fs/tree_test.go | 98 +++++++++++++++++++++++ zddc/internal/listing/types.go | 25 ++++++ zddc/internal/policy/policy.go | 19 +++++ 4 files changed, 228 insertions(+), 55 deletions(-) diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index 4414caf..341f120 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -133,8 +133,8 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, continue } subURLPath := baseURL + name + "/" - allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, subURLPath) - if !allowed { + subVerbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, subURLPath) + if !subVerbs.Has(zddc.VerbR) { continue // omit denied directories silently } // Pull the title from this subdir's own .zddc, if it has @@ -156,6 +156,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, DisplayName: displayName, Declared: declared, Title: title, + Verbs: subVerbs.String(), } result = append(result, fi) continue @@ -172,26 +173,22 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, DisplayName: displayName, Declared: declared, } - // Writable surfaces whether THIS principal could PUT this file - // — same decision as the file API's authorizeAction would - // reach. Uses the parent-dir chain (computed once above); - // active-admin status short-circuits the per-file decider - // query when the principal already holds admin authority. - // .zddc requires ActionAdmin (not ActionWrite) so the verb - // matches the file API's gate at fileapi.go:362-364. - action := policy.ActionWrite - if name == ".zddc" { - action = policy.ActionAdmin - } + // Verbs surfaces what the principal can do at this file's URL, + // computed against the parent-dir chain (files inherit from + // parent; they have no .zddc of their own). Writable is the + // legacy single-bit projection — it stays in lockstep with + // the verbs string for the transition window. For .zddc files + // the legacy gate maps Writable to the admin verb (a) instead + // of write (w), matching fileapi.go's ActionAdmin gate at + // the .zddc URL. fileURL := baseURL + name - if parentActiveAdmin { - fi.Writable = true - } else { - allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, fileURL, action) - if allowed { - fi.Writable = true - } + fileVerbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, fileURL) + fi.Verbs = fileVerbs.String() + writableBit := zddc.VerbW + if name == ".zddc" { + writableBit = zddc.VerbA } + fi.Writable = fileVerbs.Has(writableBit) || parentActiveAdmin result = append(result, fi) } @@ -201,7 +198,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, // any case variant already exists for them. A first write to that // path materialises a real folder with auto-own .zddc; subsequent // listings drop the synthetic entry naturally. - if syn, ok := virtualUserHomeEntry(fsRoot, dirPath, userEmail, baseURL, result); ok { + if syn, ok := virtualUserHomeEntry(ctx, decider, fsRoot, dirPath, principal, baseURL, result); ok { result = append(result, syn) } @@ -211,7 +208,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, // previously did this client-side; moving it server-side lets the // directory's `display:` map apply to virtual entries the same // way it applies to real ones. - result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...) + result = append(result, virtualCanonicalFolders(ctx, decider, fsRoot, absDir, principal, baseURL, result, displayMap)...) // Project-level virtual views: // @@ -241,28 +238,24 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, appendVirtualRow := func(syntheticName, partyAbs string) { rowURL := baseURL + url.PathEscape(syntheticName) chain := chainFor(partyAbs) - if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, rowURL); !allowed { + verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, rowURL) + if !verbs.Has(zddc.VerbR) { return } - partyActiveAdmin := elevated && userEmail != "" && - zddc.IsAdminForChain(chain, userEmail) - writable := partyActiveAdmin - if !writable { - allowed, _ := policy.AllowActionFromChainP(ctx, decider, chain, principal, rowURL, policy.ActionWrite) - writable = allowed - } result = append(result, listing.FileInfo{ Name: syntheticName, URL: rowURL, IsDir: false, Virtual: true, - Writable: writable, + Writable: verbs.Has(zddc.VerbW), + Verbs: verbs.String(), }) } appendVirtualPartyDir := func(party, partyAbs string) { dirURL := baseURL + url.PathEscape(party) + "/" chain := chainFor(partyAbs) - if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, dirURL); !allowed { + verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, dirURL) + if !verbs.Has(zddc.VerbR) { return } result = append(result, listing.FileInfo{ @@ -270,6 +263,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, URL: dirURL, IsDir: true, Virtual: true, + Verbs: verbs.String(), }) } @@ -299,8 +293,8 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, // files — they're just party listings rendered by browse. if zddc.IsRowSlot(vv.Slot) { result = append(result, - listing.FileInfo{Name: "table.yaml", URL: baseURL + "table.yaml", IsDir: false, Virtual: true}, - listing.FileInfo{Name: "form.yaml", URL: baseURL + "form.yaml", IsDir: false, Virtual: true}, + listing.FileInfo{Name: "table.yaml", URL: baseURL + "table.yaml", IsDir: false, Virtual: true, Verbs: zddc.VerbR.String()}, + listing.FileInfo{Name: "form.yaml", URL: baseURL + "form.yaml", IsDir: false, Virtual: true, Verbs: zddc.VerbR.String()}, ) } } @@ -321,12 +315,22 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, } } if !hasReal { - result = append(result, listing.FileInfo{ - Name: "received/", - URL: baseURL + "received/", - IsDir: true, - Virtual: true, - }) + receivedURL := baseURL + "received/" + // Verbs against the canonical workflow's chain — the + // virtual `received/` resolves to a read-through window + // onto received//; writes go through serveFilePut + // which rewrites to a +Cn revision. Read is the only verb + // surfaced here. + vrVerbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, receivedURL) + if vrVerbs.Has(zddc.VerbR) { + result = append(result, listing.FileInfo{ + Name: "received/", + URL: receivedURL, + IsDir: true, + Virtual: true, + Verbs: (vrVerbs & zddc.VerbR).String(), + }) + } } } @@ -358,7 +362,8 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, // chain, short-circuited when the principal already holds admin // authority. An elevated admin sees writable=true and the editor lets // them save; a non-admin sees writable=false and the editor mounts -// read-only. +// read-only. Verbs carries the full verb set so a client can also gate +// other affordances (e.g. delete on the editor's toolbar). func virtualZddcEntry(ctx context.Context, decider policy.Decider, parentChain zddc.PolicyChain, principal zddc.Principal, parentActiveAdmin bool, absDir, baseURL string) (listing.FileInfo, bool) { zddcPath := filepath.Join(absDir, ".zddc") if _, err := os.Stat(zddcPath); err == nil { @@ -366,17 +371,14 @@ func virtualZddcEntry(ctx context.Context, decider policy.Decider, parentChain z } else if !os.IsNotExist(err) { return listing.FileInfo{}, false } - writable := parentActiveAdmin - if !writable { - allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, baseURL+".zddc", policy.ActionAdmin) - writable = allowed - } + verbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, baseURL+".zddc") return listing.FileInfo{ Name: ".zddc", URL: baseURL + ".zddc", IsDir: false, Virtual: true, - Writable: writable, + Writable: verbs.Has(zddc.VerbA) || parentActiveAdmin, + Verbs: verbs.String(), }, true } @@ -388,8 +390,10 @@ func virtualZddcEntry(ctx context.Context, decider policy.Decider, parentChain z // incoming, received, issued under archive//; whatever an // operator added via on-disk .zddc paths:). Case-insensitive // presence check suppresses a virtual entry when the on-disk -// directory exists in any case. -func virtualCanonicalFolders(fsRoot, absDir, baseURL string, +// directory exists in any case. Verbs are computed against each +// synthetic child's would-be chain so client-side gating matches +// what a real on-disk folder would carry. +func virtualCanonicalFolders(ctx context.Context, decider policy.Decider, fsRoot, absDir string, principal zddc.Principal, baseURL string, real []listing.FileInfo, displayMap map[string]string) []listing.FileInfo { declared := zddc.ChildrenDeclaredAt(fsRoot, absDir) @@ -411,13 +415,24 @@ func virtualCanonicalFolders(fsRoot, absDir, baseURL string, if present[strings.ToLower(name)] { continue } + childAbs := filepath.Join(absDir, name) + chain, err := zddc.EffectivePolicy(fsRoot, childAbs) + if err != nil { + continue + } + childURL := baseURL + url.PathEscape(name) + "/" + verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, childURL) + if !verbs.Has(zddc.VerbR) { + continue + } synth = append(synth, listing.FileInfo{ Name: name + "/", - URL: baseURL + url.PathEscape(name) + "/", + URL: childURL, IsDir: true, Virtual: true, DisplayName: lookupDisplay(displayMap, name), Declared: true, // synthesized entries are by definition cascade-declared + Verbs: verbs.String(), }) } return synth @@ -439,8 +454,8 @@ func virtualCanonicalFolders(fsRoot, absDir, baseURL string, // - viewerEmail is non-empty // - real does not already contain a directory entry that case-folds // to viewerEmail (so a materialised home doesn't get duplicated) -func virtualUserHomeEntry(fsRoot, dirPath, viewerEmail, baseURL string, real []listing.FileInfo) (listing.FileInfo, bool) { - if viewerEmail == "" { +func virtualUserHomeEntry(ctx context.Context, decider policy.Decider, fsRoot, dirPath string, principal zddc.Principal, baseURL string, real []listing.FileInfo) (listing.FileInfo, bool) { + if principal.Email == "" { return listing.FileInfo{}, false } rel := strings.Trim(filepath.ToSlash(dirPath), "/") @@ -456,15 +471,31 @@ func virtualUserHomeEntry(fsRoot, dirPath, viewerEmail, baseURL string, real []l } // fi.Name carries a trailing slash for dirs. bare := strings.TrimSuffix(fi.Name, "/") - if strings.EqualFold(bare, viewerEmail) { + if strings.EqualFold(bare, principal.Email) { return listing.FileInfo{}, false } } + // Compute verbs against the would-be home's own chain — the + // auto_own_fenced declaration in defaults.zddc.yaml means a real + // home grants the creator rwcda; the synthetic entry reports the + // same so client-side gating renders the "+ New" affordances + // immediately, before the first write materialises the folder. + homeAbs := filepath.Join(fsRoot, filepath.FromSlash(dirPath), principal.Email) + chain, err := zddc.EffectivePolicy(fsRoot, homeAbs) + if err != nil { + return listing.FileInfo{}, false + } + homeURL := baseURL + url.PathEscape(principal.Email) + "/" + verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, homeURL) + if !verbs.Has(zddc.VerbR) { + return listing.FileInfo{}, false + } return listing.FileInfo{ - Name: viewerEmail + "/", - URL: baseURL + url.PathEscape(viewerEmail) + "/", + Name: principal.Email + "/", + URL: homeURL, IsDir: true, Virtual: true, + Verbs: verbs.String(), }, true } diff --git a/zddc/internal/fs/tree_test.go b/zddc/internal/fs/tree_test.go index b6ded6c..cd067b6 100644 --- a/zddc/internal/fs/tree_test.go +++ b/zddc/internal/fs/tree_test.go @@ -257,3 +257,101 @@ func TestListDirectory_VirtualFolderNav_FiltersInFlight(t *testing.T) { t.Errorf("project-level folder-nav listing = %v, want %v", partyDirs, want) } } + +// TestListDirectory_VerbsPerEntry — every entry in a directory listing +// carries `verbs`, the canonical "rwcda" subset granted to the caller +// at that entry's URL. Files and dirs are gated against different +// chains (files use parent's, dirs use their own), so a fenced subdir +// surfaces a different verb set than its file siblings. +func TestListDirectory_VerbsPerEntry(t *testing.T) { + root := t.TempDir() + // Root grants alice read across the project; bob nothing. + if err := os.WriteFile(filepath.Join(root, ".zddc"), + []byte("acl:\n permissions:\n \"alice@example.com\": rw\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "Proj", "sub"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "Proj", "doc.md"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + // Subdir extends alice's grant to include create — confirms the + // dir entry's verbs come from its OWN chain, not parent's. + if err := os.WriteFile(filepath.Join(root, "Proj", "sub", ".zddc"), + []byte("acl:\n permissions:\n \"alice@example.com\": rwc\n"), 0o644); err != nil { + t.Fatal(err) + } + zddc.InvalidateCache(root) + + got, err := ListDirectory(context.Background(), nil, root, + "Proj", "alice@example.com", "/Proj/", false, false) + if err != nil { + t.Fatalf("list: %v", err) + } + wantVerbs := map[string]string{ + "doc.md": "rw", // file: parent chain (project root → rw) + "sub/": "rwc", // dir: own chain (extends to rwc) + } + for _, fi := range got { + want, ok := wantVerbs[fi.Name] + if !ok { + continue + } + if fi.Verbs != want { + t.Errorf("entry %s verbs = %q, want %q", fi.Name, fi.Verbs, want) + } + // Writable stays in lockstep with verbs for the transition + // window — w bit for files, r/c semantics for dirs (no + // Writable on dirs today; we don't assert it). + if !fi.IsDir { + wantWritable := want == "rw" + if fi.Writable != wantWritable { + t.Errorf("entry %s Writable = %v, want %v", fi.Name, fi.Writable, wantWritable) + } + } + } +} + +// TestListDirectory_VerbsActiveAdminBypass — an elevated admin sees the +// full "rwcda" verb set on every entry regardless of explicit ACL +// grants. Mirrors the InternalDecider's single bypass branch. +func TestListDirectory_VerbsActiveAdminBypass(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, ".zddc"), + []byte("admins:\n - admin@example.com\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "Proj", "doc.md"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + zddc.InvalidateCache(root) + + // Elevated admin sees rwcda everywhere. + got, err := ListDirectory(context.Background(), nil, root, + "Proj", "admin@example.com", "/Proj/", false, true /* elevated */) + if err != nil { + t.Fatalf("list: %v", err) + } + for _, fi := range got { + if fi.Verbs != "rwcda" { + t.Errorf("elevated admin %s verbs = %q, want rwcda", fi.Name, fi.Verbs) + } + } + + // Same admin un-elevated sees nothing (no explicit ACL grant, + // admin bypass disabled). + got, err = ListDirectory(context.Background(), nil, root, + "Proj", "admin@example.com", "/Proj/", false, false) + if err != nil { + t.Fatalf("list un-elevated: %v", err) + } + for _, fi := range got { + if fi.Verbs == "rwcda" { + t.Errorf("un-elevated admin %s verbs = %q, should not be full grant", fi.Name, fi.Verbs) + } + } +} diff --git a/zddc/internal/listing/types.go b/zddc/internal/listing/types.go index 013326f..345eadf 100644 --- a/zddc/internal/listing/types.go +++ b/zddc/internal/listing/types.go @@ -60,5 +60,30 @@ type FileInfo struct { // false-or-unknown and gate writes accordingly. Read-only-by- // default is the safer client-side fallback if the server forgets // to populate it. + // + // Superseded by Verbs (which carries the full verb set); kept + // alongside it for the transition window so existing clients + // reading writable: don't break. New clients should read Verbs and + // check for 'w'. Writable bool `json:"writable,omitempty"` + + // Verbs is the canonical "rwcda" subset granted to the calling + // principal at this entry's URL. Computed by running the policy + // decider for each of the five actions and unioning the allowed + // bits, with the same active-admin bypass that Writable uses. + // + // Semantics per entry kind: + // - file: verbs from the parent directory's chain (files have no + // .zddc of their own; they inherit). Same chain Writable uses. + // - directory: verbs from the subdirectory's OWN chain, so a + // fenced/extended .zddc inside it shows through. The client + // interprets per-kind: `w` on a dir = "I can rename this + // folder", `c` on a dir = N/A at this URL (use path-scoped + // /.profile/access?path= for "can I create inside this dir"). + // + // omitempty: empty set ("") is the explicit-deny case; absence + // means the server didn't populate it. Clients should treat both + // as "no permissions known" and fall back to a server round-trip + // (or just disable affordances) rather than assuming any grant. + Verbs string `json:"verbs,omitempty"` } diff --git a/zddc/internal/policy/policy.go b/zddc/internal/policy/policy.go index 6737a36..01937d1 100644 --- a/zddc/internal/policy/policy.go +++ b/zddc/internal/policy/policy.go @@ -395,6 +395,25 @@ func AllowActionFromChainP(ctx context.Context, d Decider, chain zddc.PolicyChai return d.Allow(ctx, in) } +// EffectiveVerbsFromChainP returns the verb set the principal effectively +// holds at path under chain. Routes each verb through the decider so an +// external OPA's overrides surface in the result; with the InternalDecider +// the answer is the cascade's effective grant plus WORM composition plus +// the active-admin bypass. Output is the same wire shape the client +// receives in listing entry verbs / /.profile/access?path= responses. +func EffectiveVerbsFromChainP(ctx context.Context, d Decider, chain zddc.PolicyChain, p zddc.Principal, path string) zddc.VerbSet { + if p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email) { + return zddc.VerbAll + } + var verbs zddc.VerbSet + for _, action := range []string{ActionRead, ActionWrite, ActionCreate, ActionDelete, ActionAdmin} { + if allowed, _ := AllowActionFromChainP(ctx, d, chain, p, path, action); allowed { + verbs |= actionVerb(action) + } + } + return verbs +} + // cachingDecider wraps another Decider with a small per-decision cache. // Designed for the external-OPA hot path: a single .archive listing or // directory enumeration can hit the same (email, dir-policy) tuple