diff --git a/server/auth.go b/server/auth.go index 865faeb9fc8..e9cd349f65f 100644 --- a/server/auth.go +++ b/server/auth.go @@ -606,13 +606,39 @@ func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) (au } return } - // We have a juc defined here, check account. + // We have a juc, check if externally managed, i.e. should be delegated + // to the auth callout service. if juc != nil && !acc.hasExternalAuth() { if !authorized { s.sendAccountAuthErrorEvent(c, c.acc, reason) } return } + // Check config-mode. The global account is a condition since users that + // are not found in the config are implicitly bound to the global account. + // This means those users should be implicitly delegated to auth callout + // if configured. + if juc == nil && opts.AuthCallout != nil && c.acc.Name != globalAccountName { + // If no allowed accounts are defined, then all accounts are in scope. + // Otherwise see if the account is in the list. + delegated := len(opts.AuthCallout.AllowedAccounts) == 0 + if !delegated { + for _, n := range opts.AuthCallout.AllowedAccounts { + if n == c.acc.Name { + delegated = true + break + } + } + } + + // Not delegated, so return with previous authorized result. + if !delegated { + if !authorized { + s.sendAccountAuthErrorEvent(c, c.acc, reason) + } + return + } + } // We have auth callout set here. var skip bool diff --git a/server/auth_callout_test.go b/server/auth_callout_test.go index 1458dca2871..149073006b1 100644 --- a/server/auth_callout_test.go +++ b/server/auth_callout_test.go @@ -331,6 +331,88 @@ func TestAuthCalloutMultiAccounts(t *testing.T) { require_True(t, userInfo.Account == "BAZ") } +func TestAuthCalloutAllowedAccounts(t *testing.T) { + conf := ` + listen: "127.0.0.1:-1" + server_name: ZZ + accounts { + AUTH { users [ {user: "auth", password: "pwd"} ] } + FOO { users [ {user: "foo", password: "pwd"} ] } + BAR {} + SYS { users [ {user: "sys", password: "pwd"} ] } + } + system_account: SYS + no_auth_user: foo + authorization { + timeout: 1s + auth_callout { + # Needs to be a public account nkey, will work for both server config and operator mode. + issuer: "ABJHLOVMPA4CI6R5KLNGOB4GSLNIY7IOUPAJC4YFNDLQVIOBYQGUWVLA" + account: AUTH + auth_users: [ auth ] + allowed_accounts: [ BAR ] + } + } + ` + handler := func(m *nats.Msg) { + t.Helper() + user, si, ci, opts, _ := decodeAuthRequest(t, m.Data) + require_True(t, si.Name == "ZZ") + require_True(t, ci.Host == "127.0.0.1") + // Allow dlc user and map to the BAZ account. + if opts.Username == "dlc" && opts.Password == "zzz" { + ujwt := createAuthUser(t, user, _EMPTY_, "BAR", "", nil, 0, nil) + m.Respond(serviceResponse(t, user, si.ID, ujwt, "", 0)) + } else { + // Nil response signals no authentication. + m.Respond(nil) + } + } + + check := func(at *authTest, user, password, account string) { + t.Helper() + + var nc *nats.Conn + defer nc.Close() + // Assume no auth user. + if password == "" { + nc = at.Connect() + } else { + nc = at.Connect(nats.UserInfo(user, password)) + } + + resp, err := nc.Request(userDirectInfoSubj, nil, time.Second) + require_NoError(t, err) + response := ServerAPIResponse{Data: &UserInfo{}} + err = json.Unmarshal(resp.Data, &response) + require_NoError(t, err) + userInfo := response.Data.(*UserInfo) + + require_True(t, userInfo.UserID == user) + require_True(t, userInfo.Account == account) + } + + tests := []struct { + user string + password string + account string + }{ + {"dlc", "zzz", "BAR"}, + {"foo", "", "FOO"}, + {"sys", "pwd", "SYS"}, + } + + at := NewAuthTest(t, conf, handler, nats.UserInfo("auth", "pwd")) + defer at.Cleanup() + + for _, test := range tests { + t.Run(test.user, func(t *testing.T) { + at.t = t + check(at, test.user, test.password, test.account) + }) + } +} + func TestAuthCalloutClientTLSCerts(t *testing.T) { conf := ` listen: "localhost:-1" diff --git a/server/opts.go b/server/opts.go index 70524c48365..ab5f6c60b17 100644 --- a/server/opts.go +++ b/server/opts.go @@ -257,6 +257,9 @@ type AuthCallout struct { // XKey is a public xkey for the authorization service. // This will enable encryption for server requests and the authorization service responses. XKey string + // AllowedAccounts that will be delegated to the auth service. + // If empty then all accounts will be delegated. + AllowedAccounts []string } // Options block for nats-server. @@ -888,6 +891,21 @@ func (o *Options) ProcessConfigFile(configFile string) error { o.processConfigFileLine(k, v, &errors, &warnings) } + // Post-process: check auth callout allowed accounts against configured accounts. + if o.AuthCallout != nil { + accounts := make(map[string]struct{}) + for _, acc := range o.Accounts { + accounts[acc.Name] = struct{}{} + } + + for _, acc := range o.AuthCallout.AllowedAccounts { + if _, ok := accounts[acc]; !ok { + err := &configErr{nil, fmt.Sprintf("auth_callout allowed account %q not found in configured accounts", acc)} + errors = append(errors, err) + } + } + } + if len(errors) > 0 || len(warnings) > 0 { return &processConfigErr{ errors: errors, @@ -4174,6 +4192,15 @@ func parseAuthCallout(mv any, errors *[]error) (*AuthCallout, error) { if !nkeys.IsValidPublicCurveKey(ac.XKey) { return nil, &configErr{tk, fmt.Sprintf("Expected callout xkey to be a valid public xkey, got %q", ac.XKey)} } + case "allowed_accounts": + aua, ok := mv.([]any) + if !ok { + return nil, &configErr{tk, fmt.Sprintf("Expected allowed accounts field to be an array, got %T", v)} + } + for _, uv := range aua { + _, uv = unwrapValue(uv, <) + ac.AllowedAccounts = append(ac.AllowedAccounts, uv.(string)) + } default: if !tk.IsUsedVariable() { err := &configErr{tk, fmt.Sprintf("Unknown field %q parsing authorization callout", k)} diff --git a/server/opts_test.go b/server/opts_test.go index b42c8d9b855..41a36ee9c53 100644 --- a/server/opts_test.go +++ b/server/opts_test.go @@ -3292,6 +3292,24 @@ func TestAuthorizationAndAccountsMisconfigurations(t *testing.T) { `, "Can not have a token", }, + { + "auth callout allowed accounts", + ` + accounts { + AUTH { users = [ {user: "auth", password: "auth"} ] } + FOO {} + } + authorization { + auth_callout { + issuer: "ABJHLOVMPA4CI6R5KLNGOB4GSLNIY7IOUPAJC4YFNDLQVIOBYQGUWVLA" + account: AUTH + auth_users: [ auth ] + allowed_accounts: [ BAR ] + } + } + `, + "auth_callout allowed account \"BAR\" not found in configured accounts", + }, } { t.Run(test.name, func(t *testing.T) { conf := createConfFile(t, []byte(test.config))