Skip to content

Commit

Permalink
[auth-callout] Support opt-in list of accounts to be delegated in con…
Browse files Browse the repository at this point in the history
…fig-mode (#5724)

This introduces a new `auth_callout` field `allowed_accounts` which
enables explicitly listing which accounts in the server config should be
delegated to the auth callout service.

This improves the current behavior which is an all-or-nothing switch
over from config auth to auth callout.

The primary motivation for this change is to enable the system account
to authenticate against the server rather than being dependent on the
availability of the auth callout service.

(For reference the name `allowed_accounts` [matches what exists in
nsc](https://github.com/nats-io/nsc/blob/main/cmd/editauthorization.go#L33))
  • Loading branch information
derekcollison authored Jul 30, 2024
2 parents aa37839 + 6cac305 commit 548544b
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 1 deletion.
28 changes: 27 additions & 1 deletion server/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions server/auth_callout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 27 additions & 0 deletions server/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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, &lt)
ac.AllowedAccounts = append(ac.AllowedAccounts, uv.(string))
}
default:
if !tk.IsUsedVariable() {
err := &configErr{tk, fmt.Sprintf("Unknown field %q parsing authorization callout", k)}
Expand Down
18 changes: 18 additions & 0 deletions server/opts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down

0 comments on commit 548544b

Please sign in to comment.