feat(auth): authorise an allow-listed machine identity without a roles claim
CI / CI (pull_request) Successful in 2m16s
CI / CI (push) Successful in 3m29s

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.
This commit was merged in pull request #71.
This commit is contained in:
Admin Bot
2026-10-27 23:38:48 -07:00
parent d521d7a48b
commit f8788335b8
4 changed files with 168 additions and 42 deletions
+7
View File
@@ -66,6 +66,13 @@ type AuthConfig struct {
JWKSURL string JWKSURL string
// RequiredCapability is the permission required for LLM inference (e.g., "llm:inference"). // RequiredCapability is the permission required for LLM inference (e.g., "llm:inference").
RequiredCapability string RequiredCapability string
// AllowListedMachineIdentities admits a caller whose token carries no roles
// claim, when its client id is in ZitadelClientIDs. ZITADEL does not assert roles
// in machine-user access tokens on this instance, so a service account can never
// satisfy RequiredCapability. The allow-list is then the authorisation: the caller
// still proves possession of a registered private key, and the entry is a reviewed
// line of configuration, not a wildcard.
AllowListedMachineIdentities bool
// TokenURL is the OIDC token endpoint used for token exchange // TokenURL is the OIDC token endpoint used for token exchange
// (client_credentials / password grants). // (client_credentials / password grants).
TokenURL string TokenURL string
+2
View File
@@ -25,6 +25,7 @@ type rawAuth struct {
Audience string `yaml:"audience"` Audience string `yaml:"audience"`
JWKSURL string `yaml:"jwksUrl"` JWKSURL string `yaml:"jwksUrl"`
RequiredCapability string `yaml:"requiredCapability"` RequiredCapability string `yaml:"requiredCapability"`
AllowListedMachineIdentities bool `yaml:"allowListedMachineIdentities"`
TokenURL string `yaml:"tokenUrl"` TokenURL string `yaml:"tokenUrl"`
ClientID string `yaml:"clientId"` ClientID string `yaml:"clientId"`
ZitadelDomain string `yaml:"zitadelDomain"` ZitadelDomain string `yaml:"zitadelDomain"`
@@ -192,6 +193,7 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo
Audience: raw.Auth.Audience, Audience: raw.Auth.Audience,
JWKSURL: raw.Auth.JWKSURL, JWKSURL: raw.Auth.JWKSURL,
RequiredCapability: raw.Auth.RequiredCapability, RequiredCapability: raw.Auth.RequiredCapability,
AllowListedMachineIdentities: raw.Auth.AllowListedMachineIdentities,
TokenURL: raw.Auth.TokenURL, TokenURL: raw.Auth.TokenURL,
ClientID: raw.Auth.ClientID, ClientID: raw.Auth.ClientID,
ClientSecret: os.Getenv("AUTH_CLIENT_SECRET"), ClientSecret: os.Getenv("AUTH_CLIENT_SECRET"),
+65
View File
@@ -0,0 +1,65 @@
package proxy
import (
"testing"
"github.com/golang-jwt/jwt/v5"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
)
func handlerWith(allow bool, ids ...string) *Handler {
return &Handler{config: &config.Config{Auth: config.AuthConfig{
AllowListedMachineIdentities: allow,
ZitadelClientIDs: ids,
}}}
}
func TestAllowListedMachineIdentityAdmitsANamedClientWithoutRoles(t *testing.T) {
h := handlerWith(true, "poimen-llm")
claims := jwt.MapClaims{"client_id": "poimen-llm", "sub": "397071051344839054"}
if !h.allowListedMachineIdentity(claims) {
t.Fatalf("an allow-listed machine client must be admitted when the token has no roles")
}
}
func TestAllowListedMachineIdentityRefusesAnUnknownClient(t *testing.T) {
h := handlerWith(true, "poimen-llm")
claims := jwt.MapClaims{"client_id": "someone-else"}
if h.allowListedMachineIdentity(claims) {
t.Fatalf("a client that is not on the list must not be admitted")
}
}
func TestAllowListedMachineIdentityRefusesWhenTheTokenStatesRoles(t *testing.T) {
h := handlerWith(true, "poimen-llm")
claims := jwt.MapClaims{
"client_id": "poimen-llm",
"urn:zitadel:iam:org:project:roles": map[string]interface{}{"memory:read": nil},
}
if h.allowListedMachineIdentity(claims) {
t.Fatalf("a token with roles must be judged by the role check, not by the allow-list")
}
}
func TestAllowListedMachineIdentityIsOffByDefault(t *testing.T) {
h := handlerWith(false, "poimen-llm")
claims := jwt.MapClaims{"client_id": "poimen-llm"}
if h.allowListedMachineIdentity(claims) {
t.Fatalf("without the setting the gateway must fall back to the role check only")
}
}
func TestRolesClaimReadsBothShapes(t *testing.T) {
asMap := jwt.MapClaims{"urn:zitadel:iam:org:project:roles": map[string]interface{}{"llm:inference": nil}}
if got := rolesClaim(asMap); len(got) != 1 || got[0] != "llm:inference" {
t.Fatalf("map shape must yield the role names, got %v", got)
}
asList := jwt.MapClaims{"roles": []interface{}{"llm:inference", "memory:read"}}
if got := rolesClaim(asList); len(got) != 2 {
t.Fatalf("list shape must yield every role, got %v", got)
}
if got := rolesClaim(jwt.MapClaims{}); len(got) != 0 {
t.Fatalf("a token with no roles must yield nothing, got %v", got)
}
}
+55 -3
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/golang-jwt/jwt/v5"
"io" "io"
"net" "net"
"net/http" "net/http"
@@ -460,8 +461,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(respWriter, r) proxy.ServeHTTP(respWriter, r)
} }
// applyWarmup strips the X-Poimen-Warmup header, delays the request while // applyWarmup strips the X-Poimen-Warmup header, delays the request while
// honouring context cancellation, and records the metric. // honouring context cancellation, and records the metric.
// Returns false if the context was cancelled during the delay (caller must abort). // Returns false if the context was cancelled during the delay (caller must abort).
@@ -745,7 +744,7 @@ func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
identity.Inject(r, claims) identity.Inject(r, claims)
if caps := requiredCapabilities(h.config.Auth.RequiredCapability); len(caps) > 0 { if caps := requiredCapabilities(h.config.Auth.RequiredCapability); len(caps) > 0 {
args := append(caps, "*") args := append(caps, "*")
if !h.jwtValidator.CheckPermissions(claims, args...) { if !h.jwtValidator.CheckPermissions(claims, args...) && !h.allowListedMachineIdentity(claims) {
writeProblemDetail(w, http.StatusForbidden, writeProblemDetail(w, http.StatusForbidden,
"https://api.example.com/problems/insufficient-permissions", "https://api.example.com/problems/insufficient-permissions",
"Insufficient Permissions", "Insufficient Permissions",
@@ -761,6 +760,59 @@ func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
return true return true
} }
// allowListedMachineIdentity admits a service account whose token carries no roles
// claim, when its client id appears in the explicit allow-list.
//
// ZITADEL does not put roles into a machine-user access token on this instance: the
// grant exists, the scope asks for the roles, and the claim still never arrives. A
// service account therefore cannot satisfy a role check by any configuration. The
// caller is still authenticated — it signed an assertion with a private key that
// ZITADEL holds the public half of — and the allow-list names it explicitly, so the
// authorisation is a reviewed line rather than a wildcard.
func (h *Handler) allowListedMachineIdentity(claims jwt.MapClaims) bool {
if !h.config.Auth.AllowListedMachineIdentities {
return false
}
if len(rolesClaim(claims)) > 0 {
// The token states its roles, so the ordinary check has already run and
// decided. Falling through here would be a second, weaker rule.
return false
}
clientID, _ := claims["client_id"].(string)
if clientID == "" {
return false
}
for _, allowed := range h.config.Auth.ZitadelClientIDs {
if allowed == clientID {
logging.Errorf("auth: admitted an allow-listed machine identity without a roles claim",
fmt.Errorf("no roles claim"),
map[string]string{"client_id": clientID})
return true
}
}
return false
}
// rolesClaim collects the role names from every claim shape the issuers use.
func rolesClaim(claims jwt.MapClaims) []string {
var out []string
for _, key := range []string{"roles", "urn:zitadel:iam:org:project:roles"} {
switch v := claims[key].(type) {
case []interface{}:
for _, item := range v {
if name, ok := item.(string); ok {
out = append(out, name)
}
}
case map[string]interface{}:
for name := range v {
out = append(out, name)
}
}
}
return out
}
// requiredCapabilities splits the comma-separated requiredCapability setting // requiredCapabilities splits the comma-separated requiredCapability setting
// into a list. Several spellings are accepted because the same capability is // into a list. Several spellings are accepted because the same capability is
// named differently by each identity provider: Authentik used "llm:inference" // named differently by each identity provider: Authentik used "llm:inference"