Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add backend Service and Cache support for SPIFFE Federation resource #45054

Merged
merged 16 commits into from
Aug 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api/client/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ func EventToGRPC(in types.Event) (*proto.Event, error) {
out.Resource = &proto.Event_AccessGraphSettings{
AccessGraphSettings: r,
}
case *machineidv1.SPIFFEFederation:
out.Resource = &proto.Event_SPIFFEFederation{
SPIFFEFederation: r,
}
default:
return nil, trace.BadParameter("resource type %T is not supported", r)
}
Expand Down Expand Up @@ -527,6 +531,9 @@ func EventFromGRPC(in *proto.Event) (*types.Event, error) {
} else if r := in.GetAccessGraphSettings(); r != nil {
out.Resource = types.Resource153ToLegacy(r)
return &out, nil
} else if r := in.GetSPIFFEFederation(); r != nil {
out.Resource = types.Resource153ToLegacy(r)
return &out, nil
} else {
return nil, trace.BadParameter("received unsupported resource %T", in.Resource)
}
Expand Down
580 changes: 303 additions & 277 deletions api/client/proto/event.pb.go

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions api/proto/teleport/legacy/client/proto/event.proto
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import "teleport/discoveryconfig/v1/discoveryconfig.proto";
import "teleport/kubewaitingcontainer/v1/kubewaitingcontainer.proto";
import "teleport/legacy/types/types.proto";
import "teleport/machineid/v1/bot_instance.proto";
import "teleport/machineid/v1/federation.proto";
import "teleport/notifications/v1/notifications.proto";
import "teleport/secreports/v1/secreports.proto";
import "teleport/userloginstate/v1/userloginstate.proto";
Expand Down Expand Up @@ -171,5 +172,7 @@ message Event {
teleport.machineid.v1.BotInstance BotInstance = 60;
// AccessGraphSettings is a resource for access graph settings.
teleport.clusterconfig.v1.AccessGraphSettings AccessGraphSettings = 61;
// SPIFFEFederation is a resource for SPIFFE federation.
teleport.machineid.v1.SPIFFEFederation SPIFFEFederation = 62;
}
}
3 changes: 3 additions & 0 deletions integrations/event-handler/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ require (
github.com/go-errors/errors v1.4.2 // indirect
github.com/go-gorp/gorp/v3 v3.1.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another go-jose version 😢

github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
Expand Down Expand Up @@ -246,6 +247,7 @@ require (
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/cobra v1.8.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spiffe/go-spiffe/v2 v2.3.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/thales-e-security/pool v0.0.2 // indirect
github.com/vulcand/predicate v1.2.0 // indirect
Expand All @@ -257,6 +259,7 @@ require (
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
github.com/zeebo/errs v1.3.0 // indirect
github.com/zmap/zcrypto v0.0.0-20231219022726-a1f61fb1661c // indirect
github.com/zmap/zlint/v3 v3.6.0 // indirect
go.mongodb.org/mongo-driver v1.14.0 // indirect
Expand Down
6 changes: 6 additions & 0 deletions integrations/event-handler/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,8 @@ github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs
github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw=
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U=
github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk=
Expand Down Expand Up @@ -1539,6 +1541,8 @@ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spiffe/go-spiffe/v2 v2.3.0 h1:g2jYNb/PDMB8I7mBGL2Zuq/Ur6hUhoroxGQFyD6tTj8=
github.com/spiffe/go-spiffe/v2 v2.3.0/go.mod h1:Oxsaio7DBgSNqhAO9i/9tLClaVlfRok7zvJnTV8ZyIY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
Expand Down Expand Up @@ -1602,6 +1606,8 @@ github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPS
github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f h1:ERexzlUfuTvpE74urLSbIQW0Z/6hF9t8U4NsJLaioAY=
github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/errs v1.3.0 h1:hmiaKqgYZzcVgRL1Vkc1Mn2914BbzB0IBxs+ebeutGs=
github.com/zeebo/errs v1.3.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE=
github.com/zmap/rc2 v0.0.0-20190804163417-abaa70531248/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE=
Expand Down
2 changes: 2 additions & 0 deletions integrations/terraform/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ require (
github.com/go-errors/errors v1.4.2 // indirect
github.com/go-gorp/gorp/v3 v3.1.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
Expand Down Expand Up @@ -316,6 +317,7 @@ require (
github.com/yuin/goldmark-meta v1.1.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zclconf/go-cty v1.14.4 // indirect
github.com/zeebo/errs v1.3.0 // indirect
github.com/zmap/zcrypto v0.0.0-20231219022726-a1f61fb1661c // indirect
github.com/zmap/zlint/v3 v3.6.0 // indirect
go.abhg.dev/goldmark/frontmatter v0.2.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions lib/auth/accesspoint/accesspoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ type Config struct {
SAMLIdPSession services.SAMLIdPSession
SecReports services.SecReports
SnowflakeSession services.SnowflakeSession
SPIFFEFederations cache.SPIFFEFederationReader
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any special reason to store the interface in the cache package

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the cache's dependency. In this case, had I used services.SPIFFEFederations, I'd be requiring methods which are not required by the cache to function (e.g the write side)

I'd usually have this kind of interfaces unexported, but, there's a bit of a weird case here where the functionality in accesspoint really feels like it belongs elsewhere. It's essentially a weird extension of the cache package itself. I'd considered redeclaring SPIFFEFederationsReader within accesspoint, but, I think that there's a better solution down the road (e.g a accesspoint.UpstreamReaders struct which holds the interfaces that the cache uses to populate).

Largely, I'm trying to play with the idea that the services.FoosService interfaces are unnecessary and problematic. We've see the symptoms of this across our codebase: weirdly named interfaces with "Ext" or "Internal" and methods that just return NotImplemented hung off "wrapper" structs that embed some other type that only partially implements an interface.

I thought about having services.SPIFFEFederationsReader, but it seems to really be the same problem, we should be aiming to create interfaces where they are consumed, not where they are implemented, and to have these interfaces declare the minimum amount of methods. If we think it's not the right time to start solving this problem, then I'm happy to create services.SPIFFEFederationsReader and use that, but I feel like that's just prolonging us ever solving this problem.

Trust services.Trust
UserGroups services.UserGroups
UserLoginStates services.UserLoginStates
Expand Down Expand Up @@ -184,6 +185,7 @@ func NewCache(cfg Config) (*cache.Cache, error) {
SAMLIdPSession: cfg.SAMLIdPSession,
SecReports: cfg.SecReports,
SnowflakeSession: cfg.SnowflakeSession,
SPIFFEFederations: cfg.SPIFFEFederations,
Trust: cfg.Trust,
UserGroups: cfg.UserGroups,
UserLoginStates: cfg.UserLoginStates,
Expand Down
8 changes: 8 additions & 0 deletions lib/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,12 @@ func NewServer(cfg *InitConfig, opts ...ServerOption) (*Server, error) {
return nil, trace.Wrap(err)
}
}
if cfg.SPIFFEFederations == nil {
cfg.SPIFFEFederations, err = local.NewSPIFFEFederationService(cfg.Backend)
if err != nil {
return nil, trace.Wrap(err, "creating SPIFFEFederation service")
}
}

limiter, err := limiter.NewConnectionsLimiter(limiter.Config{
MaxConnections: defaults.LimiterMaxConcurrentSignatures,
Expand Down Expand Up @@ -427,6 +433,7 @@ func NewServer(cfg *InitConfig, opts ...ServerOption) (*Server, error) {
AccessMonitoringRules: cfg.AccessMonitoringRules,
CrownJewels: cfg.CrownJewels,
BotInstance: cfg.BotInstance,
SPIFFEFederations: cfg.SPIFFEFederations,
}

as := Server{
Expand Down Expand Up @@ -623,6 +630,7 @@ type Services struct {
services.BotInstance
services.AccessGraphSecretsGetter
services.DevicesGetter
services.SPIFFEFederations
}

// GetWebSession returns existing web session described by req.
Expand Down
7 changes: 7 additions & 0 deletions lib/auth/authclient/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
crownjewelv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/crownjewel/v1"
integrationpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1"
kubewaitingcontainerpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/kubewaitingcontainer/v1"
machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
userspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/users/v1"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/types/accesslist"
Expand Down Expand Up @@ -1176,6 +1177,12 @@ type Cache interface {

// GetAccessGraphSettings returns the access graph settings.
GetAccessGraphSettings(context.Context) (*clusterconfigpb.AccessGraphSettings, error)

// GetSPIFFEFederation gets a SPIFFE Federation by name.
GetSPIFFEFederation(ctx context.Context, name string) (*machineidv1.SPIFFEFederation, error)
// ListSPIFFEFederations lists all SPIFFE Federations using Google style
// pagination.
ListSPIFFEFederations(ctx context.Context, pageSize int, lastToken string) ([]*machineidv1.SPIFFEFederation, string, error)
}

type NodeWrapper struct {
Expand Down
1 change: 1 addition & 0 deletions lib/auth/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ func NewTestAuthServer(cfg TestAuthServerConfig) (*TestAuthServer, error) {
SAMLIdPSession: svces.Identity,
SecReports: svces.SecReports,
SnowflakeSession: svces.Identity,
SPIFFEFederations: svces.SPIFFEFederations,
Trust: svces.TrustInternal,
UserGroups: svces.UserGroups,
UserLoginStates: svces.UserLoginStates,
Expand Down
3 changes: 3 additions & 0 deletions lib/auth/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,9 @@ type InitConfig struct {

// BotInstance is a service that manages Machine ID bot instances
BotInstance services.BotInstance

// SPIFFEFederations is a service that manages storing SPIFFE federations.
SPIFFEFederations services.SPIFFEFederations
}

// Init instantiates and configures an instance of AuthServer
Expand Down
11 changes: 11 additions & 0 deletions lib/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ func ForAuth(cfg Config) Config {
{Kind: types.KindAccessMonitoringRule},
{Kind: types.KindDatabaseObject},
{Kind: types.KindAccessGraphSettings},
{Kind: types.KindSPIFFEFederation},
}
cfg.QueueSize = defaults.AuthQueueSize
// We don't want to enable partial health for auth cache because auth uses an event stream
Expand Down Expand Up @@ -519,6 +520,7 @@ type Cache struct {
kubeWaitingContsCache *local.KubeWaitingContainerService
notificationsCache services.Notifications
accessMontoringRuleCache services.AccessMonitoringRules
spiffeFederationCache spiffeFederationCacher

// closed indicates that the cache has been closed
closed atomic.Bool
Expand Down Expand Up @@ -691,6 +693,8 @@ type Config struct {
Notifications services.Notifications
// AccessMonitoringRules is the access monitoring rules service.
AccessMonitoringRules services.AccessMonitoringRules
// SPIFFEFederations is the SPIFFE federations service.
SPIFFEFederations SPIFFEFederationReader
// Backend is a backend for local cache
Backend backend.Backend
// MaxRetryPeriod is the maximum period between cache retries on failures
Expand Down Expand Up @@ -926,6 +930,12 @@ func New(config Config) (*Cache, error) {
return nil, trace.Wrap(err)
}

spiffeFederationCache, err := local.NewSPIFFEFederationService(config.Backend)
if err != nil {
cancel()
return nil, trace.Wrap(err)
}

cs := &Cache{
ctx: ctx,
cancel: cancel,
Expand Down Expand Up @@ -966,6 +976,7 @@ func New(config Config) (*Cache, error) {
eventsFanout: fanout,
lowVolumeEventsFanout: utils.NewRoundRobin(lowVolumeFanouts),
kubeWaitingContsCache: kubeWaitingContsCache,
spiffeFederationCache: spiffeFederationCache,
Logger: log.WithFields(log.Fields{
teleport.ComponentKey: config.Component,
}),
Expand Down
13 changes: 13 additions & 0 deletions lib/cache/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ type testPack struct {
accessMonitoringRules services.AccessMonitoringRules
crownJewels services.CrownJewels
databaseObjects services.DatabaseObjects
spiffeFederations *local.SPIFFEFederationService
}

// testFuncs are functions to support testing an object in a cache.
Expand Down Expand Up @@ -325,6 +326,12 @@ func newPackWithoutCache(dir string, opts ...packOption) (*testPack, error) {
}
p.crownJewels = crownJewelsSvc

spiffeFederationsSvc, err := local.NewSPIFFEFederationService(p.backend)
if err != nil {
return nil, trace.Wrap(err)
}
p.spiffeFederations = spiffeFederationsSvc

databaseObjectsSvc, err := local.NewDatabaseObjectService(p.backend)
if err != nil {
return nil, trace.Wrap(err)
Expand Down Expand Up @@ -387,6 +394,7 @@ func newPack(dir string, setupConfig func(c Config) Config, opts ...packOption)
Notifications: p.notifications,
AccessMonitoringRules: p.accessMonitoringRules,
CrownJewels: p.crownJewels,
SPIFFEFederations: p.spiffeFederations,
DatabaseObjects: p.databaseObjects,
MaxRetryPeriod: 200 * time.Millisecond,
EventsC: p.eventsC,
Expand Down Expand Up @@ -791,6 +799,7 @@ func TestCompletenessInit(t *testing.T) {
AccessMonitoringRules: p.accessMonitoringRules,
CrownJewels: p.crownJewels,
DatabaseObjects: p.databaseObjects,
SPIFFEFederations: p.spiffeFederations,
MaxRetryPeriod: 200 * time.Millisecond,
EventsC: p.eventsC,
}))
Expand Down Expand Up @@ -868,6 +877,7 @@ func TestCompletenessReset(t *testing.T) {
AccessMonitoringRules: p.accessMonitoringRules,
CrownJewels: p.crownJewels,
DatabaseObjects: p.databaseObjects,
SPIFFEFederations: p.spiffeFederations,
MaxRetryPeriod: 200 * time.Millisecond,
EventsC: p.eventsC,
}))
Expand Down Expand Up @@ -1057,6 +1067,7 @@ func TestListResources_NodesTTLVariant(t *testing.T) {
AccessMonitoringRules: p.accessMonitoringRules,
CrownJewels: p.crownJewels,
DatabaseObjects: p.databaseObjects,
SPIFFEFederations: p.spiffeFederations,
MaxRetryPeriod: 200 * time.Millisecond,
EventsC: p.eventsC,
neverOK: true, // ensure reads are never healthy
Expand Down Expand Up @@ -1145,6 +1156,7 @@ func initStrategy(t *testing.T) {
AccessMonitoringRules: p.accessMonitoringRules,
CrownJewels: p.crownJewels,
DatabaseObjects: p.databaseObjects,
SPIFFEFederations: p.spiffeFederations,
MaxRetryPeriod: 200 * time.Millisecond,
EventsC: p.eventsC,
}))
Expand Down Expand Up @@ -3220,6 +3232,7 @@ func TestCacheWatchKindExistsInEvents(t *testing.T) {
types.KindCrownJewel: types.Resource153ToLegacy(newCrownJewel(t, "test")),
types.KindDatabaseObject: types.Resource153ToLegacy(newDatabaseObject(t, "test")),
types.KindAccessGraphSettings: types.Resource153ToLegacy(newAccessGraphSettings(t)),
types.KindSPIFFEFederation: types.Resource153ToLegacy(newSPIFFEFederation("test")),
}

for name, cfg := range cases {
Expand Down
11 changes: 11 additions & 0 deletions lib/cache/collections.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
crownjewelv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/crownjewel/v1"
dbobjectv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobject/v1"
kubewaitingcontainerpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/kubewaitingcontainer/v1"
machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
notificationsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/notifications/v1"
userspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/users/v1"
"github.com/gravitational/teleport/api/types"
Expand Down Expand Up @@ -250,6 +251,7 @@ type cacheCollections struct {
accessGraphSettings collectionReader[accessGraphSettingsGetter]
globalNotifications collectionReader[notificationGetter]
accessMonitoringRules collectionReader[accessMonitoringRuleGetter]
spiffeFederations collectionReader[SPIFFEFederationReader]
}

// setupCollections returns a registry of collections.
Expand Down Expand Up @@ -742,6 +744,15 @@ func setupCollections(c *Cache, watches []types.WatchKind) (*cacheCollections, e
watch: watch,
}
collections.byKind[resourceKind] = collections.accessGraphSettings
case types.KindSPIFFEFederation:
if c.Config.SPIFFEFederations == nil {
return nil, trace.BadParameter("missing parameter SPIFFEFederations")
}
collections.spiffeFederations = &genericCollection[*machineidv1.SPIFFEFederation, SPIFFEFederationReader, spiffeFederationExecutor]{
cache: c,
watch: watch,
}
collections.byKind[resourceKind] = collections.spiffeFederations
default:
return nil, trace.BadParameter("resource %q is not supported", watch.Kind)
}
Expand Down
Loading
Loading