acl: HTTP endpoints for JWT auth (#16519)
This commit is contained in:
parent
e48c48e89b
commit
2b353902a1
191
api/acl.go
191
api/acl.go
|
@ -3,6 +3,7 @@ package api
|
|||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
@ -443,18 +444,33 @@ func (a *ACLBindingRules) Get(bindingRuleID string, q *QueryOptions) (*ACLBindin
|
|||
}
|
||||
|
||||
// ACLOIDC is used to query the ACL OIDC endpoints.
|
||||
//
|
||||
// Deprecated: ACLOIDC is deprecated, use ACLAuth instead.
|
||||
type ACLOIDC struct {
|
||||
client *Client
|
||||
ACLAuth
|
||||
}
|
||||
|
||||
// ACLOIDC returns a new handle on the ACL auth-methods API client.
|
||||
//
|
||||
// Deprecated: c.ACLOIDC() is deprecated, use c.ACLAuth() instead.
|
||||
func (c *Client) ACLOIDC() *ACLOIDC {
|
||||
return &ACLOIDC{client: c}
|
||||
}
|
||||
|
||||
// ACLAuth is used to query the ACL auth endpoints.
|
||||
type ACLAuth struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// ACLAuth returns a new handle on the ACL auth-methods API client.
|
||||
func (c *Client) ACLAuth() *ACLAuth {
|
||||
return &ACLAuth{client: c}
|
||||
}
|
||||
|
||||
// GetAuthURL generates the OIDC provider authentication URL. This URL should
|
||||
// be visited in order to sign in to the provider.
|
||||
func (a *ACLOIDC) GetAuthURL(req *ACLOIDCAuthURLRequest, q *WriteOptions) (*ACLOIDCAuthURLResponse, *WriteMeta, error) {
|
||||
func (a *ACLAuth) GetAuthURL(req *ACLOIDCAuthURLRequest, q *WriteOptions) (*ACLOIDCAuthURLResponse, *WriteMeta, error) {
|
||||
var resp ACLOIDCAuthURLResponse
|
||||
wm, err := a.client.put("/v1/acl/oidc/auth-url", req, &resp, q)
|
||||
if err != nil {
|
||||
|
@ -465,7 +481,7 @@ func (a *ACLOIDC) GetAuthURL(req *ACLOIDCAuthURLRequest, q *WriteOptions) (*ACLO
|
|||
|
||||
// CompleteAuth exchanges the OIDC provider token for a Nomad token with the
|
||||
// appropriate claims attached.
|
||||
func (a *ACLOIDC) CompleteAuth(req *ACLOIDCCompleteAuthRequest, q *WriteOptions) (*ACLToken, *WriteMeta, error) {
|
||||
func (a *ACLAuth) CompleteAuth(req *ACLOIDCCompleteAuthRequest, q *WriteOptions) (*ACLToken, *WriteMeta, error) {
|
||||
var resp ACLToken
|
||||
wm, err := a.client.put("/v1/acl/oidc/complete-auth", req, &resp, q)
|
||||
if err != nil {
|
||||
|
@ -474,6 +490,17 @@ func (a *ACLOIDC) CompleteAuth(req *ACLOIDCCompleteAuthRequest, q *WriteOptions)
|
|||
return &resp, wm, nil
|
||||
}
|
||||
|
||||
// Login exchanges the third party token for a Nomad token with the appropriate
|
||||
// claims attached.
|
||||
func (a *ACLAuth) Login(req *ACLLoginRequest, q *WriteOptions) (*ACLToken, *WriteMeta, error) {
|
||||
var resp ACLToken
|
||||
wm, err := a.client.put("/v1/acl/login", req, &resp, q)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &resp, wm, nil
|
||||
}
|
||||
|
||||
// ACLPolicyListStub is used to for listing ACL policies
|
||||
type ACLPolicyListStub struct {
|
||||
Name string
|
||||
|
@ -740,20 +767,6 @@ type ACLAuthMethod struct {
|
|||
ModifyIndex uint64
|
||||
}
|
||||
|
||||
// ACLAuthMethodConfig is used to store configuration of an auth method.
|
||||
type ACLAuthMethodConfig struct {
|
||||
OIDCDiscoveryURL string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
OIDCScopes []string
|
||||
BoundAudiences []string
|
||||
AllowedRedirectURIs []string
|
||||
DiscoveryCaPem []string
|
||||
SigningAlgs []string
|
||||
ClaimMappings map[string]string
|
||||
ListClaimMappings map[string]string
|
||||
}
|
||||
|
||||
// MarshalJSON implements the json.Marshaler interface and allows
|
||||
// ACLAuthMethod.MaxTokenTTL to be marshaled correctly.
|
||||
func (m *ACLAuthMethod) MarshalJSON() ([]byte, error) {
|
||||
|
@ -793,6 +806,138 @@ func (m *ACLAuthMethod) UnmarshalJSON(data []byte) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// ACLAuthMethodConfig is used to store configuration of an auth method.
|
||||
type ACLAuthMethodConfig struct {
|
||||
// A list of PEM-encoded public keys to use to authenticate signatures
|
||||
// locally
|
||||
JWTValidationPubKeys []string
|
||||
// JSON Web Key Sets url for authenticating signatures
|
||||
JWKSURL string
|
||||
// The OIDC Discovery URL, without any .well-known component (base path)
|
||||
OIDCDiscoveryURL string
|
||||
// The OAuth Client ID configured with the OIDC provider
|
||||
OIDCClientID string
|
||||
// The OAuth Client Secret configured with the OIDC provider
|
||||
OIDCClientSecret string
|
||||
// List of OIDC scopes
|
||||
OIDCScopes []string
|
||||
// List of auth claims that are valid for login
|
||||
BoundAudiences []string
|
||||
// The value against which to match the iss claim in a JWT
|
||||
BoundIssuer []string
|
||||
// A list of allowed values for redirect_uri
|
||||
AllowedRedirectURIs []string
|
||||
// PEM encoded CA certs for use by the TLS client used to talk with the
|
||||
// OIDC Discovery URL.
|
||||
DiscoveryCaPem []string
|
||||
// PEM encoded CA cert for use by the TLS client used to talk with the JWKS
|
||||
// URL
|
||||
JWKSCACert string
|
||||
// A list of supported signing algorithms
|
||||
SigningAlgs []string
|
||||
// Duration in seconds of leeway when validating expiration of a token to
|
||||
// account for clock skew
|
||||
ExpirationLeeway time.Duration
|
||||
// Duration in seconds of leeway when validating not before values of a
|
||||
// token to account for clock skew.
|
||||
NotBeforeLeeway time.Duration
|
||||
// Duration in seconds of leeway when validating all claims to account for
|
||||
// clock skew.
|
||||
ClockSkewLeeway time.Duration
|
||||
// Mappings of claims (key) that will be copied to a metadata field
|
||||
// (value).
|
||||
ClaimMappings map[string]string
|
||||
ListClaimMappings map[string]string
|
||||
}
|
||||
|
||||
// MarshalJSON implements the json.Marshaler interface and allows
|
||||
// time.Duration fields to be marshaled correctly.
|
||||
func (c *ACLAuthMethodConfig) MarshalJSON() ([]byte, error) {
|
||||
type Alias ACLAuthMethodConfig
|
||||
exported := &struct {
|
||||
ExpirationLeeway string
|
||||
NotBeforeLeeway string
|
||||
ClockSkewLeeway string
|
||||
*Alias
|
||||
}{
|
||||
ExpirationLeeway: c.ExpirationLeeway.String(),
|
||||
NotBeforeLeeway: c.NotBeforeLeeway.String(),
|
||||
ClockSkewLeeway: c.ClockSkewLeeway.String(),
|
||||
Alias: (*Alias)(c),
|
||||
}
|
||||
if c.ExpirationLeeway == 0 {
|
||||
exported.ExpirationLeeway = ""
|
||||
}
|
||||
if c.NotBeforeLeeway == 0 {
|
||||
exported.NotBeforeLeeway = ""
|
||||
}
|
||||
if c.ClockSkewLeeway == 0 {
|
||||
exported.ClockSkewLeeway = ""
|
||||
}
|
||||
return json.Marshal(exported)
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements the json.Unmarshaler interface and allows
|
||||
// time.Duration fields to be unmarshalled correctly.
|
||||
func (c *ACLAuthMethodConfig) UnmarshalJSON(data []byte) error {
|
||||
type Alias ACLAuthMethodConfig
|
||||
aux := &struct {
|
||||
ExpirationLeeway any
|
||||
NotBeforeLeeway any
|
||||
ClockSkewLeeway any
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(c),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
if aux.ExpirationLeeway != nil {
|
||||
switch v := aux.ExpirationLeeway.(type) {
|
||||
case string:
|
||||
if v != "" {
|
||||
if c.ExpirationLeeway, err = time.ParseDuration(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case float64:
|
||||
c.ExpirationLeeway = time.Duration(v)
|
||||
default:
|
||||
return fmt.Errorf("unexpected ExpirationLeeway type: %v", v)
|
||||
}
|
||||
}
|
||||
if aux.NotBeforeLeeway != nil {
|
||||
switch v := aux.NotBeforeLeeway.(type) {
|
||||
case string:
|
||||
if v != "" {
|
||||
if c.NotBeforeLeeway, err = time.ParseDuration(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case float64:
|
||||
c.NotBeforeLeeway = time.Duration(v)
|
||||
default:
|
||||
return fmt.Errorf("unexpected NotBeforeLeeway type: %v", v)
|
||||
}
|
||||
}
|
||||
if aux.ClockSkewLeeway != nil {
|
||||
switch v := aux.ClockSkewLeeway.(type) {
|
||||
case string:
|
||||
if v != "" {
|
||||
if c.ClockSkewLeeway, err = time.ParseDuration(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case float64:
|
||||
c.ClockSkewLeeway = time.Duration(v)
|
||||
default:
|
||||
return fmt.Errorf("unexpected ClockSkewLeeway type: %v", v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ACLAuthMethodListStub is the stub object returned when performing a listing
|
||||
// of ACL auth-methods. It is intentionally minimal due to the unauthenticated
|
||||
// nature of the list endpoint.
|
||||
|
@ -818,6 +963,10 @@ const (
|
|||
// ACLAuthMethodTypeOIDC the ACLAuthMethod.Type and represents an
|
||||
// auth-method which uses the OIDC protocol.
|
||||
ACLAuthMethodTypeOIDC = "OIDC"
|
||||
|
||||
// ACLAuthMethodTypeJWT the ACLAuthMethod.Type and represents an auth-method
|
||||
// which uses the JWT type.
|
||||
ACLAuthMethodTypeJWT = "JWT"
|
||||
)
|
||||
|
||||
// ACLBindingRule contains a direct relation to an ACLAuthMethod and represents
|
||||
|
@ -947,3 +1096,13 @@ type ACLOIDCCompleteAuthRequest struct {
|
|||
// required parameter.
|
||||
RedirectURI string
|
||||
}
|
||||
|
||||
// ACLLoginRequest is the request object to begin auth with an external bearer
|
||||
// token provider.
|
||||
type ACLLoginRequest struct {
|
||||
// AuthMethodName is the name of the auth method being used to login. This
|
||||
// is a required parameter.
|
||||
AuthMethodName string
|
||||
// LoginToken is the token used to login. This is a required parameter.
|
||||
LoginToken string
|
||||
}
|
||||
|
|
|
@ -874,3 +874,22 @@ func (s *HTTPServer) ACLOIDCCompleteAuthRequest(resp http.ResponseWriter, req *h
|
|||
setIndex(resp, out.Index)
|
||||
return out.ACLToken, nil
|
||||
}
|
||||
|
||||
// ACLLoginRequest performs a non-interactive authentication request
|
||||
func (s *HTTPServer) ACLLoginRequest(resp http.ResponseWriter, req *http.Request) (any, error) {
|
||||
// The endpoint only supports PUT or POST requests.
|
||||
if req.Method != http.MethodPost && req.Method != http.MethodPut {
|
||||
return nil, CodedError(http.StatusMethodNotAllowed, ErrInvalidMethod)
|
||||
}
|
||||
var args structs.ACLLoginRequest
|
||||
s.parseWriteRequest(req, &args.WriteRequest)
|
||||
if err := decodeBody(req, &args); err != nil {
|
||||
return nil, CodedError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
var out structs.ACLLoginResponse
|
||||
if err := s.agent.RPC(structs.ACLLoginRPCMethod, &args, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setIndex(resp, out.Index)
|
||||
return out.ACLToken, nil
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
capOIDC "github.com/hashicorp/cap/oidc"
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/helper/uuid"
|
||||
|
@ -1900,3 +1901,135 @@ func TestHTTPServer_ACLOIDCCompleteAuthRequest(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPServer_ACLLoginRequest(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
testFn func(srv *TestAgent)
|
||||
}{
|
||||
{
|
||||
name: "incorrect method",
|
||||
testFn: func(testAgent *TestAgent) {
|
||||
|
||||
// Build the HTTP request.
|
||||
req, err := http.NewRequest(http.MethodConnect, "/v1/acl/login", nil)
|
||||
must.NoError(t, err)
|
||||
respW := httptest.NewRecorder()
|
||||
|
||||
// Send the HTTP request.
|
||||
obj, err := testAgent.Server.ACLOIDCCompleteAuthRequest(respW, req)
|
||||
must.Error(t, err)
|
||||
must.StrContains(t, err.Error(), "Invalid method")
|
||||
must.Nil(t, obj)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
testFn: func(testAgent *TestAgent) {
|
||||
|
||||
// Generate a sample JWT
|
||||
iat := time.Now().Unix()
|
||||
nbf := time.Now().Unix()
|
||||
exp := time.Now().Add(time.Hour).Unix()
|
||||
claims := jwt.MapClaims{
|
||||
"iss": "nomad test suite",
|
||||
"iat": iat,
|
||||
"nbf": nbf,
|
||||
"exp": exp,
|
||||
"aud": "engineering",
|
||||
"http://nomad.internal/policies": []string{"engineering"},
|
||||
"http://nomad.internal/roles": []string{"engineering"},
|
||||
}
|
||||
|
||||
token, pubKey, err := mock.SampleJWTokenWithKeys(claims, nil)
|
||||
must.NoError(t, err)
|
||||
|
||||
// Generate and upsert a JWT ACL auth method for use.
|
||||
mockedAuthMethod := mock.ACLJWTAuthMethod()
|
||||
mockedAuthMethod.Config.BoundAudiences = []string{"engineering"}
|
||||
mockedAuthMethod.Config.JWTValidationPubKeys = []string{pubKey}
|
||||
mockedAuthMethod.Config.BoundIssuer = []string{"nomad test suite"}
|
||||
mockedAuthMethod.Config.ExpirationLeeway = time.Duration(3600)
|
||||
mockedAuthMethod.Config.ClockSkewLeeway = time.Duration(3600)
|
||||
mockedAuthMethod.Config.ClaimMappings = map[string]string{}
|
||||
mockedAuthMethod.Config.ListClaimMappings = map[string]string{
|
||||
"http://nomad.internal/roles": "roles",
|
||||
"http://nomad.internal/policies": "policies",
|
||||
}
|
||||
|
||||
must.NoError(t, testAgent.server.State().UpsertACLAuthMethods(
|
||||
10, []*structs.ACLAuthMethod{mockedAuthMethod}))
|
||||
|
||||
// Generate the request body.
|
||||
requestBody := structs.ACLLoginRequest{
|
||||
AuthMethodName: mockedAuthMethod.Name,
|
||||
LoginToken: token,
|
||||
WriteRequest: structs.WriteRequest{
|
||||
Region: "global",
|
||||
},
|
||||
}
|
||||
|
||||
// Build the HTTP request.
|
||||
req, err := http.NewRequest(http.MethodPost, "/v1/acl/login", encodeReq(&requestBody))
|
||||
must.NoError(t, err)
|
||||
respW := httptest.NewRecorder()
|
||||
|
||||
// Send the HTTP request.
|
||||
_, err = testAgent.Server.ACLLoginRequest(respW, req)
|
||||
must.ErrorContains(t, err, "no role or policy bindings matched")
|
||||
|
||||
// Upsert an ACL policy and role, so that we can reference this within our
|
||||
// OIDC claims.
|
||||
mockACLPolicy := mock.ACLPolicy()
|
||||
must.NoError(t, testAgent.server.State().UpsertACLPolicies(
|
||||
structs.MsgTypeTestSetup, 20, []*structs.ACLPolicy{mockACLPolicy}))
|
||||
|
||||
mockACLRole := mock.ACLRole()
|
||||
mockACLRole.Policies = []*structs.ACLRolePolicyLink{{Name: mockACLPolicy.Name}}
|
||||
must.NoError(t, testAgent.server.State().UpsertACLRoles(
|
||||
structs.MsgTypeTestSetup, 30, []*structs.ACLRole{mockACLRole}, true))
|
||||
|
||||
// Generate and upsert two binding rules, so we can test both ACL Policy
|
||||
// and Role claim mapping.
|
||||
mockBindingRule1 := mock.ACLBindingRule()
|
||||
mockBindingRule1.AuthMethod = mockedAuthMethod.Name
|
||||
mockBindingRule1.BindType = structs.ACLBindingRuleBindTypePolicy
|
||||
mockBindingRule1.Selector = "engineering in list.policies"
|
||||
mockBindingRule1.BindName = mockACLPolicy.Name
|
||||
|
||||
mockBindingRule2 := mock.ACLBindingRule()
|
||||
mockBindingRule2.AuthMethod = mockedAuthMethod.Name
|
||||
mockBindingRule2.BindName = mockACLRole.Name
|
||||
|
||||
must.NoError(t, testAgent.server.State().UpsertACLBindingRules(
|
||||
40, []*structs.ACLBindingRule{mockBindingRule1, mockBindingRule2}, true))
|
||||
|
||||
// Build the HTTP request.
|
||||
req, err = http.NewRequest(http.MethodPost, "/v1/acl/login", encodeReq(&requestBody))
|
||||
must.NoError(t, err)
|
||||
respW = httptest.NewRecorder()
|
||||
|
||||
// Send the HTTP request.
|
||||
obj, err := testAgent.Server.ACLLoginRequest(respW, req)
|
||||
must.NoError(t, err)
|
||||
|
||||
aclTokenResp, ok := obj.(*structs.ACLToken)
|
||||
must.True(t, ok)
|
||||
must.NotNil(t, aclTokenResp)
|
||||
must.Len(t, 1, aclTokenResp.Policies)
|
||||
must.Eq(t, mockACLPolicy.Name, aclTokenResp.Policies[0])
|
||||
must.Len(t, 1, aclTokenResp.Roles)
|
||||
must.Eq(t, mockACLRole.Name, aclTokenResp.Roles[0].Name)
|
||||
must.Eq(t, mockACLRole.ID, aclTokenResp.Roles[0].ID)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
httpACLTest(t, nil, tc.testFn)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -425,9 +425,10 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) {
|
|||
s.mux.HandleFunc("/v1/acl/binding-rule", s.wrap(s.ACLBindingRuleRequest))
|
||||
s.mux.HandleFunc("/v1/acl/binding-rule/", s.wrap(s.ACLBindingRuleSpecificRequest))
|
||||
|
||||
// Register out ACL OIDC SSO provider handlers.
|
||||
// Register out ACL OIDC SSO and auth handlers.
|
||||
s.mux.HandleFunc("/v1/acl/oidc/auth-url", s.wrap(s.ACLOIDCAuthURLRequest))
|
||||
s.mux.HandleFunc("/v1/acl/oidc/complete-auth", s.wrap(s.ACLOIDCCompleteAuthRequest))
|
||||
s.mux.HandleFunc("/v1/acl/login", s.wrap(s.ACLLoginRequest))
|
||||
|
||||
s.mux.Handle("/v1/client/fs/", wrapCORS(s.wrap(s.FsRequest)))
|
||||
s.mux.HandleFunc("/v1/client/gc", s.wrap(s.ClientGCRequest))
|
||||
|
|
|
@ -205,7 +205,7 @@ func (l *LoginCommand) loginOIDC(ctx context.Context, client *api.Client) (*api.
|
|||
ClientNonce: callbackServer.Nonce(),
|
||||
}
|
||||
|
||||
getAuthURLResp, _, err := client.ACLOIDC().GetAuthURL(&getAuthArgs, nil)
|
||||
getAuthURLResp, _, err := client.ACLAuth().GetAuthURL(&getAuthArgs, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -240,7 +240,7 @@ func (l *LoginCommand) loginOIDC(ctx context.Context, client *api.Client) (*api.
|
|||
State: req.State,
|
||||
}
|
||||
|
||||
token, _, err := client.ACLOIDC().CompleteAuth(&cbArgs, nil)
|
||||
token, _, err := client.ACLAuth().CompleteAuth(&cbArgs, nil)
|
||||
return token, err
|
||||
}
|
||||
|
||||
|
|
|
@ -55,7 +55,7 @@ func TestACLOIDC_GetAuthURL(t *testing.T) {
|
|||
ClientNonce: "fpSPuaodKevKfDU3IeXb",
|
||||
}
|
||||
|
||||
authURLResp, _, err := testClient.ACLOIDC().GetAuthURL(&authURLRequest, nil)
|
||||
authURLResp, _, err := testClient.ACLAuth().GetAuthURL(&authURLRequest, nil)
|
||||
must.NoError(t, err)
|
||||
|
||||
// The response URL comes encoded, so decode this and check we have each
|
||||
|
@ -170,7 +170,7 @@ func TestACLOIDC_CompleteAuth(t *testing.T) {
|
|||
Code: "codeABC",
|
||||
}
|
||||
|
||||
completeAuthResp, _, err := testClient.ACLOIDC().CompleteAuth(&authURLRequest, nil)
|
||||
completeAuthResp, _, err := testClient.ACLAuth().CompleteAuth(&authURLRequest, nil)
|
||||
must.NoError(t, err)
|
||||
must.NotNil(t, completeAuthResp)
|
||||
must.Len(t, 1, completeAuthResp.Policies)
|
||||
|
|
|
@ -63,7 +63,7 @@ var minACLAuthMethodVersion = version.Must(version.NewVersion("1.5.0-beta.1"))
|
|||
//
|
||||
// TODO: version constraint will be updated for until we reach 1.5.2, otherwise
|
||||
// it's hard to test the functionality
|
||||
var minACLJWTAuthMethodVersion = version.Must(version.NewVersion("1.4.4-dev"))
|
||||
var minACLJWTAuthMethodVersion = version.Must(version.NewVersion("1.5.2-dev"))
|
||||
|
||||
// minACLBindingRuleVersion is the Nomad version at which the ACL binding rules
|
||||
// table was introduced. It forms the minimum version all federated servers
|
||||
|
|
Loading…
Reference in New Issue