Replaces the binary acl.allow/deny model with five permission verbs
(r/w/c/d/a) and first-class roles, and adds an authenticated file API
(PUT/DELETE/POST move/mkdir) so the HTML tools can edit-in-place over
HTTP. Closes the AC-3(7) and AC-6 federal-readiness gaps.
File API (zddc/internal/handler/fileapi.go)
- PUT <new> → action c
- PUT <existing> → action w
- PUT <.zddc> → action a (CanEditZddc strict-ancestor rule)
- DELETE → action d
- POST mkdir → action c (auto-writes creator-owned .zddc when the
parent is Incoming/Working/Staging)
- POST move → action w on src + c on dst, atomic via os.Rename
- Optional If-Match for optimistic concurrency, --max-write-bytes cap,
audit log emits a structured file_write event per operation.
Permission model (zddc/internal/zddc/{acl,file,roles,cascade_mode}.go)
- acl.permissions: { principal → verb-set } map; principals are email
patterns or role names. Empty verb set is an explicit deny.
- roles: { name → members } definitions, available at the level they
declare and all descendants. Closer-to-leaf shadows ancestor.
- Legacy acl.allow/deny still work; they fold into permissions at
parse time (allow → "rwcd", deny → "").
- Cascade walks leaf→root; first level with any matching entry wins;
the union of matching verb sets at that level decides.
- --cascade-mode=strict adds a root→leaf ancestor-deny pre-pass so an
ancestor explicit-deny is absolute (NIST AC-6). Default delegated
preserves the existing commercial behavior.
Special folders (zddc/internal/zddc/special.go)
- Incoming / Working / Staging: mkdir auto-writes a .zddc into the new
subdir granting created_by + that email rwcda directly. Same form
operators write by hand; creator can edit it later to add others.
- Issued / Received: server-enforced WORM split. Cascade grants
inherited from above the WORM folder are masked to r only; grants
placed at-or-below the WORM folder retain r,c. Operators grant
write-once (cr) to the doc controller via an explicit .zddc at the
Issued/Received folder. Admins exempt — only escape hatch.
Browser polyfill (shared/zddc-source.js)
- HttpDirectoryHandle + HttpFileHandle implement the FS Access API
surface (values, getFileHandle, createWritable, removeEntry,
queryPermission/requestPermission) over zddc-server's listing JSON
and file API. Existing tools written against showDirectoryPicker
work unchanged.
- detectServerRoot() returns { handle, status }: tools auto-load on
HTTP, surface a clear "no permission to list" message on 403, and
fall back to the welcome screen on 0.
- classifier renames take the atomic POST move path on HTTP-backed
handles; mdedit and transmittal route reads/writes through the
polyfill so prior FS-API code paths cover both modes.
Tests
- zddc/internal/zddc/{cascade_mode,roles,special,acl}_test.go cover
delegated vs strict, role membership / shadowing / legacy fallback,
WORM split semantics, verb-set parser round-trip.
- zddc/internal/handler/fileapi_test.go now also covers role-based
vendor scenarios, WORM blocking vendor & doc controller writes,
explicit Issued .zddc unlocking the cr drop-box, admin bypass,
auto-ownership on mkdir, and strict-mode lockouts.
Docs
- ARCHITECTURE.md + zddc/README.md document the verb model, role
syntax, special-folder behaviors, cascade-mode flag, and full file
API surface. Federal-readiness gap analysis strikes AC-3(7) and
AC-6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
172 lines
6 KiB
Go
172 lines
6 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
|
"log/slog"
|
|
)
|
|
|
|
type contextKey string
|
|
|
|
// EmailKey is the context key for the authenticated user's email.
|
|
const EmailKey contextKey = "email"
|
|
|
|
// DeciderKey is the context key for the request's policy decider.
|
|
// Set by ACLMiddleware so handlers deep in the stack can issue policy
|
|
// queries without taking the decider as an explicit parameter. Although
|
|
// the decider is an app-wide singleton (not per-request state), routing
|
|
// it through context keeps the call-site signatures stable across the
|
|
// "swap internal evaluator for external OPA" plumbing change.
|
|
const DeciderKey contextKey = "policy-decider"
|
|
|
|
// ACLMiddleware extracts the user email from the configured header and stores
|
|
// it (along with the policy decider) in the request context. It does NOT
|
|
// enforce ACL itself — each handler performs its own ACL check via
|
|
// policy.AllowFromChain.
|
|
func ACLMiddleware(cfg config.Config, decider policy.Decider, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
email := r.Header.Get(cfg.EmailHeader)
|
|
// DEBUG-level header dump for diagnosing proxy / SSO header
|
|
// passthrough. Off by default (LogLevel info); enable with
|
|
// ZDDC_LOG_LEVEL=debug. Logs the configured header name, the
|
|
// observed value at that name, and the full request header
|
|
// map so an operator can see exactly what reached the binary.
|
|
// Note: at debug level this also captures auth tokens, cookies,
|
|
// and anything else upstream proxies forward — only enable in
|
|
// trusted environments.
|
|
slog.Debug("request headers",
|
|
"configured", cfg.EmailHeader,
|
|
"observed", email,
|
|
"headers", r.Header)
|
|
ctx := context.WithValue(r.Context(), EmailKey, email)
|
|
if decider != nil {
|
|
ctx = context.WithValue(ctx, DeciderKey, decider)
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// EmailFromContext extracts the user email from the request context.
|
|
func EmailFromContext(r *http.Request) string {
|
|
if v, ok := r.Context().Value(EmailKey).(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// WithEmail returns a context carrying email under EmailKey. Test seam
|
|
// for handlers that look up the authenticated user via EmailFromContext;
|
|
// production traffic gets the same value injected by ACLMiddleware.
|
|
func WithEmail(ctx context.Context, email string) context.Context {
|
|
return context.WithValue(ctx, EmailKey, email)
|
|
}
|
|
|
|
// DeciderFromContext extracts the policy decider from the request
|
|
// context. Returns the internal decider as a fallback if none was
|
|
// installed — this matches the "no OPA configured" semantics and
|
|
// keeps test setups that don't install ACLMiddleware functional.
|
|
func DeciderFromContext(r *http.Request) policy.Decider {
|
|
if v, ok := r.Context().Value(DeciderKey).(policy.Decider); ok {
|
|
return v
|
|
}
|
|
return &policy.InternalDecider{}
|
|
}
|
|
|
|
// responseWriter wraps http.ResponseWriter to capture status code and bytes written.
|
|
type responseWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
bytes int
|
|
wrote bool
|
|
}
|
|
|
|
// WriteHeader records the status code and writes it to the underlying ResponseWriter.
|
|
func (rw *responseWriter) WriteHeader(code int) {
|
|
rw.status = code
|
|
rw.wrote = true
|
|
rw.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
// Write records the bytes written and writes to the underlying ResponseWriter.
|
|
func (rw *responseWriter) Write(b []byte) (int, error) {
|
|
n, err := rw.ResponseWriter.Write(b)
|
|
rw.bytes += n
|
|
return n, err
|
|
}
|
|
|
|
// HSTSMiddleware sets the Strict-Transport-Security response header,
|
|
// instructing browsers to refuse plain-HTTP connections to this host
|
|
// for the next year (NIST SP 800-52 Rev. 2 § 4.4.6, also DoD STIG
|
|
// expectation; OWASP recommendation max-age >= 1 year). Use ONLY when
|
|
// zddc-server is itself terminating TLS — when an upstream proxy
|
|
// terminates, that proxy should set HSTS instead.
|
|
//
|
|
// includeSubDomains is set; preload is not (preload requires
|
|
// pre-submitting the domain to the browser-vendor list — out of
|
|
// scope for this server, and operators who want it can override
|
|
// upstream).
|
|
//
|
|
// max-age = 31536000 = 365 days.
|
|
func HSTSMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// AccessLogMiddleware logs a structured line per HTTP request after the
|
|
// response is written.
|
|
//
|
|
// Always emits to slog.Default() (stderr) so server-lifecycle logs and
|
|
// access logs share an output stream by default.
|
|
//
|
|
// If `auditLogger` is non-nil, the same structured fields are also written
|
|
// to it. The intended caller wires up auditLogger with a JSON handler
|
|
// pointing at a rotating file (see cmd/zddc-server's setupAccessAuditLog),
|
|
// so an operator gets a persisted audit trail on disk in addition to the
|
|
// stderr stream — useful when stderr is not journald-captured (e.g.
|
|
// container logging where the orchestrator drops stderr after restarts).
|
|
func AccessLogMiddleware(auditLogger *slog.Logger, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Capture request start time
|
|
start := time.Now()
|
|
|
|
// Wrap the ResponseWriter
|
|
wrapped := &responseWriter{ResponseWriter: w, status: 200}
|
|
|
|
// Serve the request
|
|
next.ServeHTTP(wrapped, r)
|
|
|
|
// Calculate duration
|
|
durationMs := int(time.Since(start).Milliseconds())
|
|
|
|
// Get email from context
|
|
email := EmailFromContext(r)
|
|
if email == "" {
|
|
email = "anonymous"
|
|
}
|
|
|
|
args := []any{
|
|
"ts", start.Format(time.RFC3339),
|
|
"email", email,
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"status", wrapped.status,
|
|
"bytes", wrapped.bytes,
|
|
"duration_ms", durationMs,
|
|
}
|
|
|
|
// Stderr stream (existing behavior).
|
|
slog.Info("access", args...)
|
|
|
|
// Audit file (when configured). Same fields, separate handler so
|
|
// the file can be JSON-formatted regardless of stderr's handler.
|
|
if auditLogger != nil {
|
|
auditLogger.Info("access", args...)
|
|
}
|
|
})
|
|
}
|