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,157 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// Principal is the authenticated external-face caller (spec §7, §14). The
|
||||
// internal face (service token) never produces a Principal — it is a trusted
|
||||
// machine caller, not a person.
|
||||
type Principal struct {
|
||||
// UserID is the stable web identity (SSO subject → users.id).
|
||||
UserID string
|
||||
// Email is the audited actor identity (spec §14: audit actor = Access email).
|
||||
Email string
|
||||
// Role is "admin" or "user" (mirrors users.role).
|
||||
Role string
|
||||
// ViaAdminAccess is true only when the request arrived through the admin.*
|
||||
// Zero-Trust hostname (Cloudflare Access). Admin-tier operations require it
|
||||
// in addition to Role=="admin" (spec §14: ZT is graded by operation).
|
||||
ViaAdminAccess bool
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the principal may perform admin-tier operations.
|
||||
// Both the role claim and the admin Access path are required: a role=admin
|
||||
// session arriving on panel.* must not bypass the Zero-Trust boundary.
|
||||
func (p *Principal) IsAdmin() bool {
|
||||
return p != nil && p.Role == "admin" && p.ViaAdminAccess
|
||||
}
|
||||
|
||||
// InternalAuth authenticates the internal face (velocity / backend callbacks):
|
||||
// a static service token presented as a Bearer credential. The internal face
|
||||
// is never wrapped in Zero Trust (spec §1.8, §14 red line).
|
||||
type InternalAuth interface {
|
||||
Authenticate(r *http.Request) error
|
||||
}
|
||||
|
||||
// ExternalAuth authenticates the external face (people / panel) and returns the
|
||||
// resolved Principal. Production verifies a Cloudflare Access JWT and checks its
|
||||
// audience; the verification key source (JWKS) is injected so the audience and
|
||||
// expiry logic stay unit-testable.
|
||||
type ExternalAuth interface {
|
||||
Authenticate(r *http.Request) (*Principal, error)
|
||||
}
|
||||
|
||||
// BearerTokenAuth is the production InternalAuth: a constant-time comparison
|
||||
// against the configured service token. A zero token fails closed so a
|
||||
// misconfiguration can never silently disable internal-face auth.
|
||||
type BearerTokenAuth struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
// Authenticate checks the Authorization: Bearer header against the token.
|
||||
func (b BearerTokenAuth) Authenticate(r *http.Request) error {
|
||||
if b.Token == "" {
|
||||
return fmt.Errorf("internal auth not configured")
|
||||
}
|
||||
got := bearerToken(r)
|
||||
if got == "" {
|
||||
return fmt.Errorf("missing bearer token")
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(b.Token)) != 1 {
|
||||
return fmt.Errorf("invalid service token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AccessVerifier is the production ExternalAuth: it parses a Cloudflare Access
|
||||
// JWT, verifies the signature with the injected key function, and enforces the
|
||||
// configured audience (spec §7 "验 aud"). AdminAudience, when set, marks a token
|
||||
// minted for the admin.* application so admin-tier routes can require it.
|
||||
type AccessVerifier struct {
|
||||
// Audience is the required `aud` claim for any external request.
|
||||
Audience string
|
||||
// AdminAudience, if non-empty and present in the token's aud set, flags the
|
||||
// principal as having passed the admin Zero-Trust path.
|
||||
AdminAudience string
|
||||
// Keyfunc resolves the signing key (production: a JWKS-backed keyfunc).
|
||||
Keyfunc jwt.Keyfunc
|
||||
}
|
||||
|
||||
// accessClaims are the subset of Access JWT claims we consume.
|
||||
type accessClaims struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"felis_role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Authenticate verifies the Access JWT and maps it onto a Principal.
|
||||
func (v AccessVerifier) Authenticate(r *http.Request) (*Principal, error) {
|
||||
if v.Keyfunc == nil {
|
||||
return nil, fmt.Errorf("external auth not configured")
|
||||
}
|
||||
raw := accessToken(r)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("missing access token")
|
||||
}
|
||||
|
||||
var claims accessClaims
|
||||
parser := jwt.NewParser(jwt.WithExpirationRequired())
|
||||
if _, err := parser.ParseWithClaims(raw, &claims, v.Keyfunc); err != nil {
|
||||
return nil, fmt.Errorf("invalid access token: %w", err)
|
||||
}
|
||||
|
||||
// Audience check: the configured app aud must be present. We do not delegate
|
||||
// to jwt.WithAudience so we can additionally detect the admin audience.
|
||||
if !audienceContains(claims.Audience, v.Audience) {
|
||||
return nil, fmt.Errorf("token audience does not include %q", v.Audience)
|
||||
}
|
||||
if claims.Subject == "" {
|
||||
return nil, fmt.Errorf("token missing subject")
|
||||
}
|
||||
|
||||
role := claims.Role
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
return &Principal{
|
||||
UserID: claims.Subject,
|
||||
Email: claims.Email,
|
||||
Role: role,
|
||||
ViaAdminAccess: v.AdminAudience != "" && audienceContains(claims.Audience, v.AdminAudience),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// bearerToken extracts a Bearer credential from the Authorization header.
|
||||
func bearerToken(r *http.Request) string {
|
||||
const prefix = "Bearer "
|
||||
h := r.Header.Get("Authorization")
|
||||
if len(h) > len(prefix) && strings.EqualFold(h[:len(prefix)], prefix) {
|
||||
return strings.TrimSpace(h[len(prefix):])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// accessToken prefers the Cloudflare Access assertion header, falling back to a
|
||||
// Bearer credential so the same verifier works behind a proxy or directly.
|
||||
func accessToken(r *http.Request) string {
|
||||
if h := r.Header.Get("Cf-Access-Jwt-Assertion"); h != "" {
|
||||
return h
|
||||
}
|
||||
return bearerToken(r)
|
||||
}
|
||||
|
||||
// audienceContains reports whether want appears in the aud claim set.
|
||||
func audienceContains(aud jwt.ClaimStrings, want string) bool {
|
||||
for _, a := range aud {
|
||||
if a == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in new issue
Block a user