feat(listing): per-entry verbs string for client-side capability gating

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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-21 08:14:25 -05:00
parent fb50bb5ef6
commit 53a10ab119
4 changed files with 228 additions and 55 deletions

View file

@ -133,8 +133,8 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
continue continue
} }
subURLPath := baseURL + name + "/" subURLPath := baseURL + name + "/"
allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, subURLPath) subVerbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, subURLPath)
if !allowed { if !subVerbs.Has(zddc.VerbR) {
continue // omit denied directories silently continue // omit denied directories silently
} }
// Pull the title from this subdir's own .zddc, if it has // 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, DisplayName: displayName,
Declared: declared, Declared: declared,
Title: title, Title: title,
Verbs: subVerbs.String(),
} }
result = append(result, fi) result = append(result, fi)
continue continue
@ -172,26 +173,22 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
DisplayName: displayName, DisplayName: displayName,
Declared: declared, Declared: declared,
} }
// Writable surfaces whether THIS principal could PUT this file // Verbs surfaces what the principal can do at this file's URL,
// — same decision as the file API's authorizeAction would // computed against the parent-dir chain (files inherit from
// reach. Uses the parent-dir chain (computed once above); // parent; they have no .zddc of their own). Writable is the
// active-admin status short-circuits the per-file decider // legacy single-bit projection — it stays in lockstep with
// query when the principal already holds admin authority. // the verbs string for the transition window. For .zddc files
// .zddc requires ActionAdmin (not ActionWrite) so the verb // the legacy gate maps Writable to the admin verb (a) instead
// matches the file API's gate at fileapi.go:362-364. // of write (w), matching fileapi.go's ActionAdmin gate at
action := policy.ActionWrite // the .zddc URL.
if name == ".zddc" {
action = policy.ActionAdmin
}
fileURL := baseURL + name fileURL := baseURL + name
if parentActiveAdmin { fileVerbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, fileURL)
fi.Writable = true fi.Verbs = fileVerbs.String()
} else { writableBit := zddc.VerbW
allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, fileURL, action) if name == ".zddc" {
if allowed { writableBit = zddc.VerbA
fi.Writable = true
}
} }
fi.Writable = fileVerbs.Has(writableBit) || parentActiveAdmin
result = append(result, fi) 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 // any case variant already exists for them. A first write to that
// path materialises a real folder with auto-own .zddc; subsequent // path materialises a real folder with auto-own .zddc; subsequent
// listings drop the synthetic entry naturally. // 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) 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 // previously did this client-side; moving it server-side lets the
// directory's `display:` map apply to virtual entries the same // directory's `display:` map apply to virtual entries the same
// way it applies to real ones. // 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: // Project-level virtual views:
// //
@ -241,28 +238,24 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
appendVirtualRow := func(syntheticName, partyAbs string) { appendVirtualRow := func(syntheticName, partyAbs string) {
rowURL := baseURL + url.PathEscape(syntheticName) rowURL := baseURL + url.PathEscape(syntheticName)
chain := chainFor(partyAbs) 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 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{ result = append(result, listing.FileInfo{
Name: syntheticName, Name: syntheticName,
URL: rowURL, URL: rowURL,
IsDir: false, IsDir: false,
Virtual: true, Virtual: true,
Writable: writable, Writable: verbs.Has(zddc.VerbW),
Verbs: verbs.String(),
}) })
} }
appendVirtualPartyDir := func(party, partyAbs string) { appendVirtualPartyDir := func(party, partyAbs string) {
dirURL := baseURL + url.PathEscape(party) + "/" dirURL := baseURL + url.PathEscape(party) + "/"
chain := chainFor(partyAbs) 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 return
} }
result = append(result, listing.FileInfo{ result = append(result, listing.FileInfo{
@ -270,6 +263,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
URL: dirURL, URL: dirURL,
IsDir: true, IsDir: true,
Virtual: 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. // files — they're just party listings rendered by browse.
if zddc.IsRowSlot(vv.Slot) { if zddc.IsRowSlot(vv.Slot) {
result = append(result, result = append(result,
listing.FileInfo{Name: "table.yaml", URL: baseURL + "table.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}, 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 { if !hasReal {
result = append(result, listing.FileInfo{ receivedURL := baseURL + "received/"
Name: "received/", // Verbs against the canonical workflow's chain — the
URL: baseURL + "received/", // virtual `received/` resolves to a read-through window
IsDir: true, // onto received/<tracking>/; writes go through serveFilePut
Virtual: true, // 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 // chain, short-circuited when the principal already holds admin
// authority. An elevated admin sees writable=true and the editor lets // authority. An elevated admin sees writable=true and the editor lets
// them save; a non-admin sees writable=false and the editor mounts // 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) { 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") zddcPath := filepath.Join(absDir, ".zddc")
if _, err := os.Stat(zddcPath); err == nil { 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) { } else if !os.IsNotExist(err) {
return listing.FileInfo{}, false return listing.FileInfo{}, false
} }
writable := parentActiveAdmin verbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, baseURL+".zddc")
if !writable {
allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, baseURL+".zddc", policy.ActionAdmin)
writable = allowed
}
return listing.FileInfo{ return listing.FileInfo{
Name: ".zddc", Name: ".zddc",
URL: baseURL + ".zddc", URL: baseURL + ".zddc",
IsDir: false, IsDir: false,
Virtual: true, Virtual: true,
Writable: writable, Writable: verbs.Has(zddc.VerbA) || parentActiveAdmin,
Verbs: verbs.String(),
}, true }, true
} }
@ -388,8 +390,10 @@ func virtualZddcEntry(ctx context.Context, decider policy.Decider, parentChain z
// incoming, received, issued under archive/<party>/; whatever an // incoming, received, issued under archive/<party>/; whatever an
// operator added via on-disk .zddc paths:). Case-insensitive // operator added via on-disk .zddc paths:). Case-insensitive
// presence check suppresses a virtual entry when the on-disk // presence check suppresses a virtual entry when the on-disk
// directory exists in any case. // directory exists in any case. Verbs are computed against each
func virtualCanonicalFolders(fsRoot, absDir, baseURL string, // 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 { real []listing.FileInfo, displayMap map[string]string) []listing.FileInfo {
declared := zddc.ChildrenDeclaredAt(fsRoot, absDir) declared := zddc.ChildrenDeclaredAt(fsRoot, absDir)
@ -411,13 +415,24 @@ func virtualCanonicalFolders(fsRoot, absDir, baseURL string,
if present[strings.ToLower(name)] { if present[strings.ToLower(name)] {
continue 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{ synth = append(synth, listing.FileInfo{
Name: name + "/", Name: name + "/",
URL: baseURL + url.PathEscape(name) + "/", URL: childURL,
IsDir: true, IsDir: true,
Virtual: true, Virtual: true,
DisplayName: lookupDisplay(displayMap, name), DisplayName: lookupDisplay(displayMap, name),
Declared: true, // synthesized entries are by definition cascade-declared Declared: true, // synthesized entries are by definition cascade-declared
Verbs: verbs.String(),
}) })
} }
return synth return synth
@ -439,8 +454,8 @@ func virtualCanonicalFolders(fsRoot, absDir, baseURL string,
// - viewerEmail is non-empty // - viewerEmail is non-empty
// - real does not already contain a directory entry that case-folds // - real does not already contain a directory entry that case-folds
// to viewerEmail (so a materialised home doesn't get duplicated) // to viewerEmail (so a materialised home doesn't get duplicated)
func virtualUserHomeEntry(fsRoot, dirPath, viewerEmail, baseURL string, real []listing.FileInfo) (listing.FileInfo, bool) { func virtualUserHomeEntry(ctx context.Context, decider policy.Decider, fsRoot, dirPath string, principal zddc.Principal, baseURL string, real []listing.FileInfo) (listing.FileInfo, bool) {
if viewerEmail == "" { if principal.Email == "" {
return listing.FileInfo{}, false return listing.FileInfo{}, false
} }
rel := strings.Trim(filepath.ToSlash(dirPath), "/") 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. // fi.Name carries a trailing slash for dirs.
bare := strings.TrimSuffix(fi.Name, "/") bare := strings.TrimSuffix(fi.Name, "/")
if strings.EqualFold(bare, viewerEmail) { if strings.EqualFold(bare, principal.Email) {
return listing.FileInfo{}, false 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{ return listing.FileInfo{
Name: viewerEmail + "/", Name: principal.Email + "/",
URL: baseURL + url.PathEscape(viewerEmail) + "/", URL: homeURL,
IsDir: true, IsDir: true,
Virtual: true, Virtual: true,
Verbs: verbs.String(),
}, true }, true
} }

View file

@ -257,3 +257,101 @@ func TestListDirectory_VirtualFolderNav_FiltersInFlight(t *testing.T) {
t.Errorf("project-level folder-nav listing = %v, want %v", partyDirs, want) 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)
}
}
}

View file

@ -60,5 +60,30 @@ type FileInfo struct {
// false-or-unknown and gate writes accordingly. Read-only-by- // false-or-unknown and gate writes accordingly. Read-only-by-
// default is the safer client-side fallback if the server forgets // default is the safer client-side fallback if the server forgets
// to populate it. // 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"` 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"`
} }

View file

@ -395,6 +395,25 @@ func AllowActionFromChainP(ctx context.Context, d Decider, chain zddc.PolicyChai
return d.Allow(ctx, in) 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. // cachingDecider wraps another Decider with a small per-decision cache.
// Designed for the external-OPA hot path: a single .archive listing or // Designed for the external-OPA hot path: a single .archive listing or
// directory enumeration can hit the same (email, dir-policy) tuple // directory enumeration can hit the same (email, dir-policy) tuple