Files
Yggdrasil/internal/acl/acl.go
T
Klagarge fc1b45058c feat: shiroa book, ACL parser, and OIDC-gated Go server
- docs/: shiroa book with three example categories (thermo, security,
  homelab); pages declare access via '#let access-groups = (...)'
- cmd/parser: scans docs/ and generates acl.json (public by default,
  skips book.typ/template.typ/partials)
- internal/acl: loads acl.json, normalizes URLs to page keys so a page's
  renderer data (*.multi.sir.in) inherits its protection; fail closed on
  malformed input
- internal/oidc: authorization-code flow client, verified ID tokens,
  groups claim from Authentik
- internal/middleware: in-memory sessions, login/logout/callback handlers,
  CSRF states, access gate + security headers
- cmd/server + embed.go: single binary embedding dist/ and acl.json;
  /searchindex.json blocked; graceful shutdown
- Justfile (build/run/parse/check/clean/preview-docs), Gitea Actions
  workflow (shiroa v0.4.0-rc2), rootless Dockerfile, README
- legacy docs moved to legacy_docs/; .idea untracked
2026-10-10 18:42:40 +02:00

123 lines
3.3 KiB
Go

// Package acl loads the access control list generated by cmd/parser and
// answers authorization questions for the HTTP middleware.
//
// acl.json maps the URL of each protected page ("/thermo/fluids.html") to
// the groups allowed to read it. URLs absent from the list are public.
//
// Keys are normalized to a "page key": the URL path reduced to the page it
// belongs to, e.g. "/thermo/fluids.html" and "/thermo/fluids.html" both
// become "/thermo/fluids". The shiroa renderer fetches the page content
// from companion files such as "/thermo/fluids.light.multi.sir.in", which
// reduce to the same key, so a protected page's content cannot be fetched
// directly while the page itself is protected.
package acl
import (
"encoding/json"
"fmt"
"path"
"strings"
)
// ACL maps page keys ("/thermo/fluids") to the groups allowed to read them.
type ACL map[string][]string
// Load parses acl.json and normalizes its keys. A malformed file is an error:
// the caller must refuse to start rather than serve protected pages as
// public (fail closed).
func Load(data []byte) (ACL, error) {
var raw map[string][]string
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parsing acl.json: %w", err)
}
acl := make(ACL, len(raw))
for urlPath, groups := range raw {
if len(groups) == 0 {
continue
}
key, err := PageKey(urlPath)
if err != nil {
return nil, fmt.Errorf("acl entry %q: %w", urlPath, err)
}
// Merge entries that reduce to the same page (e.g. a page and
// its directory index): require the union of their groups.
acl[key] = appendUnique(acl[key], groups...)
}
return acl, nil
}
// PageKey reduces a URL path to the page it belongs to.
//
// "/thermo/fluids.html" -> "/thermo/fluids"
// "/thermo/fluids.light.multi.sir.in" -> "/thermo/fluids"
// "/" -> "/index"
// "/thermo/" -> "/thermo/index"
func PageKey(urlPath string) (string, error) {
if !strings.HasPrefix(urlPath, "/") {
return "", fmt.Errorf("not an absolute path")
}
// A request for a directory serves that directory's index page.
if urlPath != "/" && strings.HasSuffix(urlPath, "/") {
urlPath += "index.html"
}
p := path.Clean(urlPath)
if p == "/" {
return "/index", nil
}
dir, base := path.Split(p)
if i := strings.Index(base, "."); i >= 0 {
base = base[:i]
}
if base == "" {
base = "index"
}
return path.Join(dir, base), nil
}
// GroupsFor returns the union of the groups required by any of the given
// page keys, and whether at least one of them is protected.
func (a ACL) GroupsFor(keys ...string) (required []string, protected bool) {
seen := make(map[string]struct{})
for _, k := range keys {
for _, g := range a[k] {
if _, dup := seen[g]; !dup {
seen[g] = struct{}{}
required = append(required, g)
}
protected = true
}
}
return required, protected
}
// Allows reports whether the user's groups include at least one of the
// required groups.
func Allows(userGroups, required []string) bool {
for _, u := range userGroups {
for _, r := range required {
if u == r {
return true
}
}
}
return false
}
func appendUnique(dst []string, groups ...string) []string {
for _, g := range groups {
if !contains(dst, g) {
dst = append(dst, g)
}
}
return dst
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}