feat(api): add felis-api service with permissions, modpack lane, and fleet read

The dual-faced felis-api: internal (service) and external (public/app/admin) routes behind a Zero-Trust guard. Includes the access domain (whitelist, ban, and LuckPerms permission/group control over the owner-gated RCON path), the modpack submission endpoints, and the admin-tier SysAdmin fleet read. Structured access fields are charset-validated before assembly so no field can splice a second RCON command.
This commit is contained in:
flyemoji committed 2026-06-26 23:32:38 +09:00
1 parent d39605e05e
commit b508fccc6f
36 files changed
+8645

No files matched your search

+198
View File
@@ -0,0 +1,198 @@
package api
import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
"sigs.k8s.io/yaml"
)
// This is the §28 #7 OpenAPI parity gate. docs/openapi.yaml is checked against
// the routes the handlers are actually built from — the same internalAPIRoutes /
// externalAPIRoutes tables in api.go — in BOTH directions and including each
// route's Zero-Trust tier. So three things cannot silently drift:
//
// - a route served but undocumented (or documented but unserved) fails the build;
// - a route whose documented face set disagrees with where it is mounted fails;
// - a route whose documented tier disagrees with its computed tier fails — this
// is the §14 four-power guard: an admin route quietly demoted to app, or an
// internal service route re-faced as external, breaks `go test ./...`.
//
// http.ServeMux exposes no way to enumerate its patterns, so a doc-vs-mux probe is
// impossible; reading the shared route table is the only exact check (api.go).
// oasKnownMethods is the set of OpenAPI path-item keys that denote operations.
// Any other key (summary, parameters, description, servers, ...) is ignored.
var oasKnownMethods = map[string]bool{
"get": true, "post": true, "put": true, "patch": true,
"delete": true, "head": true, "options": true, "trace": true,
}
// oasDoc is the slice of docs/openapi.yaml this test reads. Body schemas and the
// rest of the document are intentionally not modelled — only the surface the
// handlers must agree with.
type oasDoc struct {
OpenAPI string `json:"openapi"`
Paths map[string]map[string]oasOp `json:"paths"`
}
type oasOp struct {
Faces []string `json:"x-felis-face"`
Tier string `json:"x-felis-tier"`
}
// oasFacet is the classification of one {method, path}: which face(s) serve it
// and at which Zero-Trust tier.
type oasFacet struct {
faces map[string]bool
tier string
}
func TestOpenAPIMatchesServedRoutes(t *testing.T) {
served := oasServedFacets(t)
documented := oasDocumentedFacets(t)
// 1. Exact {method, path} set parity, both directions.
for key := range served {
if _, ok := documented[key]; !ok {
t.Errorf("route SERVED but not documented in docs/openapi.yaml: %s", key)
}
}
for key := range documented {
if _, ok := served[key]; !ok {
t.Errorf("route DOCUMENTED in docs/openapi.yaml but not served: %s", key)
}
}
// 2. For every shared route, face set and tier must agree exactly.
for key, s := range served {
d, ok := documented[key]
if !ok {
continue
}
if !oasSameSet(s.faces, d.faces) {
t.Errorf("%s: x-felis-face mismatch — served %v, documented %v",
key, oasSortedKeys(s.faces), oasSortedKeys(d.faces))
}
if s.tier != d.tier {
t.Errorf("%s: x-felis-tier mismatch — served %q, documented %q", key, s.tier, d.tier)
}
}
}
// oasServedFacets derives the live route surface from the route tables the
// handlers are built from, keyed by "METHOD /path". The tier is computed the same
// way buildFace treats the route: Public -> public; internal-face -> service;
// external-face Admin -> admin; otherwise app. /healthz is added by both tables,
// so its face set merges to {internal, external} and its tier must agree (public).
func oasServedFacets(t *testing.T) map[string]oasFacet {
t.Helper()
a := &API{}
out := map[string]oasFacet{}
add := func(method, pattern, face, tier string) {
key := method + " " + pattern
f, ok := out[key]
if !ok {
f = oasFacet{faces: map[string]bool{}}
}
f.faces[face] = true
if f.tier != "" && f.tier != tier {
t.Fatalf("%s: route table assigns conflicting tiers %q and %q", key, f.tier, tier)
}
f.tier = tier
out[key] = f
}
for _, rt := range a.internalAPIRoutes() {
tier := "service"
if rt.Public {
tier = "public"
}
add(rt.Method, rt.Pattern, "internal", tier)
}
for _, rt := range a.externalAPIRoutes() {
var tier string
switch {
case rt.Public:
tier = "public"
case rt.Admin:
tier = "admin"
default:
tier = "app"
}
add(rt.Method, rt.Pattern, "external", tier)
}
return out
}
// oasDocumentedFacets parses docs/openapi.yaml into the same shape. An operation
// missing x-felis-face or x-felis-tier is a failure, not a skip: a new route
// cannot be documented without classifying its face and tier.
func oasDocumentedFacets(t *testing.T) map[string]oasFacet {
t.Helper()
path := filepath.Join("..", "..", "docs", "openapi.yaml")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var doc oasDoc
if err := yaml.Unmarshal(raw, &doc); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
if !strings.HasPrefix(doc.OpenAPI, "3.1") {
t.Fatalf("%s: expected OpenAPI 3.1, got openapi: %q", path, doc.OpenAPI)
}
out := map[string]oasFacet{}
for rawPath, item := range doc.Paths {
for method, op := range item {
if !oasKnownMethods[strings.ToLower(method)] {
continue
}
key := strings.ToUpper(method) + " " + rawPath
if len(op.Faces) == 0 {
t.Errorf("%s: missing x-felis-face", key)
continue
}
if op.Tier == "" {
t.Errorf("%s: missing x-felis-tier", key)
continue
}
faces := map[string]bool{}
for _, f := range op.Faces {
if f != "internal" && f != "external" {
t.Errorf("%s: x-felis-face has unknown face %q", key, f)
}
faces[f] = true
}
if _, dup := out[key]; dup {
t.Errorf("%s: documented more than once", key)
}
out[key] = oasFacet{faces: faces, tier: op.Tier}
}
}
return out
}
func oasSameSet(a, b map[string]bool) bool {
if len(a) != len(b) {
return false
}
for k := range a {
if !b[k] {
return false
}
}
return true
}
func oasSortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}