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:
36 files changed
+8645
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user