ZITADEL does not put roles into a machine-user access token on this instance. The grant exists, the scope asks for urn:zitadel:iam:org:project:roles, and the claim still never arrives; a colon-free role key makes no difference either. A service account therefore cannot satisfy requiredCapability by any configuration. auth.allowListedMachineIdentities admits such a caller when its client_id appears in zitadelClientIds. The caller is still authenticated: it signed an assertion with a private key whose public half ZITADEL holds. The allow-list entry is a reviewed line of configuration, not a wildcard. The new path is off by default, and a token that does state its roles is judged by the role check as before, so the weaker rule never applies twice.
274 lines
9.4 KiB
Go
274 lines
9.4 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"time"
|
|
|
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// rawConfig represents the structure of the YAML configuration file.
|
|
type rawConfig struct {
|
|
Routes []rawRoute `yaml:"routes"`
|
|
Models []rawModel `yaml:"models"`
|
|
Adapters []rawAdapter `yaml:"adapters"`
|
|
Auth rawAuth `yaml:"auth"`
|
|
}
|
|
|
|
// rawAuth represents auth configuration in YAML.
|
|
type rawAuth struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
Issuer string `yaml:"issuer"`
|
|
Audience string `yaml:"audience"`
|
|
JWKSURL string `yaml:"jwksUrl"`
|
|
RequiredCapability string `yaml:"requiredCapability"`
|
|
AllowListedMachineIdentities bool `yaml:"allowListedMachineIdentities"`
|
|
TokenURL string `yaml:"tokenUrl"`
|
|
ClientID string `yaml:"clientId"`
|
|
ZitadelDomain string `yaml:"zitadelDomain"`
|
|
ZitadelInsecurePort string `yaml:"zitadelInsecurePort"`
|
|
ZitadelClientIDs []string `yaml:"zitadelClientIds"`
|
|
}
|
|
|
|
// rawRoute represents a single route in the YAML configuration.
|
|
type rawRoute struct {
|
|
Name string `yaml:"name"`
|
|
Upstream rawUpstream `yaml:"upstream"`
|
|
}
|
|
|
|
// rawModel represents a single model entry in the YAML configuration.
|
|
type rawModel struct {
|
|
Name string `yaml:"name"`
|
|
Address string `yaml:"address"`
|
|
Addresses []string `yaml:"addresses"`
|
|
Path string `yaml:"path"`
|
|
UpstreamModel string `yaml:"upstreamModel"`
|
|
AuthRequired *bool `yaml:"authRequired"`
|
|
}
|
|
|
|
// rawAdapter represents a service adapter in the YAML configuration.
|
|
type rawAdapter struct {
|
|
ServiceName string `yaml:"serviceName"`
|
|
Upstream struct {
|
|
URL string `yaml:"url"`
|
|
TimeoutSeconds int32 `yaml:"timeoutSeconds"`
|
|
} `yaml:"upstream"`
|
|
Auth struct {
|
|
Required bool `yaml:"required"`
|
|
Capability string `yaml:"capability"`
|
|
} `yaml:"auth"`
|
|
Retryable bool `yaml:"retryable"`
|
|
Resources []struct {
|
|
Name string `yaml:"name"`
|
|
Methods []struct {
|
|
Verb string `yaml:"verb"`
|
|
UpstreamPath string `yaml:"upstreamPath"`
|
|
RequestSchema string `yaml:"requestSchema"`
|
|
ResponseSchema string `yaml:"responseSchema"`
|
|
} `yaml:"methods"`
|
|
} `yaml:"resources"`
|
|
}
|
|
|
|
// rawUpstream represents upstream configuration in YAML.
|
|
type rawUpstream struct {
|
|
Address string `yaml:"address"`
|
|
PathRewrite string `yaml:"pathRewrite"`
|
|
ConnectTimeout string `yaml:"connectTimeout"`
|
|
ReadTimeout string `yaml:"readTimeout"`
|
|
WriteTimeout string `yaml:"writeTimeout"`
|
|
MaxBodySize int64 `yaml:"maxBodySize"`
|
|
AuthRequired *bool `yaml:"authRequired"`
|
|
}
|
|
|
|
// LoadRoutesAndModelsFromFile loads route, model, adapter, and auth configuration from a YAML file.
|
|
func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*ModelUpstream, []*serviceadapter.ServiceAdapter, AuthConfig, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, nil, nil, AuthConfig{}, fmt.Errorf("failed to read config file %q: %w", path, err)
|
|
}
|
|
|
|
var raw rawConfig
|
|
if err := yaml.Unmarshal(data, &raw); err != nil {
|
|
return nil, nil, nil, AuthConfig{}, fmt.Errorf("failed to parse config file %q: %w", path, err)
|
|
}
|
|
|
|
// Load routes
|
|
routes := make(map[string]*Route)
|
|
for _, rawRoute := range raw.Routes {
|
|
if rawRoute.Name == "" {
|
|
return nil, nil, nil, AuthConfig{}, fmt.Errorf("route has empty name")
|
|
}
|
|
|
|
if _, exists := routes[rawRoute.Name]; exists {
|
|
return nil, nil, nil, AuthConfig{}, fmt.Errorf("duplicate route: \"%s\"", rawRoute.Name)
|
|
}
|
|
|
|
upstream, err := parseUpstream(rawRoute.Name, rawRoute.Upstream)
|
|
if err != nil {
|
|
return nil, nil, nil, AuthConfig{}, err
|
|
}
|
|
|
|
routes[rawRoute.Name] = &Route{
|
|
Name: rawRoute.Name,
|
|
Upstream: upstream,
|
|
}
|
|
}
|
|
|
|
// Load models
|
|
models := make(map[string]*ModelUpstream)
|
|
for _, rawModel := range raw.Models {
|
|
if rawModel.Name == "" {
|
|
return nil, nil, nil, AuthConfig{}, fmt.Errorf("model has empty name")
|
|
}
|
|
if _, exists := models[rawModel.Name]; exists {
|
|
return nil, nil, nil, AuthConfig{}, fmt.Errorf("duplicate model: \"%s\"", rawModel.Name)
|
|
}
|
|
// Support both 'address' (single) and 'addresses' (multi-backend) YAML fields.
|
|
addresses := rawModel.Addresses
|
|
if len(addresses) == 0 && rawModel.Address != "" {
|
|
addresses = []string{rawModel.Address}
|
|
}
|
|
if len(addresses) == 0 {
|
|
return nil, nil, nil, AuthConfig{}, fmt.Errorf("model \"%s\": 'address' or 'addresses' is required", rawModel.Name)
|
|
}
|
|
for _, addr := range addresses {
|
|
if _, _, err := net.SplitHostPort(addr); err != nil {
|
|
return nil, nil, nil, AuthConfig{}, fmt.Errorf("model \"%s\": invalid address \"%s\": %w", rawModel.Name, addr, err)
|
|
}
|
|
}
|
|
// Default authRequired to global auth.enabled if not specified per-model
|
|
authRequired := false
|
|
if rawModel.AuthRequired != nil {
|
|
authRequired = *rawModel.AuthRequired
|
|
}
|
|
models[rawModel.Name] = &ModelUpstream{
|
|
Name: rawModel.Name,
|
|
Address: addresses[0],
|
|
Addresses: addresses,
|
|
Path: rawModel.Path,
|
|
UpstreamModel: rawModel.UpstreamModel,
|
|
AuthRequired: authRequired,
|
|
}
|
|
}
|
|
|
|
// Load adapters
|
|
adapters := make([]*serviceadapter.ServiceAdapter, 0, len(raw.Adapters))
|
|
for _, ra := range raw.Adapters {
|
|
if ra.ServiceName == "" {
|
|
return nil, nil, nil, AuthConfig{}, fmt.Errorf("adapter has empty serviceName")
|
|
}
|
|
a := &serviceadapter.ServiceAdapter{
|
|
Name: ra.ServiceName,
|
|
ServiceName: ra.ServiceName,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
a.Spec.ServiceName = ra.ServiceName
|
|
a.Spec.Upstream.URL = ra.Upstream.URL
|
|
a.Spec.Upstream.TimeoutSeconds = ra.Upstream.TimeoutSeconds
|
|
a.Spec.Auth.Required = ra.Auth.Required
|
|
a.Spec.Auth.Capability = ra.Auth.Capability
|
|
a.Spec.Retryable = ra.Retryable
|
|
for _, rr := range ra.Resources {
|
|
res := serviceadapter.Resource{Name: rr.Name}
|
|
for _, rm := range rr.Methods {
|
|
res.Methods = append(res.Methods, serviceadapter.Method{
|
|
Verb: rm.Verb,
|
|
UpstreamPath: rm.UpstreamPath,
|
|
RequestSchema: rm.RequestSchema,
|
|
ResponseSchema: rm.ResponseSchema,
|
|
})
|
|
}
|
|
a.Spec.Resources = append(a.Spec.Resources, res)
|
|
}
|
|
adapters = append(adapters, a)
|
|
}
|
|
|
|
// Parse auth config
|
|
authConfig := AuthConfig{
|
|
Enabled: raw.Auth.Enabled,
|
|
Issuer: raw.Auth.Issuer,
|
|
Audience: raw.Auth.Audience,
|
|
JWKSURL: raw.Auth.JWKSURL,
|
|
RequiredCapability: raw.Auth.RequiredCapability,
|
|
AllowListedMachineIdentities: raw.Auth.AllowListedMachineIdentities,
|
|
TokenURL: raw.Auth.TokenURL,
|
|
ClientID: raw.Auth.ClientID,
|
|
ClientSecret: os.Getenv("AUTH_CLIENT_SECRET"),
|
|
ZitadelDomain: raw.Auth.ZitadelDomain,
|
|
ZitadelInsecurePort: raw.Auth.ZitadelInsecurePort,
|
|
ZitadelClientIDs: raw.Auth.ZitadelClientIDs,
|
|
}
|
|
|
|
return routes, models, adapters, authConfig, nil
|
|
}
|
|
|
|
// LoadRoutesFromFile loads route configuration from a YAML file.
|
|
// Deprecated: Use LoadRoutesAndModelsFromFile instead.
|
|
func LoadRoutesFromFile(path string) (map[string]*Route, error) {
|
|
routes, _, _, _, err := LoadRoutesAndModelsFromFile(path)
|
|
return routes, err
|
|
}
|
|
|
|
// parseUpstream validates and parses upstream configuration from raw YAML.
|
|
func parseUpstream(routeName string, raw rawUpstream) (Upstream, error) {
|
|
// Validate address is not empty
|
|
if raw.Address == "" {
|
|
return Upstream{}, fmt.Errorf("route \"%s\": field 'address' is required", routeName)
|
|
}
|
|
|
|
// Validate address format (host:port)
|
|
if _, _, err := net.SplitHostPort(raw.Address); err != nil {
|
|
return Upstream{}, fmt.Errorf("route \"%s\": invalid address \"%s\": %w", routeName, raw.Address, err)
|
|
}
|
|
|
|
// Validate connectTimeout
|
|
if raw.ConnectTimeout == "" {
|
|
return Upstream{}, fmt.Errorf("route \"%s\": field 'connectTimeout' is required", routeName)
|
|
}
|
|
connectTimeout, err := time.ParseDuration(raw.ConnectTimeout)
|
|
if err != nil {
|
|
return Upstream{}, fmt.Errorf("route \"%s\": invalid connectTimeout \"%s\": %w", routeName, raw.ConnectTimeout, err)
|
|
}
|
|
|
|
// Validate readTimeout
|
|
if raw.ReadTimeout == "" {
|
|
return Upstream{}, fmt.Errorf("route \"%s\": field 'readTimeout' is required", routeName)
|
|
}
|
|
readTimeout, err := time.ParseDuration(raw.ReadTimeout)
|
|
if err != nil {
|
|
return Upstream{}, fmt.Errorf("route \"%s\": invalid readTimeout \"%s\": %w", routeName, raw.ReadTimeout, err)
|
|
}
|
|
|
|
// Validate writeTimeout
|
|
if raw.WriteTimeout == "" {
|
|
return Upstream{}, fmt.Errorf("route \"%s\": field 'writeTimeout' is required", routeName)
|
|
}
|
|
writeTimeout, err := time.ParseDuration(raw.WriteTimeout)
|
|
if err != nil {
|
|
return Upstream{}, fmt.Errorf("route \"%s\": invalid writeTimeout \"%s\": %w", routeName, raw.WriteTimeout, err)
|
|
}
|
|
|
|
// Validate maxBodySize is not zero (it must be explicitly set)
|
|
if raw.MaxBodySize == 0 {
|
|
return Upstream{}, fmt.Errorf("route \"%s\": field 'maxBodySize' is required and must be > 0", routeName)
|
|
}
|
|
|
|
// Validate authRequired is not missing
|
|
if raw.AuthRequired == nil {
|
|
return Upstream{}, fmt.Errorf("route \"%s\": field 'authRequired' is required", routeName)
|
|
}
|
|
|
|
return Upstream{
|
|
Address: raw.Address,
|
|
PathRewrite: raw.PathRewrite,
|
|
ConnectTimeout: connectTimeout,
|
|
ReadTimeout: readTimeout,
|
|
WriteTimeout: writeTimeout,
|
|
MaxBodySize: raw.MaxBodySize,
|
|
AuthRequired: *raw.AuthRequired,
|
|
}, nil
|
|
}
|