Single audit pass that removes pre-release back-compat, consolidates the
admin-policy decider, and fixes the .zddc write path.
Field removal — acl.allow / acl.deny:
- Drop ACLRules.Allow / Deny struct fields and mergeLegacyACL().
- Remove walker / lookups / validate / decider branches that read them.
- Migrate every test fixture (YAML strings and ACLRules struct literals)
to acl.permissions: { principal → verb-set }.
- Rewrite both bundled Rego policies (access.rego, access_federal.rego)
to traverse level.acl.permissions; rewrite parity-test helpers.
- Update create-project form (profile page) to collect permissions
instead of allow/deny lists.
Admin decider consolidation:
- Delete zddc.CanEditZddc — strict-ancestor rule retired. Subtree admins
own their own .zddc; the policy decider's IsActiveAdmin short-circuit
is the single bypass site.
- Migrate tablehandler.ServeTable to AllowActionFromChainP — closes the
same Forbidden bug already fixed for /browse.html.
- Drop AccessView.EditableParentChoices and treeEntry.CanEdit (always
true after the retirement). Profile page renders AdminSubtrees
directly for both lists.
- Drop the excludeLeaf parameter from AdminLevelInChain /
IsAdminForChain — no production caller passed true.
Dead code removed:
- policy.AllowWriteFromChain (zero production callers, zero tests).
- zddc.AllowedWithChain (zero production callers; tests deleted).
ModeStrict retirement — federal posture is OPA-only:
- Delete cascade_mode.go / cascade_mode_test.go and the ModeStrict
branches in cascade.go and acl.go.
- Drop --cascade-mode flag, CascadeMode config field, and the
InternalDecider.Mode field.
- Drop the mode parameter from every cascade helper:
GrantedVerbsAtLevel, AllowedAction, EffectiveVerbs,
EffectiveVerbsRange, RoleMembers, MatchesPrincipal,
MatchingPrincipals, WormZoneGrant, PolicyChain.VisibleStart.
- Strip cascade_mode from /.profile/config and
/.profile/effective-policy responses.
- Refresh README / ARCHITECTURE.md to describe federal posture as
"deploy OPA with access_federal.rego" (NIST AC-6); the bundled Rego
is the parent-deny-is-absolute variant. The in-process Go evaluator
implements only the commercial cascade.
Legacy redirects + .admin.css fallback:
- Drop /<dir>/.zddc.html → ?file=.zddc redirect and its test.
- Drop ?zip=1 retired comment + legacy test (handled by the
.zip virtual-URL path; covered by TestServeSubtreeZip).
- Drop .admin.css fallback in profile_assets.go — only .profile.css now.
- Refresh stale "retired" / "back-compat" / "legacy" comment markers.
.zddc write path fix:
- Dispatcher: route only GET/HEAD on .zddc URLs to ServeZddcFile; carve
.zddc out of the dot-prefix guard so PUT/DELETE/POST reach
ServeFileAPI. Before this, .zddc writes 405'd at ServeZddcFile and
the YAML editor's save flow had no live path.
- ServeFileAPI.resolveTargetPath: same .zddc-leaf carve-out so the file
API accepts the path; intermediate dot dirs (.zddc.d/) stay reserved.
- Listing: compute Writable per-file with ActionAdmin for .zddc
(matches the file API's gate) instead of ActionWrite for everything.
- Virtual .zddc placeholder: compute Writable via the same
parentActiveAdmin || ActionAdmin path. Was always false before.
- browse YAML editor canSave: exempt virtual .zddc — the synthetic
body is designed to materialize on PUT.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
280 lines
9 KiB
Go
280 lines
9 KiB
Go
package zddc
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// AppNames is the canonical set of app HTML files the server resolves
|
|
// via the apps fetch+cache subsystem. Order is stable for reproducible
|
|
// admin-UI rendering.
|
|
//
|
|
// All seven HTML tools belong here — including browse, form, and tables.
|
|
// Omitting any of them means the apps cascade (.zddc apps:) silently
|
|
// short-circuits to embedded for that name, defeating live-dev
|
|
// path-source overrides.
|
|
//
|
|
// Markdown editing used to be a dedicated tool ("mdedit"); it now
|
|
// lives as a plugin inside browse (browse/js/preview-markdown.js).
|
|
var AppNames = []string{"archive", "transmittal", "classifier", "landing", "browse", "form", "tables"}
|
|
|
|
// AppsDefaultKey is the special apps-map key that provides the baseline
|
|
// URL prefix and channel for any app not overridden per-name. Cascades
|
|
// through .zddc files like a per-app entry.
|
|
const AppsDefaultKey = "default"
|
|
|
|
// IsKnownApp reports whether name is one of the canonical apps.
|
|
func IsKnownApp(name string) bool {
|
|
for _, n := range AppNames {
|
|
if n == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsValidAppsKey reports whether name is acceptable as a key in the
|
|
// `apps:` map — either a canonical app or the special "default" key.
|
|
func IsValidAppsKey(name string) bool {
|
|
return name == AppsDefaultKey || IsKnownApp(name)
|
|
}
|
|
|
|
// ValidatePattern returns an error if pattern is not a syntactically
|
|
// well-formed email-glob. The matcher in MatchesPattern is forgiving and
|
|
// will silently fail to match malformed patterns (e.g., "alice@@x" or
|
|
// patterns with spaces); we want explicit rejection at write time so a
|
|
// human typo shows up as a 400 instead of "your rule mysteriously does
|
|
// nothing".
|
|
//
|
|
// Rules:
|
|
// - non-empty after trimming surrounding whitespace
|
|
// - no internal whitespace
|
|
// - at most one '@'
|
|
// - if an '@' is present, both local and domain parts are non-empty
|
|
// - the bare token "@" is rejected (covered by the non-empty parts rule)
|
|
func ValidatePattern(pattern string) error {
|
|
if pattern == "" {
|
|
return fmt.Errorf("pattern is empty")
|
|
}
|
|
if strings.TrimSpace(pattern) != pattern {
|
|
return fmt.Errorf("pattern has leading or trailing whitespace")
|
|
}
|
|
if strings.ContainsAny(pattern, " \t\n\r") {
|
|
return fmt.Errorf("pattern contains whitespace")
|
|
}
|
|
at := strings.Count(pattern, "@")
|
|
if at > 1 {
|
|
return fmt.Errorf("pattern contains more than one '@'")
|
|
}
|
|
if at == 1 {
|
|
parts := strings.SplitN(pattern, "@", 2)
|
|
if parts[0] == "" {
|
|
return fmt.Errorf("pattern has empty local part before '@'")
|
|
}
|
|
if parts[1] == "" {
|
|
return fmt.Errorf("pattern has empty domain part after '@'")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateFile returns a slice of (field, message) errors for any
|
|
// malformed entry in zf. An empty slice means the file is acceptable to
|
|
// write. The returned errors include enough field context that the editor
|
|
// can render them next to the offending row.
|
|
type FieldError struct {
|
|
Field string `json:"field"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// ValidateProjectName returns an error if name is not acceptable as a new
|
|
// directory name created under cfg.Root. The rules mirror the reserved-prefix
|
|
// policy enforced elsewhere (resolvePath, ScanZddcFiles, ServeProjectList) so
|
|
// a project created here is enumerable by the same listing code.
|
|
//
|
|
// Rules:
|
|
// - length 1..64
|
|
// - first char alphanumeric (rejects leading '.' and '_', matching the
|
|
// hidden-segment convention)
|
|
// - subsequent chars alphanumeric, '-', or '_'
|
|
// - rejects path separators, whitespace, and any '.' anywhere (so "..",
|
|
// ".hidden", "foo.bar" all fail — directory names stay flat)
|
|
func ValidateProjectName(name string) error {
|
|
if name == "" {
|
|
return fmt.Errorf("name is empty")
|
|
}
|
|
if len(name) > 64 {
|
|
return fmt.Errorf("name exceeds 64 characters")
|
|
}
|
|
for i, r := range name {
|
|
switch {
|
|
case r >= 'A' && r <= 'Z':
|
|
case r >= 'a' && r <= 'z':
|
|
case r >= '0' && r <= '9':
|
|
case (r == '-' || r == '_') && i > 0:
|
|
// allowed in the body, not as the leading character
|
|
default:
|
|
return fmt.Errorf("name contains invalid character %q at position %d", r, i)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateAppSourceSpec returns nil if spec is a syntactically well-formed
|
|
// source spec accepted by apps.ParseSpec. It checks the string shape only —
|
|
// it does not verify URLs are reachable or paths exist.
|
|
//
|
|
// Accepted forms:
|
|
// - "stable" / "beta" / "alpha" / ":stable" / ":beta" / ":alpha" (channel)
|
|
// - "v0.0.4" / "0.0.4" / "v0.0" / "0.0" / "v0" / "0" / ":v0.0.4" (version)
|
|
// - "https://host/path" (URL prefix)
|
|
// - "https://host/path:stable" (URL prefix + channel)
|
|
// - "https://host/path/file.html" (terminal full URL)
|
|
// - "/abs/path.html" / "./rel/path.html" / "../sibling.html" (path)
|
|
func ValidateAppSourceSpec(spec string) error {
|
|
if spec == "" {
|
|
return fmt.Errorf("source spec is empty")
|
|
}
|
|
if strings.ContainsAny(spec, " \t\n\r") {
|
|
return fmt.Errorf("source spec contains whitespace")
|
|
}
|
|
|
|
// Path forms.
|
|
if strings.HasPrefix(spec, "/") ||
|
|
strings.HasPrefix(spec, "./") ||
|
|
strings.HasPrefix(spec, "../") {
|
|
return nil
|
|
}
|
|
|
|
// URL forms.
|
|
if strings.HasPrefix(spec, "https://") || strings.HasPrefix(spec, "http://") {
|
|
return validateURLSpec(spec)
|
|
}
|
|
|
|
// Channel-or-version (with optional leading colon).
|
|
chanPart := strings.TrimPrefix(spec, ":")
|
|
if chanPart == "" {
|
|
return fmt.Errorf("empty channel after ':'")
|
|
}
|
|
return validateChannelOrVersion(chanPart)
|
|
}
|
|
|
|
// validateURLSpec checks the URL-prefix or full-URL form. Splits on the
|
|
// last `:` after the last `/` (matching apps.parseURLSpec behavior).
|
|
func validateURLSpec(spec string) error {
|
|
// Minimal sanity check on URL shape.
|
|
if len(spec) <= len("https://") {
|
|
return fmt.Errorf("URL is missing host")
|
|
}
|
|
lastSlash := strings.LastIndex(spec, "/")
|
|
if lastSlash < 0 {
|
|
return fmt.Errorf("invalid URL %q: missing path separator", spec)
|
|
}
|
|
afterSlash := spec[lastSlash+1:]
|
|
colonInTail := strings.LastIndex(afterSlash, ":")
|
|
urlPart, suffixPart := spec, ""
|
|
if colonInTail >= 0 {
|
|
urlPart = spec[:lastSlash+1+colonInTail]
|
|
suffixPart = afterSlash[colonInTail+1:]
|
|
}
|
|
if strings.HasSuffix(urlPart, ".html") {
|
|
if suffixPart != "" {
|
|
return fmt.Errorf("URL ends in .html but has %q suffix", ":"+suffixPart)
|
|
}
|
|
return nil // terminal full URL
|
|
}
|
|
if suffixPart != "" {
|
|
return validateChannelOrVersion(suffixPart)
|
|
}
|
|
return nil // URL-prefix only
|
|
}
|
|
|
|
// validateChannelOrVersion enforces the channel/version shape.
|
|
func validateChannelOrVersion(s string) error {
|
|
if s == "stable" || s == "beta" || s == "alpha" {
|
|
return nil
|
|
}
|
|
rest := strings.TrimPrefix(s, "v")
|
|
if rest == "" {
|
|
return fmt.Errorf("unrecognized source spec %q", s)
|
|
}
|
|
parts := strings.Split(rest, ".")
|
|
if len(parts) > 3 {
|
|
return fmt.Errorf("version has too many dots: %q", s)
|
|
}
|
|
for _, p := range parts {
|
|
if p == "" {
|
|
return fmt.Errorf("version has empty component: %q", s)
|
|
}
|
|
for _, r := range p {
|
|
if r < '0' || r > '9' {
|
|
return fmt.Errorf("unrecognized source spec %q", s)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ValidateFile(zf ZddcFile) []FieldError {
|
|
var errs []FieldError
|
|
check := func(field string, vals []string) {
|
|
for i, v := range vals {
|
|
if err := ValidatePattern(v); err != nil {
|
|
errs = append(errs, FieldError{
|
|
Field: fmt.Sprintf("%s[%d]", field, i),
|
|
Message: err.Error(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
for principal := range zf.ACL.Permissions {
|
|
if err := ValidatePattern(principal); err != nil {
|
|
errs = append(errs, FieldError{
|
|
Field: fmt.Sprintf("acl.permissions[%q]", principal),
|
|
Message: err.Error(),
|
|
})
|
|
}
|
|
}
|
|
check("admins", zf.Admins)
|
|
if len(zf.Title) > 200 {
|
|
errs = append(errs, FieldError{
|
|
Field: "title",
|
|
Message: "title exceeds 200 characters",
|
|
})
|
|
}
|
|
for app, spec := range zf.Apps {
|
|
if !IsValidAppsKey(app) {
|
|
errs = append(errs, FieldError{
|
|
Field: fmt.Sprintf("apps.%s", app),
|
|
Message: fmt.Sprintf("unknown app %q (known: default, archive, transmittal, classifier, landing, browse, form, tables)", app),
|
|
})
|
|
continue
|
|
}
|
|
if err := ValidateAppSourceSpec(spec); err != nil {
|
|
errs = append(errs, FieldError{
|
|
Field: fmt.Sprintf("apps.%s", app),
|
|
Message: err.Error(),
|
|
})
|
|
}
|
|
}
|
|
// worm: is a list of principal patterns (email-globs, @role:name,
|
|
// or bare role names) that get write-once-create inside the WORM
|
|
// zone. Validate each as an email-glob unless it's a role
|
|
// reference (role names are validated by the role machinery).
|
|
for i, principal := range zf.Worm {
|
|
if strings.HasPrefix(principal, "@role:") {
|
|
continue // role refs validated elsewhere
|
|
}
|
|
// A bare name with no "@" could be a role name; ValidatePattern
|
|
// accepts it (no @, no whitespace), and MatchesPrincipal
|
|
// resolves it as a role if one is defined. So this only
|
|
// rejects genuinely malformed entries (whitespace, double @).
|
|
if err := ValidatePattern(principal); err != nil {
|
|
errs = append(errs, FieldError{
|
|
Field: fmt.Sprintf("worm[%d]", i),
|
|
Message: err.Error(),
|
|
})
|
|
}
|
|
}
|
|
return errs
|
|
}
|