feat(classification): data-sensitivity taxonomy + per-wing/repo tags (#50)
Implements the I1-prerequisite from #49/#50: the classification taxonomy and the per-wing / per-repo tagging the capture server reads to derive a target's sensitivity. - Levels public < internal < confidential, ordered so "stricter wins" (spec §4.1 model C) is a plain max via Stricter(). - Tags read from an optional classification.yaml at the brain root (wings:/repos: maps). Absent file → defaults-only, not an error. - Defaulting: client-* → confidential; hyperguild/homelab → internal; everything else → confidential. Fail-safe-to-strictest is the load-bearing property: a missing tag never silently downgrades. - Config.Derive(Target) is the function the use-case calls; Wing/Repo are the per-kind helpers. ParseLevel rejects unknown tokens; a bad level in the config file is a hard load error. Central classification.yaml (not _index.md frontmatter, not gitea repo topics): classifying a repo needs no live Gitea client, so #50 has no dependency on the tracker work (#52); it's auditable in one place; and it avoids BuildWingIndex clobbering a wing's regenerated _index.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
// Package classification defines the data-sensitivity taxonomy and the
|
||||
// per-wing / per-repo tagging the capture server reads to enforce the I1
|
||||
// sovereignty gate (issue #50, capture spec §4.1).
|
||||
//
|
||||
// The single load-bearing property is fail-safe-to-strictest: a target
|
||||
// with no explicit tag and no known default classifies as Confidential,
|
||||
// never as something more permissive. A missing tag must never silently
|
||||
// downgrade — that would turn the I1 gate into theatre.
|
||||
//
|
||||
// Classification is read from an optional classification.yaml at the
|
||||
// brain root. A central, Flux-reconcilable file is deliberate: it is
|
||||
// auditable in one place (I2/I5), it does not require a live Gitea client
|
||||
// to classify a repo (so this package has no dependency on the gitea
|
||||
// tracker work), and it avoids tagging a wing's _index.md frontmatter —
|
||||
// which BuildWingIndex regenerates and would clobber.
|
||||
package classification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Level is a data-sensitivity tier. Higher is stricter, so the "stricter
|
||||
// wins" rule (spec §4.1 model C) is a plain max.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
Public Level = iota
|
||||
Internal
|
||||
Confidential
|
||||
)
|
||||
|
||||
// String returns the canonical lowercase token for a level.
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case Public:
|
||||
return "public"
|
||||
case Internal:
|
||||
return "internal"
|
||||
case Confidential:
|
||||
return "confidential"
|
||||
default:
|
||||
return fmt.Sprintf("level(%d)", int(l))
|
||||
}
|
||||
}
|
||||
|
||||
// ParseLevel parses a level token (case-insensitive, surrounding space
|
||||
// tolerated). An unknown token is an error — callers must decide what to
|
||||
// do with bad input rather than have it silently coerced.
|
||||
func ParseLevel(s string) (Level, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "public":
|
||||
return Public, nil
|
||||
case "internal":
|
||||
return Internal, nil
|
||||
case "confidential":
|
||||
return Confidential, nil
|
||||
default:
|
||||
return Confidential, fmt.Errorf("unknown classification level %q (want public/internal/confidential)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Stricter returns the more restrictive of two levels.
|
||||
func Stricter(a, b Level) Level {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TargetKind distinguishes the two kinds of capture destination.
|
||||
type TargetKind int
|
||||
|
||||
const (
|
||||
WingTarget TargetKind = iota // a brain wing (insights land here)
|
||||
RepoTarget // a Gitea repo (tickets / summaries land here)
|
||||
)
|
||||
|
||||
// Target names a capture destination to classify.
|
||||
type Target struct {
|
||||
Kind TargetKind
|
||||
Name string
|
||||
}
|
||||
|
||||
// Config holds the explicit per-wing / per-repo classification tags read
|
||||
// from classification.yaml. Absent entries fall through to the built-in
|
||||
// defaults in defaultFor. The zero value (no file) is valid and applies
|
||||
// defaults to everything.
|
||||
type Config struct {
|
||||
wings map[string]Level
|
||||
repos map[string]Level
|
||||
}
|
||||
|
||||
// rawConfig is the on-disk YAML shape: string→string maps, parsed into
|
||||
// validated levels by Load.
|
||||
type rawConfig struct {
|
||||
Wings map[string]string `yaml:"wings"`
|
||||
Repos map[string]string `yaml:"repos"`
|
||||
}
|
||||
|
||||
// Load reads classification.yaml from brainDir. An absent file is not an
|
||||
// error — it yields an empty config where every target classifies by the
|
||||
// built-in defaults. A malformed file, or any unparseable level token in
|
||||
// it, is a hard error: a classification source the server cannot trust
|
||||
// must fail loud, not degrade silently.
|
||||
func Load(brainDir string) (*Config, error) {
|
||||
cfg := &Config{wings: map[string]Level{}, repos: map[string]Level{}}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(brainDir, "classification.yaml"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cfg, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read classification.yaml: %w", err)
|
||||
}
|
||||
|
||||
var raw rawConfig
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parse classification.yaml: %w", err)
|
||||
}
|
||||
for name, lvl := range raw.Wings {
|
||||
parsed, perr := ParseLevel(lvl)
|
||||
if perr != nil {
|
||||
return nil, fmt.Errorf("wing %q: %w", name, perr)
|
||||
}
|
||||
cfg.wings[normalise(name)] = parsed
|
||||
}
|
||||
for name, lvl := range raw.Repos {
|
||||
parsed, perr := ParseLevel(lvl)
|
||||
if perr != nil {
|
||||
return nil, fmt.Errorf("repo %q: %w", name, perr)
|
||||
}
|
||||
cfg.repos[normalise(name)] = parsed
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Derive returns the classification for any target — the function the
|
||||
// capture use-case calls per item.
|
||||
func (c *Config) Derive(t Target) Level {
|
||||
if t.Kind == RepoTarget {
|
||||
return c.Repo(t.Name)
|
||||
}
|
||||
return c.Wing(t.Name)
|
||||
}
|
||||
|
||||
// Wing classifies a brain wing: an explicit tag wins, else defaults.
|
||||
func (c *Config) Wing(name string) Level {
|
||||
if lvl, ok := c.wings[normalise(name)]; ok {
|
||||
return lvl
|
||||
}
|
||||
return defaultFor(name)
|
||||
}
|
||||
|
||||
// Repo classifies a Gitea repo: an explicit tag wins, else defaults.
|
||||
func (c *Config) Repo(name string) Level {
|
||||
if lvl, ok := c.repos[normalise(name)]; ok {
|
||||
return lvl
|
||||
}
|
||||
return defaultFor(name)
|
||||
}
|
||||
|
||||
// defaultFor applies the built-in defaulting rules when a target has no
|
||||
// explicit tag:
|
||||
// - client-* → Confidential (client work is confidential by default)
|
||||
// - hyperguild / homelab → Internal (the operator's own infra)
|
||||
// - everything else → Confidential (fail safe to strictest)
|
||||
func defaultFor(name string) Level {
|
||||
n := normalise(name)
|
||||
if strings.HasPrefix(n, "client-") {
|
||||
return Confidential
|
||||
}
|
||||
switch n {
|
||||
case "hyperguild", "homelab":
|
||||
return Internal
|
||||
default:
|
||||
return Confidential
|
||||
}
|
||||
}
|
||||
|
||||
// normalise lowercases and trims a wing/repo name so matching and the
|
||||
// client-* prefix check are case-insensitive.
|
||||
func normalise(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
Reference in New Issue
Block a user