Groundwork for exposing when queries are filtered by ACLs (#11569)
This commit is contained in:
parent
da929237b5
commit
0efe478044
|
@ -0,0 +1,3 @@
|
|||
```release-note:enhancement
|
||||
api: responses that contain only a partial subset of results, due to filtering by ACL policies, may now include an `X-Consul-Results-Filtered-By-ACLs` header
|
||||
```
|
|
@ -1807,6 +1807,7 @@ func filterACLWithAuthorizer(logger hclog.Logger, authorizer acl.Authorizer, sub
|
|||
filtered := filt.filterServiceTopology(v.ServiceTopology)
|
||||
if filtered {
|
||||
v.FilteredByACLs = true
|
||||
v.QueryMeta.ResultsFilteredByACLs = true
|
||||
}
|
||||
|
||||
case *structs.DatacenterIndexedCheckServiceNodes:
|
||||
|
|
|
@ -1360,7 +1360,7 @@ func (a *ACL) PolicyResolve(args *structs.ACLPolicyBatchGetRequest, reply *struc
|
|||
}
|
||||
}
|
||||
|
||||
a.srv.setQueryMeta(&reply.QueryMeta)
|
||||
a.srv.setQueryMeta(&reply.QueryMeta, args.Token)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -1761,7 +1761,7 @@ func (a *ACL) RoleResolve(args *structs.ACLRoleBatchGetRequest, reply *structs.A
|
|||
}
|
||||
}
|
||||
|
||||
a.srv.setQueryMeta(&reply.QueryMeta)
|
||||
a.srv.setQueryMeta(&reply.QueryMeta, args.Token)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -411,7 +411,7 @@ func (m *Internal) EventFire(args *structs.EventFireRequest,
|
|||
}
|
||||
|
||||
// Set the query meta data
|
||||
m.srv.setQueryMeta(&reply.QueryMeta)
|
||||
m.srv.setQueryMeta(&reply.QueryMeta, args.Token)
|
||||
|
||||
// Add the consul prefix to the event name
|
||||
eventName := userEventName(args.Name)
|
||||
|
|
|
@ -1728,6 +1728,7 @@ func TestInternal_ServiceTopology(t *testing.T) {
|
|||
var out structs.IndexedServiceTopology
|
||||
require.NoError(r, msgpackrpc.CallWithCodec(codec, "Internal.ServiceTopology", &args, &out))
|
||||
require.False(r, out.FilteredByACLs)
|
||||
require.False(r, out.QueryMeta.ResultsFilteredByACLs)
|
||||
require.Equal(r, "http", out.ServiceTopology.MetricsProtocol)
|
||||
|
||||
// foo/api, foo/api-proxy
|
||||
|
@ -1767,6 +1768,7 @@ func TestInternal_ServiceTopology(t *testing.T) {
|
|||
var out structs.IndexedServiceTopology
|
||||
require.NoError(r, msgpackrpc.CallWithCodec(codec, "Internal.ServiceTopology", &args, &out))
|
||||
require.False(r, out.FilteredByACLs)
|
||||
require.False(r, out.QueryMeta.ResultsFilteredByACLs)
|
||||
require.Equal(r, "http", out.ServiceTopology.MetricsProtocol)
|
||||
|
||||
// edge/ingress
|
||||
|
@ -1822,6 +1824,7 @@ func TestInternal_ServiceTopology(t *testing.T) {
|
|||
var out structs.IndexedServiceTopology
|
||||
require.NoError(r, msgpackrpc.CallWithCodec(codec, "Internal.ServiceTopology", &args, &out))
|
||||
require.False(r, out.FilteredByACLs)
|
||||
require.False(r, out.QueryMeta.ResultsFilteredByACLs)
|
||||
require.Equal(r, "http", out.ServiceTopology.MetricsProtocol)
|
||||
|
||||
// foo/api, foo/api-proxy
|
||||
|
@ -1875,6 +1878,7 @@ func TestInternal_ServiceTopology(t *testing.T) {
|
|||
var out structs.IndexedServiceTopology
|
||||
require.NoError(r, msgpackrpc.CallWithCodec(codec, "Internal.ServiceTopology", &args, &out))
|
||||
require.False(r, out.FilteredByACLs)
|
||||
require.False(r, out.QueryMeta.ResultsFilteredByACLs)
|
||||
require.Equal(r, "http", out.ServiceTopology.MetricsProtocol)
|
||||
|
||||
require.Len(r, out.ServiceTopology.Upstreams, 0)
|
||||
|
@ -1931,6 +1935,7 @@ func TestInternal_ServiceTopology_RoutingConfig(t *testing.T) {
|
|||
var out structs.IndexedServiceTopology
|
||||
require.NoError(r, msgpackrpc.CallWithCodec(codec, "Internal.ServiceTopology", &args, &out))
|
||||
require.False(r, out.FilteredByACLs)
|
||||
require.False(r, out.QueryMeta.ResultsFilteredByACLs)
|
||||
require.Equal(r, "http", out.ServiceTopology.MetricsProtocol)
|
||||
|
||||
require.Empty(r, out.ServiceTopology.Downstreams)
|
||||
|
@ -2010,6 +2015,7 @@ service "web" { policy = "read" }
|
|||
require.NoError(t, msgpackrpc.CallWithCodec(codec, "Internal.ServiceTopology", &args, &out))
|
||||
|
||||
require.True(t, out.FilteredByACLs)
|
||||
require.True(t, out.QueryMeta.ResultsFilteredByACLs)
|
||||
require.Equal(t, "http", out.ServiceTopology.MetricsProtocol)
|
||||
|
||||
// The web-proxy upstream gets filtered out from both bar and baz
|
||||
|
@ -2030,6 +2036,7 @@ service "web" { policy = "read" }
|
|||
require.NoError(t, msgpackrpc.CallWithCodec(codec, "Internal.ServiceTopology", &args, &out))
|
||||
|
||||
require.True(t, out.FilteredByACLs)
|
||||
require.True(t, out.QueryMeta.ResultsFilteredByACLs)
|
||||
require.Equal(t, "http", out.ServiceTopology.MetricsProtocol)
|
||||
|
||||
// The redis upstream gets filtered out but the api and proxy downstream are returned
|
||||
|
|
|
@ -299,7 +299,7 @@ func (p *PreparedQuery) Explain(args *structs.PreparedQueryExecuteRequest,
|
|||
defer metrics.MeasureSince([]string{"prepared-query", "explain"}, time.Now())
|
||||
|
||||
// We have to do this ourselves since we are not doing a blocking RPC.
|
||||
p.srv.setQueryMeta(&reply.QueryMeta)
|
||||
p.srv.setQueryMeta(&reply.QueryMeta, args.Token)
|
||||
if args.RequireConsistent {
|
||||
if err := p.srv.consistentRead(); err != nil {
|
||||
return err
|
||||
|
@ -346,7 +346,6 @@ func (p *PreparedQuery) Execute(args *structs.PreparedQueryExecuteRequest,
|
|||
defer metrics.MeasureSince([]string{"prepared-query", "execute"}, time.Now())
|
||||
|
||||
// We have to do this ourselves since we are not doing a blocking RPC.
|
||||
p.srv.setQueryMeta(&reply.QueryMeta)
|
||||
if args.RequireConsistent {
|
||||
if err := p.srv.consistentRead(); err != nil {
|
||||
return err
|
||||
|
@ -383,6 +382,9 @@ func (p *PreparedQuery) Execute(args *structs.PreparedQueryExecuteRequest,
|
|||
// might not be worth the code complexity and behavior differences,
|
||||
// though, since this is essentially a misconfiguration.
|
||||
|
||||
// We have to do this ourselves since we are not doing a blocking RPC.
|
||||
p.srv.setQueryMeta(&reply.QueryMeta, token)
|
||||
|
||||
// Shuffle the results in case coordinates are not available if they
|
||||
// requested an RTT sort.
|
||||
reply.Nodes.Shuffle()
|
||||
|
@ -481,7 +483,6 @@ func (p *PreparedQuery) ExecuteRemote(args *structs.PreparedQueryExecuteRemoteRe
|
|||
defer metrics.MeasureSince([]string{"prepared-query", "execute_remote"}, time.Now())
|
||||
|
||||
// We have to do this ourselves since we are not doing a blocking RPC.
|
||||
p.srv.setQueryMeta(&reply.QueryMeta)
|
||||
if args.RequireConsistent {
|
||||
if err := p.srv.consistentRead(); err != nil {
|
||||
return err
|
||||
|
@ -503,6 +504,9 @@ func (p *PreparedQuery) ExecuteRemote(args *structs.PreparedQueryExecuteRemoteRe
|
|||
return err
|
||||
}
|
||||
|
||||
// We have to do this ourselves since we are not doing a blocking RPC.
|
||||
p.srv.setQueryMeta(&reply.QueryMeta, token)
|
||||
|
||||
// We don't bother trying to do an RTT sort here since we are by
|
||||
// definition in another DC. We just shuffle to make sure that we
|
||||
// balance the load across the results.
|
||||
|
|
|
@ -938,8 +938,6 @@ func (s *Server) blockingQuery(queryOpts structs.QueryOptionsCompat, queryMeta s
|
|||
|
||||
RUN_QUERY:
|
||||
// Setup blocking loop
|
||||
// Update the query metadata.
|
||||
s.setQueryMeta(queryMeta)
|
||||
|
||||
// Validate
|
||||
// If the read must be consistent we verify that we are still the leader.
|
||||
|
@ -968,6 +966,10 @@ RUN_QUERY:
|
|||
|
||||
// Execute the queryFn
|
||||
err := fn(ws, state)
|
||||
|
||||
// Update the query metadata.
|
||||
s.setQueryMeta(queryMeta, queryOpts.GetToken())
|
||||
|
||||
// Note we check queryOpts.MinQueryIndex is greater than zero to determine if
|
||||
// blocking was requested by client, NOT meta.Index since the state function
|
||||
// might return zero if something is not initialized and care wasn't taken to
|
||||
|
@ -1001,7 +1003,9 @@ RUN_QUERY:
|
|||
}
|
||||
|
||||
// setQueryMeta is used to populate the QueryMeta data for an RPC call
|
||||
func (s *Server) setQueryMeta(m structs.QueryMetaCompat) {
|
||||
//
|
||||
// Note: This method must be called *after* filtering query results with ACLs.
|
||||
func (s *Server) setQueryMeta(m structs.QueryMetaCompat, token string) {
|
||||
if s.IsLeader() {
|
||||
m.SetLastContact(0)
|
||||
m.SetKnownLeader(true)
|
||||
|
@ -1009,6 +1013,7 @@ func (s *Server) setQueryMeta(m structs.QueryMetaCompat) {
|
|||
m.SetLastContact(time.Since(s.raft.LastContact()))
|
||||
m.SetKnownLeader(s.raft.Leader() != "")
|
||||
}
|
||||
maskResultsFilteredByACLs(token, m)
|
||||
}
|
||||
|
||||
// consistentRead is used to ensure we do not perform a stale
|
||||
|
@ -1043,3 +1048,31 @@ func (s *Server) consistentRead() error {
|
|||
|
||||
return structs.ErrNotReadyForConsistentReads
|
||||
}
|
||||
|
||||
// maskResultsFilteredByACLs blanks out the ResultsFilteredByACLs flag if the
|
||||
// request is unauthenticated, to limit information leaking.
|
||||
//
|
||||
// Endpoints that support bexpr filtering could be used in combination with
|
||||
// this flag/header to discover the existence of resources to which the user
|
||||
// does not have access, therefore we only expose it when the user presents
|
||||
// a valid ACL token. This doesn't completely remove the risk (by nature the
|
||||
// purpose of this flag is to let the user know there are resources they can
|
||||
// not access) but it prevents completely unauthenticated users from doing so.
|
||||
//
|
||||
// Notes:
|
||||
//
|
||||
// * The definition of "unauthenticated" here is incomplete, as it doesn't
|
||||
// account for the fact that operators can modify the anonymous token with
|
||||
// custom policies, or set namespace default policies. As these scenarios
|
||||
// are less common and this flag is a best-effort UX improvement, we think
|
||||
// the trade-off for reduced complexity is acceptable.
|
||||
//
|
||||
// * This method assumes that the given token has already been validated (and
|
||||
// will only check whether it is blank or not). It's a safe assumption because
|
||||
// ResultsFilteredByACLs is only set to try when applying the already-resolved
|
||||
// token's policies.
|
||||
func maskResultsFilteredByACLs(token string, meta structs.QueryMetaCompat) {
|
||||
if token == "" {
|
||||
meta.SetResultsFilteredByACLs(false)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@ import (
|
|||
"github.com/hashicorp/consul/agent/structs"
|
||||
tokenStore "github.com/hashicorp/consul/agent/token"
|
||||
"github.com/hashicorp/consul/api"
|
||||
"github.com/hashicorp/consul/lib"
|
||||
"github.com/hashicorp/consul/proto/pbsubscribe"
|
||||
"github.com/hashicorp/consul/sdk/testutil"
|
||||
"github.com/hashicorp/consul/sdk/testutil/retry"
|
||||
|
@ -369,6 +370,39 @@ func TestRPC_blockingQuery(t *testing.T) {
|
|||
t.Fatalf("bad: %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("ResultsFilteredByACLs is reset for unauthenticated calls", func(t *testing.T) {
|
||||
opts := structs.QueryOptions{
|
||||
Token: "",
|
||||
}
|
||||
var meta structs.QueryMeta
|
||||
fn := func(_ memdb.WatchSet, _ *state.Store) error {
|
||||
meta.ResultsFilteredByACLs = true
|
||||
return nil
|
||||
}
|
||||
|
||||
err := s.blockingQuery(&opts, &meta, fn)
|
||||
require.NoError(err)
|
||||
require.False(meta.ResultsFilteredByACLs, "ResultsFilteredByACLs should be reset for unauthenticated calls")
|
||||
})
|
||||
|
||||
t.Run("ResultsFilteredByACLs is honored for authenticated calls", func(t *testing.T) {
|
||||
token, err := lib.GenerateUUID(nil)
|
||||
require.NoError(err)
|
||||
|
||||
opts := structs.QueryOptions{
|
||||
Token: token,
|
||||
}
|
||||
var meta structs.QueryMeta
|
||||
fn := func(_ memdb.WatchSet, _ *state.Store) error {
|
||||
meta.ResultsFilteredByACLs = true
|
||||
return nil
|
||||
}
|
||||
|
||||
err = s.blockingQuery(&opts, &meta, fn)
|
||||
require.NoError(err)
|
||||
require.True(meta.ResultsFilteredByACLs, "ResultsFilteredByACLs should be honored for authenticated calls")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRPC_ReadyForConsistentReads(t *testing.T) {
|
||||
|
|
|
@ -77,7 +77,7 @@ func (s *Server) dispatchSnapshotRequest(args *structs.SnapshotRequest, in io.Re
|
|||
|
||||
// Set the metadata here before we do anything; this should always be
|
||||
// pessimistic if we get more data while the snapshot is being taken.
|
||||
s.setQueryMeta(&reply.QueryMeta)
|
||||
s.setQueryMeta(&reply.QueryMeta, args.Token)
|
||||
|
||||
// Take the snapshot and capture the index.
|
||||
snap, err := snapshot.New(s.logger, s.raft)
|
||||
|
|
|
@ -183,7 +183,6 @@ func (t *Txn) Read(args *structs.TxnReadRequest, reply *structs.TxnReadResponse)
|
|||
defer metrics.MeasureSince([]string{"txn", "read"}, time.Now())
|
||||
|
||||
// We have to do this ourselves since we are not doing a blocking RPC.
|
||||
t.srv.setQueryMeta(&reply.QueryMeta)
|
||||
if args.RequireConsistent {
|
||||
if err := t.srv.consistentRead(); err != nil {
|
||||
return err
|
||||
|
@ -204,5 +203,9 @@ func (t *Txn) Read(args *structs.TxnReadRequest, reply *structs.TxnReadResponse)
|
|||
state := t.srv.fsm.State()
|
||||
reply.Results, reply.Errors = state.TxnRO(args.Ops)
|
||||
reply.Results = FilterTxnResults(authz, reply.Results)
|
||||
|
||||
// We have to do this ourselves since we are not doing a blocking RPC.
|
||||
t.srv.setQueryMeta(&reply.QueryMeta, args.Token)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -941,11 +941,7 @@ func TestTxn_Read_ACLDeny(t *testing.T) {
|
|||
}
|
||||
|
||||
// Verify the transaction's return value.
|
||||
expected := structs.TxnReadResponse{
|
||||
QueryMeta: structs.QueryMeta{
|
||||
KnownLeader: true,
|
||||
},
|
||||
}
|
||||
var expected structs.TxnReadResponse
|
||||
for i, op := range arg.Ops {
|
||||
switch {
|
||||
case op.KV != nil:
|
||||
|
|
|
@ -734,6 +734,7 @@ func setMeta(resp http.ResponseWriter, m structs.QueryMetaCompat) {
|
|||
setKnownLeader(resp, m.GetKnownLeader())
|
||||
setConsistency(resp, m.GetConsistencyLevel())
|
||||
setQueryBackend(resp, m.GetBackend())
|
||||
setResultsFilteredByACLs(resp, m.GetResultsFilteredByACLs())
|
||||
}
|
||||
|
||||
func setQueryBackend(resp http.ResponseWriter, backend structs.QueryBackend) {
|
||||
|
@ -757,6 +758,16 @@ func setCacheMeta(resp http.ResponseWriter, m *cache.ResultMeta) {
|
|||
}
|
||||
}
|
||||
|
||||
// setResultsFilteredByACLs sets an HTTP response header to indicate that the
|
||||
// query results were filtered by enforcing ACLs. If the given filtered value
|
||||
// is false the header will be omitted, as its ambiguous whether the results
|
||||
// were not filtered or whether the endpoint doesn't yet support this header.
|
||||
func setResultsFilteredByACLs(resp http.ResponseWriter, filtered bool) {
|
||||
if filtered {
|
||||
resp.Header().Set("X-Consul-Results-Filtered-By-ACLs", "true")
|
||||
}
|
||||
}
|
||||
|
||||
// setHeaders is used to set canonical response header fields
|
||||
func setHeaders(resp http.ResponseWriter, headers map[string]string) {
|
||||
for field, value := range headers {
|
||||
|
|
|
@ -264,6 +264,22 @@ func TestSetKnownLeader(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSetFilteredByACLs(t *testing.T) {
|
||||
t.Parallel()
|
||||
resp := httptest.NewRecorder()
|
||||
setResultsFilteredByACLs(resp, true)
|
||||
header := resp.Header().Get("X-Consul-Results-Filtered-By-ACLs")
|
||||
if header != "true" {
|
||||
t.Fatalf("Bad: %v", header)
|
||||
}
|
||||
resp = httptest.NewRecorder()
|
||||
setResultsFilteredByACLs(resp, false)
|
||||
header = resp.Header().Get("X-Consul-Results-Filtered-By-ACLs")
|
||||
if header != "" {
|
||||
t.Fatalf("Bad: %v", header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetLastContact(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
|
@ -291,23 +307,24 @@ func TestSetLastContact(t *testing.T) {
|
|||
func TestSetMeta(t *testing.T) {
|
||||
t.Parallel()
|
||||
meta := structs.QueryMeta{
|
||||
Index: 1000,
|
||||
KnownLeader: true,
|
||||
LastContact: 123456 * time.Microsecond,
|
||||
Index: 1000,
|
||||
KnownLeader: true,
|
||||
LastContact: 123456 * time.Microsecond,
|
||||
ResultsFilteredByACLs: true,
|
||||
}
|
||||
resp := httptest.NewRecorder()
|
||||
setMeta(resp, &meta)
|
||||
header := resp.Header().Get("X-Consul-Index")
|
||||
if header != "1000" {
|
||||
t.Fatalf("Bad: %v", header)
|
||||
|
||||
testCases := map[string]string{
|
||||
"X-Consul-Index": "1000",
|
||||
"X-Consul-KnownLeader": "true",
|
||||
"X-Consul-LastContact": "123",
|
||||
"X-Consul-Results-Filtered-By-ACLs": "true",
|
||||
}
|
||||
header = resp.Header().Get("X-Consul-KnownLeader")
|
||||
if header != "true" {
|
||||
t.Fatalf("Bad: %v", header)
|
||||
}
|
||||
header = resp.Header().Get("X-Consul-LastContact")
|
||||
if header != "123" {
|
||||
t.Fatalf("Bad: %v", header)
|
||||
for header, expectedValue := range testCases {
|
||||
if v := resp.Header().Get(header); v != expectedValue {
|
||||
t.Fatalf("expected %q for header %s got %q", expectedValue, header, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -45,6 +45,8 @@ type QueryMetaCompat interface {
|
|||
GetConsistencyLevel() string
|
||||
SetConsistencyLevel(string)
|
||||
GetBackend() QueryBackend
|
||||
GetResultsFilteredByACLs() bool
|
||||
SetResultsFilteredByACLs(bool)
|
||||
}
|
||||
|
||||
// GetToken helps implement the QueryOptionsCompat interface
|
||||
|
@ -274,3 +276,15 @@ func (q *QueryMeta) SetConsistencyLevel(consistencyLevel string) {
|
|||
func (q *QueryMeta) GetBackend() QueryBackend {
|
||||
return q.Backend
|
||||
}
|
||||
|
||||
// GetResultsFilteredByACLs is needed to implement the structs.QueryMetaCompat
|
||||
// interface.
|
||||
func (q *QueryMeta) GetResultsFilteredByACLs() bool {
|
||||
return q.ResultsFilteredByACLs
|
||||
}
|
||||
|
||||
// SetResultsFilteredByACLs is needed to implement the structs.QueryMetaCompat
|
||||
// interface.
|
||||
func (q *QueryMeta) SetResultsFilteredByACLs(v bool) {
|
||||
q.ResultsFilteredByACLs = v
|
||||
}
|
||||
|
|
|
@ -392,6 +392,11 @@ type QueryMeta struct {
|
|||
|
||||
// Backend used to handle this query, either blocking-query or streaming.
|
||||
Backend QueryBackend
|
||||
|
||||
// ResultsFilteredByACLs is true when some of the query's results were
|
||||
// filtered out by enforcing ACLs. It may be false because nothing was
|
||||
// removed, or because the endpoint does not yet support this flag.
|
||||
ResultsFilteredByACLs bool
|
||||
}
|
||||
|
||||
// RegisterRequest is used for the Catalog.Register endpoint
|
||||
|
|
|
@ -2449,10 +2449,11 @@ func TestSnapshotRequestResponse_MsgpackEncodeDecode(t *testing.T) {
|
|||
in := &SnapshotResponse{
|
||||
Error: "blah",
|
||||
QueryMeta: QueryMeta{
|
||||
Index: 3,
|
||||
LastContact: 5 * time.Second,
|
||||
KnownLeader: true,
|
||||
ConsistencyLevel: "default",
|
||||
Index: 3,
|
||||
LastContact: 5 * time.Second,
|
||||
KnownLeader: true,
|
||||
ConsistencyLevel: "default",
|
||||
ResultsFilteredByACLs: true,
|
||||
},
|
||||
}
|
||||
TestMsgpackEncodeDecode(t, in, true)
|
||||
|
|
13
api/api.go
13
api/api.go
|
@ -281,6 +281,11 @@ type QueryMeta struct {
|
|||
// defined policy. This can be "allow" which means ACLs are used to
|
||||
// deny-list, or "deny" which means ACLs are allow-lists.
|
||||
DefaultACLPolicy string
|
||||
|
||||
// ResultsFilteredByACLs is true when some of the query's results were
|
||||
// filtered out by enforcing ACLs. It may be false because nothing was
|
||||
// removed, or because the endpoint does not yet support this flag.
|
||||
ResultsFilteredByACLs bool
|
||||
}
|
||||
|
||||
// WriteMeta is used to return meta data about a write
|
||||
|
@ -1071,6 +1076,14 @@ func parseQueryMeta(resp *http.Response, q *QueryMeta) error {
|
|||
q.DefaultACLPolicy = v
|
||||
}
|
||||
|
||||
// Parse the X-Consul-Results-Filtered-By-ACLs
|
||||
switch header.Get("X-Consul-Results-Filtered-By-ACLs") {
|
||||
case "true":
|
||||
q.ResultsFilteredByACLs = true
|
||||
default:
|
||||
q.ResultsFilteredByACLs = false
|
||||
}
|
||||
|
||||
// Parse Cache info
|
||||
if cacheStr := header.Get("X-Cache"); cacheStr != "" {
|
||||
q.CacheHit = strings.EqualFold(cacheStr, "HIT")
|
||||
|
|
|
@ -932,6 +932,7 @@ func TestAPI_ParseQueryMeta(t *testing.T) {
|
|||
resp.Header.Set("X-Consul-KnownLeader", "true")
|
||||
resp.Header.Set("X-Consul-Translate-Addresses", "true")
|
||||
resp.Header.Set("X-Consul-Default-ACL-Policy", "deny")
|
||||
resp.Header.Set("X-Consul-Results-Filtered-By-ACLs", "true")
|
||||
|
||||
qm := &QueryMeta{}
|
||||
if err := parseQueryMeta(resp, qm); err != nil {
|
||||
|
@ -953,6 +954,9 @@ func TestAPI_ParseQueryMeta(t *testing.T) {
|
|||
if qm.DefaultACLPolicy != "deny" {
|
||||
t.Fatalf("Bad: %v", qm)
|
||||
}
|
||||
if !qm.ResultsFilteredByACLs {
|
||||
t.Fatalf("Bad: %v", qm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_UnixSocket(t *testing.T) {
|
||||
|
|
|
@ -391,6 +391,10 @@ type QueryMeta struct {
|
|||
// Having `discovery_max_stale` on the agent can affect whether
|
||||
// the request was served by a leader.
|
||||
ConsistencyLevel string `protobuf:"bytes,4,opt,name=ConsistencyLevel,proto3" json:"ConsistencyLevel,omitempty"`
|
||||
// ResultsFilteredByACLs is true when some of the query's results were
|
||||
// filtered out by enforcing ACLs. It may be false because nothing was
|
||||
// removed, or because the endpoint does not yet support this flag.
|
||||
ResultsFilteredByACLs bool `protobuf:"varint,7,opt,name=ResultsFilteredByACLs,proto3" json:"ResultsFilteredByACLs,omitempty"`
|
||||
}
|
||||
|
||||
func (m *QueryMeta) Reset() { *m = QueryMeta{} }
|
||||
|
@ -454,6 +458,13 @@ func (m *QueryMeta) GetConsistencyLevel() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func (m *QueryMeta) GetResultsFilteredByACLs() bool {
|
||||
if m != nil {
|
||||
return m.ResultsFilteredByACLs
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EnterpriseMeta contains metadata that is only used by the Enterprise version
|
||||
// of Consul.
|
||||
type EnterpriseMeta struct {
|
||||
|
@ -509,46 +520,49 @@ func init() {
|
|||
func init() { proto.RegisterFile("proto/pbcommon/common.proto", fileDescriptor_a6f5ac44994d718c) }
|
||||
|
||||
var fileDescriptor_a6f5ac44994d718c = []byte{
|
||||
// 620 bytes of a gzipped FileDescriptorProto
|
||||
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x94, 0x54, 0x41, 0x4f, 0xd4, 0x40,
|
||||
0x14, 0xde, 0xe2, 0xb2, 0x6e, 0xdf, 0x02, 0xc1, 0x09, 0x31, 0x15, 0x4d, 0x97, 0x6c, 0x8c, 0x21,
|
||||
0x44, 0xb7, 0x09, 0xde, 0xf0, 0x04, 0x0b, 0x1a, 0xe2, 0x56, 0x74, 0xc4, 0x90, 0x78, 0x9b, 0xed,
|
||||
0xbe, 0xed, 0x4e, 0x6c, 0x3b, 0x75, 0x3a, 0x85, 0xe5, 0x1f, 0x78, 0xf4, 0x48, 0x3c, 0xf9, 0x43,
|
||||
0xfc, 0x01, 0x1c, 0x39, 0x7a, 0x42, 0x65, 0xff, 0x81, 0xbf, 0xc0, 0x74, 0x5a, 0xa0, 0x08, 0x18,
|
||||
0x3c, 0xed, 0x7e, 0xdf, 0x7c, 0xdf, 0xeb, 0x9b, 0xf7, 0xbe, 0x16, 0xee, 0xc7, 0x52, 0x28, 0xe1,
|
||||
0xc4, 0x3d, 0x4f, 0x84, 0xa1, 0x88, 0x9c, 0xfc, 0xa7, 0xad, 0x59, 0x52, 0xcb, 0xd1, 0xbc, 0xed,
|
||||
0x0b, 0xe1, 0x07, 0xe8, 0x68, 0xb6, 0x97, 0x0e, 0x9c, 0x7e, 0x2a, 0x99, 0xe2, 0xa7, 0xba, 0xf9,
|
||||
0x39, 0x5f, 0xf8, 0x22, 0x2f, 0x94, 0xfd, 0xcb, 0xd9, 0x56, 0x08, 0x26, 0x65, 0x03, 0xb5, 0x19,
|
||||
0xf5, 0x71, 0x44, 0x1c, 0x68, 0x74, 0x24, 0x32, 0x85, 0x1a, 0x5a, 0xc6, 0x82, 0xb1, 0x58, 0x5d,
|
||||
0x9b, 0xfe, 0x7d, 0xdc, 0x34, 0x7b, 0x38, 0x8a, 0xe5, 0x4a, 0xeb, 0x49, 0x8b, 0x96, 0x15, 0x99,
|
||||
0xc1, 0x15, 0x7d, 0x3e, 0xd8, 0xcf, 0x0d, 0x13, 0x57, 0x1a, 0x4a, 0x8a, 0xd6, 0x32, 0xcc, 0x6e,
|
||||
0x33, 0xe9, 0xa3, 0x5a, 0x67, 0x8a, 0x79, 0x18, 0x29, 0x94, 0xc4, 0x06, 0x38, 0x47, 0xfa, 0xa1,
|
||||
0x26, 0x2d, 0x31, 0xad, 0x25, 0x98, 0xda, 0x91, 0x5c, 0x21, 0xc5, 0x8f, 0x29, 0x26, 0x8a, 0xcc,
|
||||
0xc1, 0xe4, 0xb6, 0xf8, 0x80, 0x51, 0x21, 0xcd, 0xc1, 0x4a, 0xf5, 0xd3, 0xd7, 0xa6, 0xd1, 0xda,
|
||||
0x81, 0x06, 0x45, 0xd6, 0xff, 0xa7, 0x94, 0x3c, 0x86, 0x3b, 0x99, 0x80, 0x4b, 0xec, 0x88, 0x28,
|
||||
0xe1, 0x89, 0xc2, 0x48, 0xe9, 0xde, 0xeb, 0xf4, 0xf2, 0x41, 0x51, 0xf8, 0x4b, 0x15, 0xa6, 0xde,
|
||||
0xa4, 0x28, 0xf7, 0xb7, 0xe2, 0x6c, 0xa6, 0xc9, 0x35, 0xa5, 0x1f, 0xc2, 0xb4, 0xcb, 0x23, 0x2d,
|
||||
0x2c, 0x8d, 0x84, 0x5e, 0x24, 0xc9, 0x0b, 0x98, 0x72, 0xd9, 0x48, 0x13, 0xdb, 0x3c, 0x44, 0xeb,
|
||||
0xd6, 0x82, 0xb1, 0xd8, 0x58, 0xbe, 0xd7, 0xce, 0x37, 0xd8, 0x3e, 0xdd, 0x60, 0x7b, 0xbd, 0xd8,
|
||||
0xe0, 0x5a, 0xfd, 0xf0, 0xb8, 0x59, 0x39, 0xf8, 0xd1, 0x34, 0xe8, 0x05, 0x63, 0x36, 0xba, 0xd5,
|
||||
0x20, 0x10, 0x7b, 0x6f, 0x15, 0x0b, 0xd0, 0xaa, 0xea, 0x2b, 0x94, 0x98, 0xab, 0x6f, 0x3a, 0x79,
|
||||
0xcd, 0x4d, 0xc9, 0x3c, 0xd4, 0xdf, 0x25, 0xd8, 0x61, 0xde, 0x10, 0xad, 0x9a, 0x16, 0x9d, 0x61,
|
||||
0xb2, 0x05, 0xb3, 0x2e, 0x1b, 0xe9, 0xaa, 0xa7, 0x5d, 0x59, 0xb7, 0x6f, 0xde, 0xf6, 0x25, 0x33,
|
||||
0x79, 0x06, 0x35, 0x97, 0x8d, 0x56, 0x7d, 0xb4, 0xea, 0x37, 0x2f, 0x53, 0x58, 0xc8, 0x23, 0x98,
|
||||
0x71, 0xd3, 0x44, 0x51, 0xdc, 0x65, 0x01, 0xef, 0x33, 0x85, 0x96, 0xa9, 0xfb, 0xfd, 0x8b, 0xcd,
|
||||
0x06, 0xad, 0x9f, 0xba, 0x39, 0xd8, 0x90, 0x52, 0x48, 0x0b, 0xfe, 0x63, 0xd0, 0x65, 0x23, 0xb9,
|
||||
0x0b, 0xb5, 0xe7, 0x3c, 0xc8, 0xf2, 0xd9, 0xd0, 0xeb, 0x2e, 0x50, 0x11, 0x8e, 0x6f, 0x06, 0x98,
|
||||
0x7a, 0x29, 0x2e, 0x2a, 0x96, 0x25, 0xa3, 0xf4, 0xfe, 0xd0, 0x1c, 0x90, 0x0d, 0x68, 0x74, 0x59,
|
||||
0xa2, 0x3a, 0x22, 0x52, 0xcc, 0xcb, 0xe3, 0x76, 0xc3, 0x4e, 0xca, 0x3e, 0xb2, 0x00, 0x8d, 0x97,
|
||||
0x91, 0xd8, 0x8b, 0xba, 0xc8, 0xfa, 0x28, 0x75, 0x72, 0xea, 0xb4, 0x4c, 0x91, 0x25, 0x98, 0x3d,
|
||||
0xdb, 0xa9, 0xb7, 0xdf, 0xc5, 0x5d, 0x0c, 0x74, 0x32, 0x4c, 0x7a, 0x89, 0x2f, 0xda, 0xef, 0xc2,
|
||||
0xcc, 0x46, 0xf6, 0xa6, 0xc5, 0x92, 0x27, 0xa8, 0xaf, 0xf0, 0x00, 0xcc, 0x57, 0x2c, 0xc4, 0x24,
|
||||
0x66, 0x1e, 0x16, 0x01, 0x3f, 0x27, 0xb2, 0xd3, 0xd7, 0x4c, 0x2a, 0xae, 0x43, 0x30, 0x91, 0x9f,
|
||||
0x9e, 0x11, 0x6b, 0xdd, 0xc3, 0x5f, 0x76, 0xe5, 0xf0, 0xc4, 0x36, 0x8e, 0x4e, 0x6c, 0xe3, 0xe7,
|
||||
0x89, 0x6d, 0x7c, 0x1e, 0xdb, 0x95, 0x83, 0xb1, 0x5d, 0x39, 0x1a, 0xdb, 0x95, 0xef, 0x63, 0xbb,
|
||||
0xf2, 0x7e, 0xc9, 0xe7, 0x6a, 0x98, 0xf6, 0xda, 0x9e, 0x08, 0x9d, 0x21, 0x4b, 0x86, 0xdc, 0x13,
|
||||
0x32, 0x76, 0x3c, 0x11, 0x25, 0x69, 0xe0, 0x5c, 0xfc, 0xd4, 0xf5, 0x6a, 0x1a, 0x3f, 0xfd, 0x13,
|
||||
0x00, 0x00, 0xff, 0xff, 0x9c, 0xf6, 0xbd, 0xcc, 0x03, 0x05, 0x00, 0x00,
|
||||
// 657 bytes of a gzipped FileDescriptorProto
|
||||
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x94, 0x54, 0xcd, 0x4e, 0xdb, 0x4a,
|
||||
0x18, 0x8d, 0xb9, 0x21, 0xd8, 0x13, 0x40, 0xb9, 0x23, 0xee, 0x95, 0x2f, 0xb7, 0x72, 0x90, 0x55,
|
||||
0x55, 0x08, 0xb5, 0xb1, 0x44, 0xbb, 0xa2, 0x2b, 0x12, 0x68, 0x05, 0x8d, 0x4b, 0x3b, 0xa5, 0x42,
|
||||
0xea, 0x6e, 0x62, 0x7f, 0x71, 0xac, 0x3a, 0x1e, 0x77, 0x66, 0x0c, 0xc9, 0x1b, 0x74, 0xd9, 0x25,
|
||||
0xea, 0xaa, 0x8f, 0xc3, 0x92, 0x65, 0x57, 0xb4, 0x25, 0x6f, 0xd0, 0x07, 0xa8, 0x2a, 0x8f, 0x0d,
|
||||
0x98, 0x02, 0x15, 0x5d, 0x25, 0xe7, 0xcc, 0x39, 0xdf, 0x7c, 0x7f, 0x63, 0xf4, 0x7f, 0xc2, 0x99,
|
||||
0x64, 0x4e, 0xd2, 0xf3, 0xd8, 0x70, 0xc8, 0x62, 0x27, 0xff, 0x69, 0x29, 0x16, 0xd7, 0x72, 0xb4,
|
||||
0x68, 0x05, 0x8c, 0x05, 0x11, 0x38, 0x8a, 0xed, 0xa5, 0x7d, 0xc7, 0x4f, 0x39, 0x95, 0xe1, 0x99,
|
||||
0x6e, 0x71, 0x21, 0x60, 0x01, 0xcb, 0x03, 0x65, 0xff, 0x72, 0xd6, 0x1e, 0x22, 0x83, 0xd0, 0xbe,
|
||||
0xdc, 0x8a, 0x7d, 0x18, 0x61, 0x07, 0xd5, 0x3b, 0x1c, 0xa8, 0x04, 0x05, 0x4d, 0x6d, 0x49, 0x5b,
|
||||
0xae, 0xb6, 0xe7, 0xbe, 0x9f, 0x34, 0x8d, 0x1e, 0x8c, 0x12, 0xbe, 0x66, 0x3f, 0xb0, 0x49, 0x59,
|
||||
0x91, 0x19, 0x5c, 0xe6, 0x87, 0xfd, 0x71, 0x6e, 0x98, 0xba, 0xd6, 0x50, 0x52, 0xd8, 0xab, 0xa8,
|
||||
0xb1, 0x4b, 0x79, 0x00, 0x72, 0x83, 0x4a, 0xea, 0x41, 0x2c, 0x81, 0x63, 0x0b, 0xa1, 0x0b, 0xa4,
|
||||
0x2e, 0x35, 0x48, 0x89, 0xb1, 0x57, 0xd0, 0xec, 0x1e, 0x0f, 0x25, 0x10, 0x78, 0x97, 0x82, 0x90,
|
||||
0x78, 0x01, 0x4d, 0xef, 0xb2, 0xb7, 0x10, 0x17, 0xd2, 0x1c, 0xac, 0x55, 0xdf, 0x7f, 0x6a, 0x6a,
|
||||
0xf6, 0x1e, 0xaa, 0x13, 0xa0, 0xfe, 0x6f, 0xa5, 0xf8, 0x3e, 0xfa, 0x3b, 0x13, 0x84, 0x1c, 0x3a,
|
||||
0x2c, 0x16, 0xa1, 0x90, 0x10, 0x4b, 0x95, 0xbb, 0x4e, 0xae, 0x1e, 0x14, 0x81, 0x3f, 0x56, 0xd1,
|
||||
0xec, 0xcb, 0x14, 0xf8, 0x78, 0x27, 0xc9, 0x7a, 0x2a, 0x6e, 0x08, 0x7d, 0x17, 0xcd, 0xb9, 0x61,
|
||||
0xac, 0x84, 0xa5, 0x96, 0x90, 0xcb, 0x24, 0x7e, 0x8a, 0x66, 0x5d, 0x3a, 0x52, 0xc4, 0x6e, 0x38,
|
||||
0x04, 0xf3, 0xaf, 0x25, 0x6d, 0xb9, 0xbe, 0xfa, 0x5f, 0x2b, 0x9f, 0x60, 0xeb, 0x6c, 0x82, 0xad,
|
||||
0x8d, 0x62, 0x82, 0x6d, 0xfd, 0xe8, 0xa4, 0x59, 0x39, 0xfc, 0xd2, 0xd4, 0xc8, 0x25, 0x63, 0xd6,
|
||||
0xba, 0xf5, 0x28, 0x62, 0x07, 0xaf, 0x24, 0x8d, 0xc0, 0xac, 0xaa, 0x12, 0x4a, 0xcc, 0xf5, 0x95,
|
||||
0x4e, 0xdf, 0x50, 0x29, 0x5e, 0x44, 0xfa, 0x6b, 0x01, 0x1d, 0xea, 0x0d, 0xc0, 0xac, 0x29, 0xd1,
|
||||
0x39, 0xc6, 0x3b, 0xa8, 0xe1, 0xd2, 0x91, 0x8a, 0x7a, 0x96, 0x95, 0x39, 0x73, 0xfb, 0xb4, 0xaf,
|
||||
0x98, 0xf1, 0x63, 0x54, 0x73, 0xe9, 0x68, 0x3d, 0x00, 0x53, 0xbf, 0x7d, 0x98, 0xc2, 0x82, 0xef,
|
||||
0xa1, 0x79, 0x37, 0x15, 0x92, 0xc0, 0x3e, 0x8d, 0x42, 0x9f, 0x4a, 0x30, 0x0d, 0x95, 0xef, 0x2f,
|
||||
0x6c, 0xd6, 0x68, 0x75, 0xeb, 0x56, 0x7f, 0x93, 0x73, 0xc6, 0x4d, 0xf4, 0x07, 0x8d, 0x2e, 0x1b,
|
||||
0xf1, 0xbf, 0xa8, 0xf6, 0x24, 0x8c, 0xb2, 0xfd, 0xac, 0xab, 0x71, 0x17, 0xa8, 0x58, 0x8e, 0x1f,
|
||||
0x1a, 0x32, 0xd4, 0x50, 0x5c, 0x90, 0x34, 0xdb, 0x8c, 0xd2, 0xfb, 0x21, 0x39, 0xc0, 0x9b, 0xa8,
|
||||
0xde, 0xa5, 0x42, 0x76, 0x58, 0x2c, 0xa9, 0x97, 0xaf, 0xdb, 0x2d, 0x33, 0x29, 0xfb, 0xf0, 0x12,
|
||||
0xaa, 0x3f, 0x8b, 0xd9, 0x41, 0xdc, 0x05, 0xea, 0x03, 0x57, 0x9b, 0xa3, 0x93, 0x32, 0x85, 0x57,
|
||||
0x50, 0xe3, 0x7c, 0xa6, 0xde, 0xb8, 0x0b, 0xfb, 0x10, 0xa9, 0xcd, 0x30, 0xc8, 0x15, 0x1e, 0x3f,
|
||||
0x42, 0xff, 0x10, 0x10, 0x69, 0x24, 0x45, 0x5e, 0x0f, 0xf8, 0xed, 0xf1, 0x7a, 0xa7, 0x2b, 0xd4,
|
||||
0x68, 0x75, 0x72, 0xfd, 0x61, 0x5e, 0xf4, 0x76, 0x55, 0x9f, 0x6e, 0xd4, 0xb6, 0xab, 0x7a, 0xad,
|
||||
0x31, 0x63, 0x77, 0xd1, 0xfc, 0x66, 0xf6, 0x56, 0x13, 0x1e, 0x0a, 0x50, 0x4d, 0xb8, 0x83, 0x8c,
|
||||
0xe7, 0x74, 0x08, 0x22, 0xa1, 0x1e, 0x14, 0x4f, 0xe4, 0x82, 0xc8, 0x4e, 0x5f, 0x50, 0x2e, 0x43,
|
||||
0xb5, 0x46, 0x53, 0xf9, 0xe9, 0x39, 0xd1, 0xee, 0x1e, 0x7d, 0xb3, 0x2a, 0x47, 0xa7, 0x96, 0x76,
|
||||
0x7c, 0x6a, 0x69, 0x5f, 0x4f, 0x2d, 0xed, 0xc3, 0xc4, 0xaa, 0x1c, 0x4e, 0xac, 0xca, 0xf1, 0xc4,
|
||||
0xaa, 0x7c, 0x9e, 0x58, 0x95, 0x37, 0x2b, 0x41, 0x28, 0x07, 0x69, 0xaf, 0xe5, 0xb1, 0xa1, 0x33,
|
||||
0xa0, 0x62, 0x10, 0x7a, 0x8c, 0x27, 0x8e, 0xc7, 0x62, 0x91, 0x46, 0xce, 0xe5, 0x8f, 0x65, 0xaf,
|
||||
0xa6, 0xf0, 0xc3, 0x9f, 0x01, 0x00, 0x00, 0xff, 0xff, 0xab, 0xfa, 0x4f, 0xec, 0x45, 0x05, 0x00,
|
||||
0x00,
|
||||
}
|
||||
|
||||
func (m *RaftIndex) Marshal() (dAtA []byte, err error) {
|
||||
|
@ -818,6 +832,16 @@ func (m *QueryMeta) MarshalToSizedBuffer(dAtA []byte) (int, error) {
|
|||
_ = i
|
||||
var l int
|
||||
_ = l
|
||||
if m.ResultsFilteredByACLs {
|
||||
i--
|
||||
if m.ResultsFilteredByACLs {
|
||||
dAtA[i] = 1
|
||||
} else {
|
||||
dAtA[i] = 0
|
||||
}
|
||||
i--
|
||||
dAtA[i] = 0x38
|
||||
}
|
||||
if len(m.ConsistencyLevel) > 0 {
|
||||
i -= len(m.ConsistencyLevel)
|
||||
copy(dAtA[i:], m.ConsistencyLevel)
|
||||
|
@ -1014,6 +1038,9 @@ func (m *QueryMeta) Size() (n int) {
|
|||
if l > 0 {
|
||||
n += 1 + l + sovCommon(uint64(l))
|
||||
}
|
||||
if m.ResultsFilteredByACLs {
|
||||
n += 2
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
|
@ -1872,6 +1899,26 @@ func (m *QueryMeta) Unmarshal(dAtA []byte) error {
|
|||
}
|
||||
m.ConsistencyLevel = string(dAtA[iNdEx:postIndex])
|
||||
iNdEx = postIndex
|
||||
case 7:
|
||||
if wireType != 0 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field ResultsFilteredByACLs", wireType)
|
||||
}
|
||||
var v int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflowCommon
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
v |= int(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
m.ResultsFilteredByACLs = bool(v != 0)
|
||||
default:
|
||||
iNdEx = preIndex
|
||||
skippy, err := skipCommon(dAtA[iNdEx:])
|
||||
|
|
|
@ -150,6 +150,14 @@ message QueryMeta {
|
|||
// Having `discovery_max_stale` on the agent can affect whether
|
||||
// the request was served by a leader.
|
||||
string ConsistencyLevel = 4;
|
||||
|
||||
// Reserved for NotModified and Backend.
|
||||
reserved 5, 6;
|
||||
|
||||
// ResultsFilteredByACLs is true when some of the query's results were
|
||||
// filtered out by enforcing ACLs. It may be false because nothing was
|
||||
// removed, or because the endpoint does not yet support this flag.
|
||||
bool ResultsFilteredByACLs = 7;
|
||||
}
|
||||
|
||||
// EnterpriseMeta contains metadata that is only used by the Enterprise version
|
||||
|
@ -159,4 +167,4 @@ message EnterpriseMeta {
|
|||
string Namespace = 1;
|
||||
// Partition in which the entity exists.
|
||||
string Partition = 2;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue